diff --git a/.agents/skills/release-beta/SKILL.md b/.agents/skills/release-beta/SKILL.md index e763b97a7..fa8b72928 100644 --- a/.agents/skills/release-beta/SKILL.md +++ b/.agents/skills/release-beta/SKILL.md @@ -8,4 +8,6 @@ user-invocable: true Read `docs/release.md` in the Paseo repo and follow the **Beta flow** section end-to-end. Run the **Beta release** completion checklist at the bottom of that doc. -Key rules the doc enforces — each beta updates an in-place `CHANGELOG.md` entry (`## X.Y.Z-beta.N`) that gets overwritten at promotion (never leave a stale `-beta.N` heading behind), and betas publish npm only with the explicit `beta` dist-tag. +During preparation, classify the previous-stable-to-`HEAD` diff as patch or minor and show the target version and rationale to the user. Agents never select a major version autonomously. + +Each beta updates an in-place `CHANGELOG.md` entry (`## X.Y.Z-beta.N`) that gets overwritten at promotion, and npm publishes only on the explicit `beta` dist-tag. diff --git a/.agents/skills/release-stable/SKILL.md b/.agents/skills/release-stable/SKILL.md index f1dd8508e..9755e33e3 100644 --- a/.agents/skills/release-stable/SKILL.md +++ b/.agents/skills/release-stable/SKILL.md @@ -1,11 +1,13 @@ --- name: release-stable -description: Cut a stable release of Paseo (fresh patch or promote from beta). Use when the user says "release stable", "ship stable", "promote", "release:patch", "release:promote", or "/release-stable". +description: Cut a stable release of Paseo (fresh patch or minor, or promote from beta). Use when the user says "release stable", "ship stable", "promote", "release:patch", "release:minor", "release:promote", or "/release-stable". user-invocable: true --- # Release stable -Read `docs/release.md` in the Paseo repo and follow the **Standard release (patch)** flow if cutting fresh, or the **Beta flow** promotion step if promoting an existing beta. Run the **Stable release (or promotion)** completion checklist at the bottom of that doc. +Read `docs/release.md` in the Paseo repo and follow the **Standard release (stable)** flow if cutting fresh, or the **Beta flow** promotion step if promoting an existing beta. Run the **Stable release (or promotion)** completion checklist at the bottom of that doc. -The doc covers the changelog (required for stable), the pre-release sanity check (required for stable), and the post-release babysit pattern. Don't skip steps. +For a fresh release, classify the previous-stable-to-`HEAD` diff as patch or minor and show the target version and rationale to the user. Agents never select a major version autonomously. + +The doc covers the changelog, pre-release sanity check, and post-release babysit pattern. Don't skip steps. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eaf76408d..fd0ec3112 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -161,10 +161,29 @@ jobs: - name: Run desktop tests run: npm run test --workspace=@getpaseo/desktop + - name: Build app dependencies for desktop E2E + if: matrix.os == 'ubuntu-latest' + run: npm run build:app-deps + - name: Install virtual display - if: matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true' + if: matrix.os == 'ubuntu-latest' run: sudo apt-get update && sudo apt-get install -y xvfb xauth + - name: Run real Electron browser tab bridge E2E + if: matrix.os == 'ubuntu-latest' + run: npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop + env: + PASEO_TAB_BRIDGE_E2E_ARTIFACT_DIR: ${{ runner.temp }}/browser-tab-bridge-e2e + + - name: Upload browser tab bridge diagnostics + uses: actions/upload-artifact@v4 + if: failure() && matrix.os == 'ubuntu-latest' + with: + name: browser-tab-bridge-e2e + path: ${{ runner.temp }}/browser-tab-bridge-e2e + if-no-files-found: ignore + retention-days: 7 + - name: Build and smoke unpacked desktop app if: matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true' run: npm run build:desktop -- --publish never --linux --x64 --dir diff --git a/.oxlintrc.json b/.oxlintrc.json index 9e389e515..bdcab1dc5 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -87,6 +87,9 @@ { "files": ["packages/app/src/**/*.{ts,tsx}"], "rules": { + // React Native style arrays must read Unistyles proxies during render. Hoisting them to + // satisfy this allocation rule captures the temporary startup theme instead. + "react-perf/jsx-no-new-array-as-prop": "off", "no-restricted-imports": [ "error", { diff --git a/CHANGELOG.md b/CHANGELOG.md index b9af2fc50..2bd2895dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## 0.2.0-beta.1 - 2026-07-17 + +### Added + +- Work with pull requests and merge requests from GitLab, Gitea, Forgejo, and Codeberg ([#1913](https://github.com/getpaseo/paseo/pull/1913) by [@nllptrx](https://github.com/nllptrx)) +- Use Oh My Pi as a native agent provider ([#2067](https://github.com/getpaseo/paseo/pull/2067) by [@ebg1223](https://github.com/ebg1223)) +- Browse branch commit history and open individual commit diffs from Changes ([#1534](https://github.com/getpaseo/paseo/pull/1534), [#2146](https://github.com/getpaseo/paseo/pull/2146) by [@adradr](https://github.com/adradr)) +- Open workspace files in more installed editors and file managers ([#2119](https://github.com/getpaseo/paseo/pull/2119)) +- Remove individual custom providers from Settings ([#1951](https://github.com/getpaseo/paseo/pull/1951)) + +### Improved + +- ACP provider catalog updated to the latest registry versions +- Workspace focus mode stays confined to the active workspace with a visible exit control ([#2151](https://github.com/getpaseo/paseo/pull/2151)) +- Desktop installs the newest available update instead of a cached older release ([#2149](https://github.com/getpaseo/paseo/pull/2149)) +- Remote daemon update failures now show specific recovery steps ([#2120](https://github.com/getpaseo/paseo/pull/2120)) +- Agent history errors now appear immediately instead of after a timeout ([#2124](https://github.com/getpaseo/paseo/pull/2124)) + +### Fixed + +- Terminal panes no longer remain at 80x24 after focus or visibility changes ([#2059](https://github.com/getpaseo/paseo/pull/2059), [#2154](https://github.com/getpaseo/paseo/pull/2154) by [@cleiter](https://github.com/cleiter)) +- Sign-in popups in the desktop browser now complete successfully ([#2137](https://github.com/getpaseo/paseo/pull/2137)) +- Browser typing and shortcuts no longer submit the active Paseo prompt ([#1982](https://github.com/getpaseo/paseo/pull/1982)) +- Agent browser tabs remain controllable after switching workspaces ([#2156](https://github.com/getpaseo/paseo/pull/2156)) +- Archived workspaces now show the correct Unarchive or Restore action ([#2002](https://github.com/getpaseo/paseo/pull/2002)) +- Archived sessions can be reimported into the current workspace ([#2123](https://github.com/getpaseo/paseo/pull/2123)) +- Browser shortcuts no longer appear where browser tabs are unavailable ([#2116](https://github.com/getpaseo/paseo/pull/2116) by [@jasonhnd](https://github.com/jasonhnd)) + ## 0.1.110 - 2026-07-16 ### Fixed diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index b87b700ff..f5e922830 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -10,7 +10,26 @@ initializing → idle → running → idle (or error → closed) └────────┘ (agent completes a turn, awaits next prompt) ``` -Each agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, `error`, or `closed`. State transitions persist to disk and stream to subscribed clients via WebSocket. +Each live agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, or `error`. `closed` is the persisted, resumable state for an agent record that has no live provider runtime. State transitions persist to disk and stream to subscribed clients via WebSocket. + +## Runtime residency + +An unarchived agent may be `closed` without being deleted or archived. Closing releases its provider +processes and subscriptions while retaining its Paseo identity, persistence handle, timeline, +workspace, labels, title, usage, attention, timestamps, and parent relationship. Opening or prompting +the agent runs through `ensureAgentLoaded()`, which resumes the durable provider session under the +same Paseo agent ID. Provider history is not appended again when the canonical timeline is already +primed. + +The daemon collects an eligible idle runtime after two minutes and sweeps every 15 seconds. Only +unarchived, non-internal agents that are exactly `idle`, have no active or pending run, replacement, +or permission, and have not been activated during the idle window are eligible. `running`, +`initializing`, and `error` agents stay resident. Subagents are considered independently; collection +does not cascade or change parentage. + +Active schedules targeting an existing agent protect that agent from collection. Paused, completed, +and new-agent schedules do not. A pane may remain open after collection; its next prompt resumes the +runtime. ### Cancellation @@ -18,21 +37,14 @@ Cancellation changes lifecycle state only after the provider acknowledges the in ## Relationships -Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. `relationship` and `workspace` are separate decisions: - -- `relationship` decides whether the new agent belongs under the caller. -- `workspace` decides where the new agent lives and whether a new workspace/worktree is created. - -`relationship: { kind: "subagent" }` stamps the created agent with `paseo.parent-agent-id`, pointing back at the creating agent. The client surfaces that as `agent.parentAgentId`. This requires an agent-scoped MCP session. - -`relationship: { kind: "detached" }` creates a sibling/root agent (e.g. handoffs, fire-and-forget delegations). The daemon may still use the creating agent for cwd/config inheritance, but it does not write `paseo.parent-agent-id`. +Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous and always stamps `paseo.parent-agent-id`, pointing back at the caller. Omit `workspaceId` to use the caller's workspace, or pass an existing workspace ID returned by `create_workspace`. Placement never changes parentage. - **Subagents** — exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it. -- **Detached agents** — stand on their own, do not appear in the creating agent's subagent track, and are not archived with it. +- **Detached agents** — stand on their own after an explicit detach transition, do not appear in the former parent's subagent track, and are not archived with it. -`workspace: { kind: "current" }` uses the caller's workspace and can optionally override the runtime cwd. It requires an agent-scoped MCP session. `workspace: { kind: "create", source: { kind: "directory" | "worktree", ... } }` creates a new workspace for the new agent; worktree creation goes through the Paseo worktree workflow and stamps the agent with that fresh workspace id. +Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation only selects an existing workspace. -Users can also detach an existing subagent from the subagents track. Detach removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive. +Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive. `notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts. @@ -46,7 +58,11 @@ The provider still owns the underlying runtime. Paseo keeps an agent record so t Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client. -`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). If the same request created a Paseo worktree through its `worktree` field, auto-archive archives that worktree too, which removes the agent records inside the worktree. +Archive is distinct from runtime collection. Archive sets `archivedAt`, invokes the provider's native +archive hook, and cascades to managed children. Runtime collection does none of those things; it only +releases the live runtime and writes `lastStatus: closed` on the still-active record. + +`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). When the agent owns an isolated workspace, auto-archive archives that workspace too; the managed worktree is removed when its final workspace reference is gone. Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/agent/agent-manager.ts`): @@ -139,11 +155,11 @@ $PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json Each agent is a single JSON file. Fields relevant to this doc: -| Field | Type | Meaning | -| --------------------------------- | ------------- | -------------------------------------------------------------------------------------------- | -| `id` | `string` | Stable identifier | -| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) | -| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by `create_agent` when `relationship.kind === "subagent"` | -| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` | +| Field | Type | Meaning | +| --------------------------------- | ------------- | ---------------------------------------------------------------------------------- | +| `id` | `string` | Stable identifier | +| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) | +| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically for agent-scoped creation and removed by detach | +| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` | See [`docs/data-model.md`](./data-model.md) for the full agent record. diff --git a/docs/android.md b/docs/android.md index ec75faa7c..69a31b5b5 100644 --- a/docs/android.md +++ b/docs/android.md @@ -122,6 +122,8 @@ The flag must be present for both prebuild and Gradle because Gradle starts Metr Keep the excluded npm packages installed. Normal builds use them, while the F-Droid profile removes only their Android native modules and config plugins. Paseo always applies `expo-gradle-jvmargs` with `-Xmx4096m` and `-XX:MaxMetaspaceSize=1024m` so local Expo prebuilds have enough Gradle heap whether they use precompiled AARs or source-built Expo modules. +The EAS `production-apk` profile uses the large Android resource class. Release builds compile the native ABIs and run Hermes bundling in the same Gradle invocation; the default worker can exhaust its remaining memory and kill Hermes with exit code 137 even when Gradle's own heap is correctly sized. + ### React version lockstep Keep `react` and `react-dom` pinned to the React version embedded by the current `react-native` release. React Native `0.81.x` embeds `react-native-renderer` `19.1.0`, so `packages/app` must use React `19.1.0`. Bumping React to a newer patch can build successfully but crash at JS startup on Android with `Incompatible React versions`, leaving the app on the native splash screen. diff --git a/docs/architecture.md b/docs/architecture.md index a25f739d5..5355df2ca 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,22 +54,30 @@ The heart of Paseo. A Node.js process that: All paths are under `packages/server/src/`. +Project identity is daemon-global rather than session-owned. After registry bootstrap, the daemon's +project Git observer keeps one non-recursive watch on each lexically equivalent active project root +and listens only for the root `.git` entry, with a slow rescan as a missed-event fallback. It runs +for empty projects and without connected clients, then fans metadata changes through the WebSocket +server to capability-aware sessions. It deliberately does not use the broad recursive working-tree +watcher or the per-session Git observer: those are checkout/status mechanisms and intentionally do +not retain non-Git directories. + **Key modules:** -| Module | Responsibility | -| ------------------------------- | ---------------------------------------------------------------------------- | -| `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay | -| `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing | -| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations | -| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management | -| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` | -| `server/agent/tools/` | Transport-neutral Paseo tool catalog for subagents, permissions, worktrees | -| `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/relay-transport.ts` | Outbound relay connection with E2E encryption | -| `server/schedule/` | Cron-based scheduled agents | -| `server/loop-service.ts` | Looping agent runs that retry until an exit condition | -| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging | +| Module | Responsibility | +| ------------------------------- | ----------------------------------------------------------------------------- | +| `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay | +| `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing | +| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations | +| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management | +| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` | +| `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/relay-transport.ts` | Outbound relay connection with E2E encryption | +| `server/schedule/` | Cron-based scheduled agents | +| `server/loop-service.ts` | Looping agent runs that retry until an exit condition | +| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging | ### `packages/protocol` — Wire schemas and shared protocol types @@ -101,14 +109,22 @@ local-only Electron bridge for supported migrations. Cross-platform React Native app that connects to one or more daemons. -- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id (path-shaped today and opaque-encoded for routing), not a directly meaningful filesystem path. +- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, not a directly meaningful filesystem path. - `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state +- `runtime/replica-cache` keeps a non-authoritative per-host display replica in AsyncStorage: only the last focused agent, its workspace, and a short timeline tail. It restores before navigation becomes ready, leaves remote hydration flags false, and is atomically replaced by the normal snapshot-plus-delta synchronization path. - `SessionContext` wraps the daemon client for the active session - Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/` - Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication - Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete. - Voice features: dictation (STT) and voice agent (realtime) +The replica cache exists only to paint stale data immediately while the host connects. It does not +own mutations, infer deletions, or replace daemon reconciliation. Pending permission requests are +not restored from it. AsyncStorage is not encrypted, so the cached timeline tail may contain source +code, prompts, and tool output; encrypted-at-rest storage is a separate product/security decision. +Its serialized payload has a 1 MiB byte budget and evicts whole host snapshots in least-recently- +written order; a single oversized host is omitted rather than partially restored. + ### `packages/cli` — Command-line client Commander.js CLI with Docker-style commands. Common agent operations are also exposed at the top level (e.g. `paseo ls`, `paseo run`). @@ -119,9 +135,11 @@ Commander.js CLI with Docker-style commands. Common agent operations are also ex - `paseo terminal ls/create/capture/send-keys/kill` - `paseo loop run/ls/inspect/logs/stop` - `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete` +- `paseo heartbeat create/update/delete` +- `paseo workspace create/ls/archive` - `paseo permit allow/deny/ls` - `paseo provider ls/models` -- `paseo worktree create/ls/archive` +- hidden legacy `paseo worktree create/ls/archive` compatibility alias - `paseo speech …` Communicates with the daemon via the same WebSocket protocol as the app. @@ -138,6 +156,11 @@ Enables remote access when the daemon is behind a firewall. See [SECURITY.md](../SECURITY.md) for the full threat model. +### Paseo Hub + +The optional Hub relationship is daemon-outbound and does not use the relay. Its connection, +authorization, ownership, persistence, and lifecycle contract is documented in [hub.md](hub.md). + ### `packages/desktop` — Desktop app (Electron) Electron wrapper for macOS, Linux, and Windows. @@ -150,7 +173,7 @@ Electron wrapper for macOS, Linux, and Windows. > **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys. > -> **In-app browser profile.** Every browser guest uses one stable persistent Electron session, so cookies, authentication, cache, and site storage are shared across tabs, workspaces, and desktop windows and survive tab or app closure. Browser identity is independent of that storage partition: after `did-attach`, the renderer explicitly registers its browser id, workspace id, and guest `WebContents` id, and main accepts the registration only when that guest belongs to the calling renderer and the shared profile. Settings > General > Clear browser data is the sole profile-deletion path; it clears the shared session and reloads live guests without deleting saved tabs or URLs. +> **In-app browser profile.** Every browser guest uses one stable persistent Electron session, so cookies, authentication, cache, and site storage are shared across tabs, workspaces, and desktop windows and survive tab or app closure. Browser identity is independent of that storage partition: after every `did-attach`, the renderer explicitly registers its browser id, workspace id, and current guest `WebContents` id, and main accepts the registration only when that guest belongs to the calling renderer and the shared profile. Registration is intentionally repeated because reparenting a retained `` can replace its guest without replacing the DOM element. Settings > General > Clear browser data is the sole profile-deletion path; it clears the shared session and reloads live guests without deleting saved tabs or URLs. > > **In-app browser window opens.** Ordinary link opens, including Shift-clicked links, become Paseo workspace tabs. Script-created opens with popup features or a named window target and POST-backed opens remain secured Electron child windows in the shared browser profile, preserving `window.opener`, `postMessage`, named-window reuse, request bodies, and `window.close()` for OAuth, payment, and similar popup protocols. Unsupported URL schemes are denied before either path. > diff --git a/docs/data-model.md b/docs/data-model.md index 71050c594..829ca612a 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -1,5 +1,29 @@ # Data Model +## Project identity + +Projects are allocated for the exact root selected by the caller, normalized lexically with `path.resolve` (never `realpath`). New project IDs are opaque `prj_<16 hex>` values. Existing remote-shaped or path-shaped IDs are retained as readable compatibility records and are never rekeyed. An active exact root is idempotent; archived-only matches do not resurrect an old project. Workspace `projectId` is stable membership: reconciliation may update git-derived kind and branch metadata, but never rehomes a workspace or changes a project's root, ID, or default name. + +`kind` is mutable metadata, not identity. Workspace reconciliation watches active project roots and +updates only a project's `kind` and `updatedAt` when `.git` appears or disappears, preserving its +ID, root path, names, and workspace foreign keys. Attached workspaces are independently refreshed +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`. + 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. All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`). @@ -58,8 +82,8 @@ Each agent is stored as a separate JSON file, grouped by project directory. | `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp | | `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp | | `title` | `string?` | User-visible title | -| `labels` | `Record` | Key-value labels (default `{}`). `paseo.parent-agent-id` is set automatically for `create_agent` subagent relationships — see [agent-lifecycle.md](./agent-lifecycle.md) | -| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` | +| `labels` | `Record` | Key-value labels (default `{}`). `paseo.parent-agent-id` is set automatically for agent-scoped creation and removed by detach — see [agent-lifecycle.md](./agent-lifecycle.md) | +| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"`. `closed` means the record is resumable but has no live provider runtime; archive remains represented separately by `archivedAt`. | | `lastModeId` | `string?` | Last active mode ID | | `config` | `SerializableConfig?` | Agent session configuration (see below) | | `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) | @@ -265,8 +289,8 @@ One file per schedule. ID is 8 hex characters. ### Nested: ScheduleCadence (discriminated union on `type`) -- `{ type: "every", everyMs: number }` — interval in milliseconds -- `{ type: "cron", expression: string, timezone?: string }` — cron expression; absent `timezone` means UTC, present `timezone` is an IANA time zone used for local wall-clock recurrence +- `{ type: "cron", expression: string, timezone?: string }` — canonical cadence for new writes; absent `timezone` means UTC +- `{ type: "every", everyMs: number }` — legacy rolling interval, still readable and executable during the compatibility window ### Nested: ScheduleTarget (discriminated union on `type`) @@ -422,19 +446,22 @@ Array of project records. | Field | Type | Description | | ------------- | --------------------------- | -------------------------------------------------------------------------------- | -| `projectId` | `string` | Primary key | -| `rootPath` | `string` | Filesystem root of the project | -| `kind` | `"git" \| "non_git"` | | -| `displayName` | `string` | | +| `projectId` | `string` | Primary key; new records use opaque `prj_<16 hex>` IDs | +| `rootPath` | `string` | Exact lexically normalized selected root; never realpathed | +| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key | +| `displayName` | `string` | Selected-root basename, stable across remote and Git changes | | `customName` | `string \| null` | User-set override layered over `displayName`. Null means "use the derived name". | | `createdAt` | `string` (ISO 8601) | | | `updatedAt` | `string` (ISO 8601) | | | `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable | -Active git projects are unique by normalized `rootPath`. Startup reconciliation repairs older bad -states by moving workspaces from duplicate path-keyed projects onto the canonical project, -preferring remote-keyed project IDs such as `remote:github.com/owner/repo`, then archiving the -emptied duplicate. +Active exact roots are idempotent using lexical platform-equivalence semantics. Existing legacy +remote-shaped and path-shaped IDs remain readable, including duplicate roots; reconciliation never +merges them, transfers names, archives them, or moves workspace foreign keys. An explicit +workspace `projectId` is authoritative when it names an active project, regardless of cwd +containment. Archived-only exact-root records are not resurrected by explicit add/open; a fresh +opaque project is allocated instead. Agent restore is separate and restores the agent's existing +workspace together with its owning project. --- @@ -444,21 +471,25 @@ emptied duplicate. Array of workspace records. A workspace is a specific working directory within a project. -| Field | Type | Description | -| ------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `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 | -| `cwd` | `string` | Filesystem path | -| `kind` | `"local_checkout" \| "worktree" \| "directory"` | | -| `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`". | -| `branch` | `string \| null` | The worktree's git branch. Separate from `displayName`/`title`; only worktree workspaces set it. A branch rename writes this and never the name. | -| `createdAt` | `string` (ISO 8601) | | -| `updatedAt` | `string` (ISO 8601) | | -| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable | -| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" | +| Field | Type | Description | +| ---------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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 | +| `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`". | +| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. | +| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. | +| `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` | +| `createdAt` | `string` (ISO 8601) | | +| `updatedAt` | `string` (ISO 8601) | | +| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable | +| `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. Path-derived grouping keys (e.g. `deriveWorkspaceDirectoryKey`, used at bootstrap to group agents into a workspace) are directory keys, not workspace identity, and must not be persisted or compared as ids. +> **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. `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 d303f3c22..c36de231f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -374,6 +374,8 @@ install. Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The script wraps the CLI with `scripts/dev-home.sh`, so it automatically uses this checkout's `.dev/paseo-home` and dev daemon endpoint unless you pass an explicit override. The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing. +Canonical automation uses `paseo workspace create/ls/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --isolation local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases. + ```bash npm run cli -- ls -a -g # List all agents globally npm run cli -- ls -a -g --json # Same, as JSON diff --git a/docs/forge-providers.md b/docs/forge-providers.md index f633a1922..6cc4c518e 100644 --- a/docs/forge-providers.md +++ b/docs/forge-providers.md @@ -84,6 +84,20 @@ Register the adapter in `defaultForgeRegistry` with: - `matchesHost` from manifest `cloudHosts` - `probeHost` when self-hosted/Enterprise detection is supported +Current change-request lookup uses two identities deliberately: + +- An open PR/MR belongs to the checkout when its head branch and head repository + match. Its remote head SHA may differ because the checkout can be ahead, + behind, or contain commits that have not been pushed yet. +- A merged or closed PR/MR belongs to the checkout only when its recorded head + SHA exactly matches the checkout's current `HEAD`. Branch names are reusable; + selecting the newest terminal request by branch alone can silently attach an + old promotion or feature request to new work. + +Thread the checkout head SHA through adapter cache and poll identities as well +as the lookup itself. Otherwise a commit made on the same branch can inherit the +previous commit's cached terminal status until the cache expires. + Cloud hosts in the manifest are a bounded public-host list, not a self-host allowlist. Self-hosted detection is a trust gate: Paseo only talks to a forge host that is either a known cloud host or one the CLI is already authenticated diff --git a/docs/glossary.md b/docs/glossary.md index cdbdd0565..e2345e704 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -2,22 +2,22 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here. -- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label. +- **Project** — A stable, exact selected-root record. New IDs are opaque `prj_<16 hex>` values; older remote-shaped and path-shaped IDs remain readable compatibility records. Git facts can update mutable kind metadata but never project identity, 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 and app shortcuts 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. CLI/MCP **archive worktree** is a separate lower-level operation that archives every workspace on that worktree. -- **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 (`deriveWorkspaceKind`, `packages/server/src/server/workspace-registry-model.ts:158`), not stored from a user choice. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`). Don't confuse with **Isolation** (the create-time intent). +- **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`). - **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). - **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym. -- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/protocol/src/messages.ts:2113`). +- **Placement** — One workspace's stable foreign-key relationship to its project plus its git checkout snapshot. Internal. An explicit creation `projectId` is authoritative when active. - **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/protocol/src/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`). - **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. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/protocol/src/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym. -- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label. +- **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. +- **Repository / Remote** — Internal Git observations. They may affect mutable kind/branch metadata but never project identity, 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). - **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`. @@ -29,7 +29,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`). - **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`). - **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent). -- **Heartbeat** — Cron-style prompt sent back into the same agent/conversation. MCP: `create_heartbeat`. Use for reminders and babysitting where the status should return inline. +- **Heartbeat** — Ephemeral cron prompt sent back into the same agent/conversation. Agent surfaces expose create, update cron, and delete only. Use for reminders and babysitting where status should return inline. - **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/protocol/src/messages.ts:257`). - **Attachment** — External or local context bound to an agent prompt: forge issue/change request, review context, uploaded file, text, or image. UI: "Attach issue or PR/MR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`). - **Composer** — The whole prompt surface for sending work to an agent. Code: `Composer` (`packages/app/src/composer/index.tsx`). Don't call this "message input" except for the text-entry subcomponent. diff --git a/docs/hub.md b/docs/hub.md new file mode 100644 index 000000000..4a6d736eb --- /dev/null +++ b/docs/hub.md @@ -0,0 +1,72 @@ +# Paseo Hub relationship + +Paseo Hub is an explicit opt-in connection from one Paseo daemon to one Hub. Running a daemon does +not register it with a Hub. The relationship begins only when a user runs +`paseo hub connect --token ` from the daemon machine. + +## Connection and authority + +The daemon enrolls over HTTP(S), then opens and maintains a direct outbound WebSocket to the Hub. +The Hub never discovers or acquires the daemon through Paseo's relay. The relay remains an optional +encrypted path for normal Paseo clients and has no role in Hub enrollment, authentication, dispatch, +or reconnects. + +The daemon persists a relationship ID and private connection credential before enrollment. The +relationship is independent of its current transport, so a future transport can replace the direct +WebSocket without pairing again. The current foundation supports one Hub relationship per daemon. + +Normal authenticated daemon sessions may run the `hub.management.daemon.connect`, +`hub.management.daemon.get_status`, and `hub.management.daemon.disconnect` RPCs. Hub connections +receive only `hub.execution.*` authority, so execution credentials cannot manage the relationship. + +## Session grants and execution ownership + +Trusted clients and the Hub use the same `Session` implementation. The connection boundary supplies +grants: trusted clients receive `*`, while an enrolled Hub connection receives its persisted +`hub.execution.*` grant. One matcher handles exact RPC names and trailing namespace wildcards for +both inbound requests and outbound messages. A denied request returns the ordinary `rpc_error` +shape. + +The Hub connection still has a narrow lifecycle boundary: it has no trusted-client hello/resume, +browser, binary, retained-session, or broadcast state. Its outbound execution events include only +agents owned by that daemon identity, so unrelated local agents remain outside the Hub surface. + +Each Hub create carries an execution ID. The daemon stores that ID with the agent's relationship +owner before acknowledging creation. Duplicate or replayed creates for the same daemon and +execution resolve to the same durable agent. After a lost response, reconnect, or daemon restart, +the Hub retries `hub.execution.agent.create.request` with the same execution ID. The idempotent +response returns the existing agent and its current state; there is no separate reconciliation RPC. +Transient stream frames are not durably replayed. + +Daemon restart preserves the Hub relationship and owned execution identity, but interrupts any +active turn. The daemon persists that agent as `closed`; an idempotent create retry returns the same +daemon, execution, and agent identity with that terminal state. Paseo never stores or automatically +replays the original prompt. A duplicate create returns the existing agent without starting another +turn. + +Hub creates use the same agent creation path as trusted clients. They may select any existing +worktree target shape and may request `autoArchive`. Worktree creation and terminal auto-archive use +the shared workspace-aware lifecycle policy; Hub does not have a second launch or cleanup path. + +## Disconnect and revocation + +Normal socket loss reconnects the active relationship with bounded exponential backoff and jitter. +Daemon restart loads the same relationship and credential and reconnects without another enrollment +ceremony. + +Hub authentication rejection or close code `4403` permanently revokes the local relationship. The +daemon deletes its credential, stops reconnecting, and retains only the relationship ID, Hub origin, +scopes, and a sanitized reason for status reporting. + +`paseo hub disconnect` disables socket reconnect before requesting remote revocation. If the Hub is +offline, the daemon persists `disconnecting` and retries revocation across daemon restarts without +opening a Hub socket. This also covers an enrollment whose request may have succeeded but whose +response was lost. `--force` removes local authority immediately and warns that remote revocation may +still be pending. + +## Cross-repository compatibility + +The consumer implementation lives in Paseo Cloud. Cloud owns its copy of the Hub wire schemas and +has no Paseo runtime or build dependency. Cross-repository end-to-end verification separately builds +a Paseo source checkout and exercises the real daemon, CLI, direct WebSocket, Cloud service, and +Postgres. That compatibility fixture is not a package dependency or fallback implementation. diff --git a/docs/product.md b/docs/product.md index fbfc4cfa0..2eeeb0b84 100644 --- a/docs/product.md +++ b/docs/product.md @@ -73,7 +73,7 @@ Anyone who builds software: - Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi, OMP - One-click ACP provider catalog: CodeWhale, Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers - Voice mode: dictate prompts or talk through problems hands-free -- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees, workspace renaming) +- MCP server exposes the daemon to other agents (workspaces, create/detach agent, schedules, heartbeats, terminals, workspace renaming) - Scheduled agents (cron-style triggers) via app, CLI, and MCP - Frequent releases (multiple per week) - Community contributions across packaging, providers, and bug fixes diff --git a/docs/release.md b/docs/release.md index 90dd263e3..ed40fa278 100644 --- a/docs/release.md +++ b/docs/release.md @@ -12,6 +12,8 @@ A release has exactly two steps. The agent does the first, the user authorizes t - ACP provider catalog drift checked with `npm run acp:version-drift:check`; if stale package-runner pins are intentional, say so explicitly, otherwise run `npm run acp:version-drift:update` and commit the updated catalog +- classify the previous-stable-to-`HEAD` diff as patch or minor, then show the + target version and rationale to the user - draft the changelog, show it to the user, wait for review - run the pre-release sanity check, surface findings to the user - confirm CI is green @@ -36,16 +38,40 @@ There are two supported ways to ship from `main`: 1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately. 2. **Beta flow**: release candidates on the `beta` channel. Betas carry an in-place changelog entry (beta users check it), publish npm only on the explicit `beta` dist-tag, and never move the website download target off the latest stable. -## Standard release (patch) +## Release version decision -Before running any stable patch release command: +Every fresh release starts by classifying the full previous-stable-to-`HEAD` +diff. The highest-impact change determines the version: + +- **Minor** — a user would experience the release as a significant upgrade. This + includes substantial new workflows, providers, forges, platforms, integrations, + or meaningful expansions of existing capabilities. Foundational internal work + also qualifies when it materially changes reliability, performance, + compatibility, deployment, or operation; diff size alone does not. +- **Patch** — fixes, polish, small enhancements, and reliability or performance + improvements within existing capabilities. Follow-up corrections to a minor + release are patches. + +The release agent selects patch or minor during preparation and presents the +target version with the changelog for approval. Agents never select a major +version autonomously. A major release requires an explicit user instruction and +approval; Paseo remains on major version zero until that deliberate decision. + +Version bumps are never used to retry a failed build. Retry the existing version +as described in **Fixing a failed release build**. + +## Standard release (stable) + +Before running any stable release command: - Make sure the intended release commit is already committed to `main` and the working tree is clean. - **Run `npm run format`, `npm run lint`, and `npm run typecheck` and commit any resulting changes BEFORE you start any `release:*` command.** `release:check` runs `npm install --workspaces --include-workspace-root` as part of `release:prepare`, which can mutate `package-lock.json` (e.g. churning `"dev": true` markers on optional deps). The next step, `version:all:*`, runs `npm version` which aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skips `package-lock.json` while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then `release:prepare` once on its own to absorb any lockfile churn into a normal commit, then start the release. -- Do not use `npm run release:patch` as a substitute for checking whether the current commit is actually ready. +- Do not use a release command as a substitute for checking whether the current commit is actually ready. ```bash +# Run exactly one, matching the approved decision: npm run release:patch +npm run release:minor ``` This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo. @@ -54,8 +80,6 @@ The Docker workflow builds images from the checked-out source tree on pull reque Relay deployment is manual-only while `relay.paseo.sh` bridges traffic to the Fly deployment. Releases and pushes to `main` do not deploy the Cloudflare relay worker. Deploy it explicitly with `gh workflow run deploy-relay.yml` only when the production bridge should change. -**Releases are always patch.** "Release paseo", "release stable", "ship stable", and similar always mean a patch bump from the previous stable. Never bump minor or major to trigger a build, ever — minor and major bumps are reserved for genuinely larger product cuts and require an explicit user instruction with the word "minor" or "major". If you find yourself reaching for `release:minor` to retrigger a failed build, you are doing the wrong thing — push a retry tag instead (see "Fixing a failed release build" below). - **Stable means stable.** If the user says "stable" or "ship stable", do not ask whether they want a beta first. They picked stable; treat it as a direct stable release. Only run the beta flow when the user explicitly says "beta". ## Manual step-by-step @@ -63,7 +87,9 @@ Relay deployment is manual-only while `relay.paseo.sh` bridges traffic to the Fl ```bash npm run typecheck # Verify the exact commit you intend to release npm run release:check # Typecheck, build, dry-run pack -npm run version:all:patch # Bump version, create commit + tag +# Run exactly one approved version command: +npm run version:all:patch +npm run version:all:minor npm run release:publish # Publish to npm npm run release:push # Push HEAD + tag (triggers CI workflows) ``` @@ -71,7 +97,8 @@ npm run release:push # Push HEAD + tag (triggers CI workflows) ## Beta flow ```bash -npm run release:beta:patch # Bump to X.Y.Z-beta.1, publish npm beta, push commit + tag +npm run release:beta:patch # Start the next patch beta line +npm run release:beta:minor # Start the next minor beta line # ... test desktop and APK prerelease assets from GitHub Releases ... npm run release:beta:next # Optional: cut X.Y.Z-beta.2, beta.3, ... npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z @@ -107,7 +134,7 @@ Updater clients only discover a release through those `.yml` manifests, so there ### Default behavior -`npm run release:patch` → tag push → 36h ramp. No extra action needed. +`npm run release:patch` or `npm run release:minor` → tag push → 36h ramp. No extra action needed. The `rollout_hours` input on `desktop-release.yml` is **only read on `workflow_dispatch`** — tag-push runs always default to 36. To get any other rollout duration on a fresh release, use the post-publish flip below. @@ -127,7 +154,7 @@ gh workflow run desktop-rollout.yml \ **Why this is gap-free:** `desktop-release.yml`'s `finalize-rollout` job and `desktop-rollout.yml` share the concurrency group `desktop-rollout-`. Dispatching `desktop-rollout.yml` while the tag-push pipeline is still running queues it safely behind `finalize-rollout`. The first public manifests already carry `rolloutHours=36`, then `desktop-rollout.yml` flips them to `rolloutHours=0` shortly afterward. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check. -Run the dispatch right after `release:patch` returns. Don't wait for the tag-push CI to finish. +Run the dispatch right after `release:patch` or `release:minor` returns. Don't wait for the tag-push CI to finish. ### Adjusting an already-published release @@ -165,17 +192,17 @@ gh workflow run desktop-release.yml \ -f rollout_hours=6 ``` -This does **not** apply to fresh releases cut via `npm run release:patch` — that path always tag-pushes and stamps 36. For a fresh release with a custom ramp, cut normally and then dispatch `desktop-rollout.yml` (same pattern as the instant-admit flow above, with your chosen `rollout_hours`). +This does **not** apply to fresh releases cut via `npm run release:patch` or `npm run release:minor` — those paths always tag-push and stamp 36. For a fresh release with a custom ramp, cut normally and then dispatch `desktop-rollout.yml` (same pattern as the instant-admit flow above, with your chosen `rollout_hours`). ### Releasing during an active rollout -If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its own publish timestamp. N's rollout effectively ends — the newer manifest supersedes it. +If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its own publish timestamp. N's rollout effectively ends — the newer manifest supersedes it. Rollout-aware clients revalidate the manifest for up to five seconds before installing a downloaded update on quit. If N+1 has replaced N but the client is not admitted to N+1 yet, it skips the downloaded N and waits rather than installing two updates in succession. If revalidation times out, the app exits without installing the cached update. If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1. -f rollout_hours=0` after N+1 publishes so the users who already got N reach the fix fast. ### Limitations -- **No pause / kill switch.** Once a stable user is admitted, they will install the update on next quit (`autoInstallOnAppQuit = true`). To stop new admissions, ship a superseding release. To "recall" already-admitted users, ship a hotfix `+1` patch. +- **No pause / kill switch.** To stop new admissions, ship a superseding release. Clients revalidate on quit and will not install the superseded download, but a client that already completed installation cannot be recalled; ship a hotfix `+1` patch. - **No rollback.** `allowDowngrade = false`. Bad release = ship a hotfix. - **Bootstrap caveat.** Clients running a build older than the rollout feature ignore `rolloutHours` and admit immediately. Rollout protection only applies to clients running the rollout-aware version or later. - **Up to ~30 min automatic admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window. Clicking **Check** is manual and bypasses rollout admission. @@ -463,7 +490,8 @@ Betas are checkpoints along the way; the entry is the single record for the jump - [ ] Working tree is clean and the intended commit is on `main` - [ ] Update the in-place beta entry in `CHANGELOG.md` (heading `## X.Y.Z-beta.N - YYYY-MM-DD`), review it against the changelog policy, get approval, and commit it before cutting the release -- [ ] `npm run release:beta:patch` (or `:next`) completes successfully +- [ ] The previous-stable-to-`HEAD` diff is classified as patch or minor, with the target version and rationale approved +- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully - [ ] npm shows the version under the `beta` dist-tag, not `latest` - [ ] GitHub `Desktop Release` workflow for the `v*-beta.N` tag is green - [ ] GitHub `Android APK Release` workflow for the same tag is green @@ -472,11 +500,12 @@ Betas are checkpoints along the way; the entry is the single record for the jump ### Stable release (or promotion) - [ ] Run the pre-release sanity check (see above) and address any findings -- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any `release:*` patch/promote command -- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any `release:*` patch/promote command +- [ ] The previous-stable-to-`HEAD` diff is classified as patch or minor, with the target version and rationale approved +- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any release command +- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command - [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). When promoting from beta, overwrite the existing `## X.Y.Z-beta.N` heading in place (heading → `X.Y.Z`, date → promotion day) — do not add a new entry on top of the beta one - [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format -- [ ] `npm run release:patch` or `npm run release:promote` completes successfully +- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully - [ ] GitHub `Desktop Release` workflow for the `v*` tag is green - [ ] GitHub `Android APK Release` workflow for the same tag is green - [ ] EAS `Release Mobile` workflow for the same tag is green diff --git a/docs/testing.md b/docs/testing.md index b5226a987..86cd45701 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -124,6 +124,16 @@ PASEO_DESKTOP_SMOKE_ARTIFACT_DIR=/tmp/paseo-desktop-smoke \ npm run build:desktop -- --publish never --linux --x64 --dir ``` +### Browser tab bridge regression + +The desktop browser tab bridge E2E launches an isolated real daemon, Metro, and Electron app. It forces workspace LRU eviction to reparent the original tab and replace its guest `WebContents`, then makes one MCP call each for tab listing, snapshot, and click against that original browser id. A final MCP wait proves the real target page received the click. + +Run it locally with the same command owned by the Ubuntu leg of the existing `desktop-tests` CI check: + +```bash +npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop +``` + ## Test organization - Collocate tests with implementation: `thing.ts` + `thing.test.ts` @@ -176,6 +186,7 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es - Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow. - App Playwright specs share one isolated daemon per run. Helpers that create projects or workspaces must remove the daemon project record during cleanup, not only delete the temp directory. Agent helpers must pass the intended `workspaceId` through to agent creation; never infer ownership from `cwd`. - CI can shard app Playwright across multiple jobs; each shard still owns a full isolated daemon/relay/Metro stack from global setup. Helpers that restart the daemon must preserve the global setup environment, including disabled speech/local-model settings, so a restart does not change the tested surface or start background downloads. +- Global setup starts Metro before Wrangler, assigns Wrangler explicit distinct relay and inspector ports, and accepts Metro as ready only when `/status` returns `packager-status:running`. A generic TCP listener is not sufficient readiness evidence. ## Agent authentication in tests diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 020ee0ed4..e90abf412 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -51,9 +51,42 @@ When a client resumes with a known cursor, it catches up after that cursor to co When a client resumes without a cursor, it fetches the latest tail page. +## Selective and legacy delivery + +The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`: + +- Selective daemons receive the union of agents visible in every pane. Additions subscribe and + catch up immediately. Every visibility-driven removal, including app backgrounding, stays + subscribed for a short grace period so brief tab, pane, route, and app switches do not repeatedly + unsubscribe and catch up. Losing window keyboard focus does not make a selected pane invisible. + Disconnecting and disposal clear pending grace because the subscription itself no longer exists. + After grace has expired, a retained timeline stays covered when revisited until authoritative + catch-up completes; cached partial output is never presented as current history. +- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing + authoritative catch-up, but the app does not issue selective-subscription RPCs. + +This policy is owned by `viewed-timeline-sync.ts`; downstream reducers do not branch on daemon +version. + +## Projected pages reconcile with live presentation + +A projected page is canonical state, not a sequence of live deltas. One projected item can overlap +rows already received live—for example, a tool call retained at its original display position while +its completion advances `seqEnd`, followed by a merged assistant message. The app uses +`sourceSeqRanges` to replace overlapping assistant and reasoning projections before applying the +remaining page through the existing stream reducer. It must not append full projected text to a +live prefix. + +Optimistic user prompts are presentation state rather than canonical history. Incremental catch-up +temporarily separates them, applies canonical entries, lets canonical user rows reconcile through +the existing optimistic-message rules, then restores any unmatched prompts after the caught-up +history. This keeps late history before a newly submitted prompt without duplicating an +acknowledged prompt. + ## Relevant code - Server live stream forwarding: `packages/server/src/server/session.ts` - App sync planning: `packages/app/src/timeline/timeline-sync-plan.ts` +- App viewed-agent synchronization: `packages/app/src/timeline/viewed-timeline-sync.ts` - App stream/timeline reducer: `packages/app/src/timeline/session-stream-reducers.ts` - Session wiring: `packages/app/src/contexts/session-context.tsx` diff --git a/docs/unistyles.md b/docs/unistyles.md index 255d74d44..619ceed3b 100644 --- a/docs/unistyles.md +++ b/docs/unistyles.md @@ -58,6 +58,30 @@ For standard React Native components, the [Unistyles Babel plugin](https://www.u The important detail: the automatic native path tracks `props.style`. It does not generally track every prop that happens to carry style-like values. +### Do Not Materialize Styles At Module Scope + +Never read a Unistyles style property into a module-level constant. This includes cached arrays: + +```tsx +// Wrong: evaluated while the app may still be using the temporary system theme. +const ROW_STYLE = [settingsStyles.row, settingsStyles.rowBorder]; + +// Right: each style proxy is read when this view renders. +; +``` + +Paseo starts with adaptive themes, then applies the persisted theme after async settings load. A +module-level read can therefore materialize the light style before a persisted dark theme is +active. If the view mounts after that theme change, React Native receives the stale light object; +Unistyles registers the node for future changes but does not retroactively replace its initial +props. Settings dividers once rendered light `#e4e4e7` inside a dark `#252B2A` card for exactly +this reason. + +Render-time array syntax is intentional and exempt from the app's JSX array-allocation lint rule. +Keep the entries separate so each retains its Unistyles metadata. If composition is needed outside +JSX, create the array inside the component or in a `useMemo` that first runs when the component +mounts—never at module evaluation time. + [`useUnistyles()`](https://www.unistyl.es/v3/references/use-unistyles) is different. It gives React access to the current theme/runtime and can make a component re-render when those values change. Use it for values that must be rendered through React props, such as icon colors or small escape hatches. Do not expect direct reads from `UnistylesRuntime` to re-render a component; [issue #817](https://github.com/jpudysz/react-native-unistyles/issues/817) is a useful reminder of that invariant. ## Dynamic Pixel Styles On Web diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index bb3948a0b..7d52fd51c 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-DL1LamUyFzJOkPYR7eeIefGhzP/mcWGO5oxld/Bt8n0= +sha256-MjAAaU+zU3m5fdE9rQrr4aA3eTDZztIXJQVXKbj73rY= diff --git a/package-lock.json b/package-lock.json index 75fb7bfe3..5d12d0f2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.1.110", + "version": "0.2.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.1.110", + "version": "0.2.0-beta.1", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -35190,7 +35190,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.1.110", + "version": "0.2.0-beta.1", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -36209,12 +36209,12 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.1.110", + "version": "0.2.0-beta.1", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.1.110", - "@getpaseo/protocol": "0.1.110", - "@getpaseo/server": "0.1.110", + "@getpaseo/client": "0.2.0-beta.1", + "@getpaseo/protocol": "0.2.0-beta.1", + "@getpaseo/server": "0.2.0-beta.1", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -36460,10 +36460,10 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.1.110", + "version": "0.2.0-beta.1", "dependencies": { - "@getpaseo/protocol": "0.1.110", - "@getpaseo/relay": "0.1.110", + "@getpaseo/protocol": "0.2.0-beta.1", + "@getpaseo/relay": "0.2.0-beta.1", "ws": "^8.20.0", "zod": "^4.4.3" }, @@ -36476,7 +36476,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.1.110", + "version": "0.2.0-beta.1", "license": "AGPL-3.0-or-later", "dependencies": { "@getpaseo/cli": "*", @@ -36721,7 +36721,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.1.110", + "version": "0.2.0-beta.1", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -37617,7 +37617,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.1.110", + "version": "0.2.0-beta.1", "dependencies": { "@codemirror/language": "^6.12.3", "@codemirror/legacy-modes": "^6.5.3", @@ -37849,10 +37849,10 @@ }, "packages/migrate": { "name": "@getpaseo/migrate", - "version": "0.1.110", + "version": "0.2.0-beta.1", "dependencies": { - "@getpaseo/client": "0.1.110", - "@getpaseo/protocol": "0.1.110", + "@getpaseo/client": "0.2.0-beta.1", + "@getpaseo/protocol": "0.2.0-beta.1", "smol-toml": "^1.6.0", "sql.js": "^1.14.1" }, @@ -37864,11 +37864,14 @@ "@types/sql.js": "^1.4.11", "typescript": "^5.2.2", "vitest": "^4.1.6" + }, + "engines": { + "node": ">=18" } }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.1.110", + "version": "0.2.0-beta.1", "dependencies": { "zod": "^4.4.3" }, @@ -37881,7 +37884,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.1.110", + "version": "0.2.0-beta.1", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -38099,15 +38102,15 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.1.110", + "version": "0.2.0-beta.1", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", - "@anthropic-ai/claude-agent-sdk": "^0.3.195", + "@anthropic-ai/claude-agent-sdk": "^0.3.214", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.1.110", - "@getpaseo/highlight": "0.1.110", - "@getpaseo/protocol": "0.1.110", - "@getpaseo/relay": "0.1.110", + "@getpaseo/client": "0.2.0-beta.1", + "@getpaseo/highlight": "0.2.0-beta.1", + "@getpaseo/protocol": "0.2.0-beta.1", + "@getpaseo/relay": "0.2.0-beta.1", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", @@ -38137,7 +38140,7 @@ "zod": "^4.4.3" }, "devDependencies": { - "@getpaseo/migrate": "0.1.110", + "@getpaseo/migrate": "0.2.0-beta.1", "@playwright/test": "^1.56.1", "@types/express": "^4.17.20", "@types/node": "^20.9.0", @@ -38152,22 +38155,22 @@ } }, "packages/server/node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.195", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.195.tgz", - "integrity": "sha512-FVmXu9pvOMbuBKWrF8YsYQdQ/upOpv5rS8lFAnFO5jbyXT/2hN7kEPd2vd2GJpaMvNcO/KptyQUK5AxjjTz3+w==", + "version": "0.3.214", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.214.tgz", + "integrity": "sha512-wt5ImwhU+p259Zt4K/Q9v5xVi6ruxYO5+KFICyJxnjs/QFEClAeSRqhcXx1J8jgGfatPW1faw09hkm66680UjA==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.195", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.195", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.195", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.195", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.195", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.195", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.195", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.195" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.214", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.214", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.214", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.214", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.214", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.214", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.214", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.214" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -38175,10 +38178,10 @@ "zod": "^4.0.0" } }, - "packages/server/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.195", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.195.tgz", - "integrity": "sha512-WIMM/8HRCLsTDHFTIwQvvE8WCA/oaMJtdQxsP7iNyfzIGwXbuOyU95V8vYIhZfaO2yaSpbBRncunq4CtR5H4ng==", + "packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { + "version": "0.3.214", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.214.tgz", + "integrity": "sha512-vAAOeVtlXs3p7MpFVfvfu9ja32eaKtB+MDZnPxCbdzxJk5nofCHlNsHa1UJwXHOsP9lOnFYNIccYztsZ4DNaJA==", "cpu": [ "arm64" ], @@ -38188,10 +38191,10 @@ "darwin" ] }, - "packages/server/node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.195", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.195.tgz", - "integrity": "sha512-RY7DB+4LXosE0MJ+XELmakfPrDN1YX4lkk9CTDm28jGCVcESRz9kAEqbyaiC48dZcmN9V1NCLutzINGdcr1TBg==", + "packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { + "version": "0.3.214", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.214.tgz", + "integrity": "sha512-NTbV8U2yucxCWqEiDC7L0MUehNmd8x3Op8OGzLZRhMF3xmQBy2DxpXiNZgSwwKWNDC3HdgKmJM5ZBbT7XrF/0g==", "cpu": [ "x64" ], @@ -38201,10 +38204,10 @@ "darwin" ] }, - "packages/server/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.195", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.195.tgz", - "integrity": "sha512-JuIq5Fnz/F1snl0aqi1gcuRZqPWoPNrL9dJ0DuievCxKkO8hnEz/Mmn5Zos7x1X8HE//ZnEvmQXoEQEZXonJew==", + "packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { + "version": "0.3.214", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.214.tgz", + "integrity": "sha512-KBCf+BlusG0ZcgvpjjHwv1kh+6WiR8vJbPjpR2udwkrtcQgKLN+24l+FeaQEzO2TL+ExCmM1KC4tNPvnPpy+tw==", "cpu": [ "arm64" ], @@ -38214,10 +38217,10 @@ "linux" ] }, - "packages/server/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.195", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.195.tgz", - "integrity": "sha512-ZmyBA/AFzhgutcxb7dbhCm6GTjJytwNYXTxJoKE2B3A409WCYccjMqeji6vCMNxyyfylglGo5D8dVMIxW9aoug==", + "packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { + "version": "0.3.214", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.214.tgz", + "integrity": "sha512-i06wQRsmevE7spY1ryYfs+NP+xdZ1FwAyTjDaF0k/xG+cgtzZYghTFUemdYF3GVwgWgpcava9GiCFDT3DoHI6w==", "cpu": [ "arm64" ], @@ -38227,10 +38230,10 @@ "linux" ] }, - "packages/server/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.195", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.195.tgz", - "integrity": "sha512-s1lNi1cL93luoqsItH+fNO4KpIhdkvnVhWGGQUQ/8ftwa2gfmcIQnOg1hG8Ks+KzeD3UUQ8L9YEVHVADnFI/9A==", + "packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { + "version": "0.3.214", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.214.tgz", + "integrity": "sha512-vqadSceJkBKHaTUszxI35uTuECGWtsHWK/mOwIJ4b9DUtYYySz6EJEk4gHg4ccutisJ/oRVCpFXHIKFb+osrKw==", "cpu": [ "x64" ], @@ -38240,10 +38243,10 @@ "linux" ] }, - "packages/server/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.195", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.195.tgz", - "integrity": "sha512-nf8Q/LauB+ZOC6QDjxNhbsvwUtYjKYnaWJLTYFwhkmsLujePnety1AtT/1ubaUoq5AM1j297DhMlYTasa79OUA==", + "packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { + "version": "0.3.214", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.214.tgz", + "integrity": "sha512-948RstHDhs0E69h+2dKYWIAL1kcwEme4zvtgNUwP8XpKXzWOhrTKmd6MAokHn25zwfuSx5d+HE0XHfFH6R4hLg==", "cpu": [ "x64" ], @@ -38253,10 +38256,10 @@ "linux" ] }, - "packages/server/node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.195", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.195.tgz", - "integrity": "sha512-hbkDE+xPIZzRWm+D+BKrH9uJH6USIZdDIlsyrIlGi3JFHoieYoA1vdUNyldSS9+F3ZqQtfPjr2Qy08IVB6akYA==", + "packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { + "version": "0.3.214", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.214.tgz", + "integrity": "sha512-CEKlPFCPv+ee79utQMEDSsgeo2f27ulhNHpjrKIt1jXz5G04J7lAWN/QwvPDv9XaLBkWpyfqZWiPXlRcwuaO/w==", "cpu": [ "arm64" ], @@ -38266,10 +38269,10 @@ "win32" ] }, - "packages/server/node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.195", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.195.tgz", - "integrity": "sha512-av0piEB3X1Dzhpr8A+DqHVZ9y8s1jpn8enzwX0TKKUPBn5IqLTWC7wD6v66aoUgu4f+g4ThZirmDZA6shyPEZQ==", + "packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { + "version": "0.3.214", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.214.tgz", + "integrity": "sha512-cJMJfFoR9IWBZWnTtt9PnEm89hGOsZjoDNjOCEOF3+x+g/ANIXAjMJTcaQYv2HKh6MylWZ0AkxBASI+twkukaQ==", "cpu": [ "x64" ], @@ -38645,7 +38648,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.1.110", + "version": "0.2.0-beta.1", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", diff --git a/package.json b/package.json index 09851c003..27babc631 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.1.110", + "version": "0.2.0-beta.1", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/e2e/directory-bootstrap.spec.ts b/packages/app/e2e/directory-bootstrap.spec.ts new file mode 100644 index 000000000..ad42b991f --- /dev/null +++ b/packages/app/e2e/directory-bootstrap.spec.ts @@ -0,0 +1,19 @@ +import { test } from "./fixtures"; +import { DirectoryBootstrapScenario } from "./helpers/directory-bootstrap-scenario"; + +test.describe("Directory bootstrap correctness", () => { + test("connect, pushed deltas, and reconnect keep directories current without duplicate bootstraps", async ({ + page, + }) => { + test.setTimeout(180_000); + const scenario = await DirectoryBootstrapScenario.open(page); + try { + await scenario.expectDirectoryStarts(1); + await scenario.stayConnectedWithoutRefetchAndApplyDeltas(); + await scenario.disconnectMutateAndReconnect(); + await scenario.expectVisibleReconciliationAndNavigateAgent(); + } finally { + await scenario.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/empty-project-persists.spec.ts b/packages/app/e2e/empty-project-persists.spec.ts index 2344fd597..c915d809f 100644 --- a/packages/app/e2e/empty-project-persists.spec.ts +++ b/packages/app/e2e/empty-project-persists.spec.ts @@ -219,15 +219,20 @@ test.describe("Project remove", () => { const readded = await workspace.client.addProject(workspace.repoPath); expect(readded.error).toBeNull(); + expect(readded.project).not.toBeNull(); + const readdedProjectId = readded.project?.projectId ?? ""; + expect(readdedProjectId).not.toBe(workspace.projectId); expect(readded.project?.projectDisplayName).toBe(workspace.projectDisplayName); await page.reload(); await waitForSidebarHydration(page); - await expect(projectRow).toBeVisible({ timeout: 30_000 }); - await expect(projectRow).toContainText(workspace.projectDisplayName); - await expect(projectRow).not.toContainText(workspace.repoPath); + await expect(projectRow).toHaveCount(0, { timeout: 30_000 }); + const readdedProjectRow = page.getByTestId(`sidebar-project-row-${readdedProjectId}`); + await expect(readdedProjectRow).toBeVisible({ timeout: 30_000 }); + await expect(readdedProjectRow).toContainText(workspace.projectDisplayName); + await expect(readdedProjectRow).not.toContainText(workspace.repoPath); await expect( - page.getByTestId(`sidebar-project-new-workspace-row-${workspace.projectId}`), + page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectId}`), ).toBeVisible({ timeout: 30_000 }); } finally { await workspace.cleanup(); diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts index c128b28e1..8e2868ff6 100644 --- a/packages/app/e2e/global-setup.ts +++ b/packages/app/e2e/global-setup.ts @@ -14,7 +14,7 @@ import { withDisabledE2ESpeechEnv } from "./helpers/speech-env"; const wranglerCliPath = path.resolve(__dirname, "../node_modules/wrangler/bin/wrangler.js"); -interface WaitForServerOptions { +export interface WaitForServerOptions { host?: string; timeoutMs?: number; label: string; @@ -22,6 +22,8 @@ interface WaitForServerOptions { getRecentOutput?: () => string; } +type ServerProbe = (host: string, port: number) => Promise; + async function getAvailablePort(): Promise { return new Promise((resolve, reject) => { const server = net.createServer(); @@ -75,7 +77,25 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -async function waitForServer(port: number, options: WaitForServerOptions): Promise { +async function connectToServer(host: string, port: number): Promise { + await new Promise((resolve, reject) => { + const socket = net.connect(port, host, () => { + socket.end(); + resolve(); + }); + socket.setTimeout(1000, () => { + socket.destroy(); + reject(new Error(`Connection timed out to ${host}:${port}`)); + }); + socket.on("error", reject); + }); +} + +async function waitForServer( + port: number, + options: WaitForServerOptions, + probe: ServerProbe = connectToServer, +): Promise { const { host = "127.0.0.1", timeoutMs = 15000, label, childProcess, getRecentOutput } = options; const start = Date.now(); let lastConnectionError: unknown = null; @@ -89,17 +109,7 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi } try { - await new Promise((resolve, reject) => { - const socket = net.connect(port, host, () => { - socket.end(); - resolve(); - }); - socket.setTimeout(1000, () => { - socket.destroy(); - reject(new Error(`Connection timed out to ${host}:${port}`)); - }); - socket.on("error", reject); - }); + await probe(host, port); return; } catch (error) { lastConnectionError = error; @@ -116,6 +126,22 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi ); } +async function probeMetro(host: string, port: number): Promise { + const response = await fetch(`http://${host}:${port}/status`, { + signal: AbortSignal.timeout(1000), + }); + const body = (await response.text()).trim(); + if (response.status !== 200 || body !== "packager-status:running") { + throw new Error( + `Expected Metro status on ${host}:${port}, received HTTP ${response.status}: ${JSON.stringify(body.slice(0, 200))}`, + ); + } +} + +export async function waitForMetro(port: number, options: WaitForServerOptions): Promise { + await waitForServer(port, options, probeMetro); +} + function parseRelayStartupFailure(line: string): string | null { const clean = stripAnsi(line); if (/Address already in use/i.test(clean)) { @@ -556,13 +582,19 @@ async function getAvailablePortExcluding(excludedPorts: Set): Promise): Promise { +interface RelayPorts { + relayPort: number; + inspectorPort: number; +} + +async function startRelay(excludedPorts: Set): Promise { const relayDir = path.resolve(__dirname, "..", "..", "relay"); const maxRelayStartupAttempts = 5; let lastRelayStartupError: unknown = null; for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) { const relayPort = await getAvailablePortExcluding(excludedPorts); + const inspectorPort = await getAvailablePortExcluding(new Set([...excludedPorts, relayPort])); const buffer = createLineBuffer(); const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false }; @@ -576,6 +608,10 @@ async function startRelay(excludedPorts: Set): Promise { "127.0.0.1", "--port", String(relayPort), + "--inspector-ip", + "127.0.0.1", + "--inspector-port", + String(inspectorPort), "--live-reload=false", "--show-interactive-dev-session=false", ], @@ -590,7 +626,7 @@ async function startRelay(excludedPorts: Set): Promise { try { await awaitRelayReady(relayProcess, relayPort, state, buffer); - return relayPort; + return { relayPort, inspectorPort }; } catch (error) { lastRelayStartupError = error; await stopProcess(relayProcess); @@ -769,12 +805,19 @@ export default async function globalSetup() { await logSpeechHarnessConfig(); try { - const relayPort = await startRelay(new Set([port, metroPort])); metroProcess = startMetro({ metroPort, daemonPort: port, buffer: metroLineBuffer, }); + await waitForMetro(metroPort, { + label: "Metro web server", + timeoutMs: 120000, + childProcess: metroProcess, + getRecentOutput: metroLineBuffer.dump, + }); + + const { relayPort, inspectorPort } = await startRelay(new Set([port, metroPort])); daemonProcess = startDaemon({ port, relayPort, @@ -785,19 +828,11 @@ export default async function globalSetup() { buffer: daemonLineBuffer, }); - await Promise.all([ - waitForServer(port, { - label: "Paseo daemon", - childProcess: daemonProcess, - getRecentOutput: daemonLineBuffer.dump, - }), - waitForServer(metroPort, { - label: "Metro web server", - timeoutMs: 120000, - childProcess: metroProcess, - getRecentOutput: metroLineBuffer.dump, - }), - ]); + await waitForServer(port, { + label: "Paseo daemon", + childProcess: daemonProcess, + getRecentOutput: daemonLineBuffer.dump, + }); const offer = await waitForPairingOfferFromDaemon({ port, @@ -811,7 +846,7 @@ export default async function globalSetup() { process.env.E2E_PASEO_HOME = paseoHome; process.env.E2E_EDITOR_RECORD_PATH = editorRecordPath; console.log( - `[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`, + `[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, relay on port ${relayPort}, relay inspector on port ${inspectorPort}, home: ${paseoHome}`, ); return async () => { diff --git a/packages/app/e2e/helpers/daemon-client-loader.ts b/packages/app/e2e/helpers/daemon-client-loader.ts index 3a2a1b7ce..b9a6e7b22 100644 --- a/packages/app/e2e/helpers/daemon-client-loader.ts +++ b/packages/app/e2e/helpers/daemon-client-loader.ts @@ -26,13 +26,14 @@ interface E2EDaemonClientConfig { webSocketFactory?: NodeWebSocketFactory; } -function resolveDaemonWsUrl(): string { - return `ws://127.0.0.1:${getE2EDaemonPort()}/ws`; +function resolveDaemonWsUrl(port?: number): string { + return `ws://127.0.0.1:${port ?? getE2EDaemonPort()}/ws`; } export interface ConnectDaemonClientOptions { clientIdPrefix: string; appVersion?: string; + port?: number; } /** @@ -45,7 +46,7 @@ export async function connectDaemonClient { const DaemonClient = await loadDaemonClientConstructor(); const client = new DaemonClient({ - url: resolveDaemonWsUrl(), + url: resolveDaemonWsUrl(options.port), clientId: `${options.clientIdPrefix}-${randomUUID()}`, clientType: "cli", appVersion: options.appVersion ?? loadAppVersion(), diff --git a/packages/app/e2e/helpers/daemon-restart.ts b/packages/app/e2e/helpers/daemon-restart.ts deleted file mode 100644 index 5490d449f..000000000 --- a/packages/app/e2e/helpers/daemon-restart.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { spawn, type ChildProcess } from "node:child_process"; -import { createRequire } from "node:module"; -import { readFile } from "node:fs/promises"; -import net from "node:net"; -import path from "node:path"; - -import { getE2EDaemonPort } from "./daemon-port"; -import { withDisabledE2ESpeechEnv } from "./speech-env"; - -/** - * Restarts the isolated E2E daemon against the SAME PASEO_HOME and SAME port so - * persisted state reloads and existing clients can reconnect. This exercises the - * post-restart rehydration path (the daemon rebuilding workspace/agent links - * from disk), which is where the worktree-branch regression lives. - * - * The daemon is owned by Playwright's `globalSetup`, which keeps its child - * handle in module scope we can't reach from a spec. Instead we drive it the - * same way an operator would: read the supervisor PID from - * `$PASEO_HOME/paseo.pid`, SIGTERM it (the supervisor forwards the signal to its - * worker and releases the lock), wait for the port to free, then re-spawn the - * supervisor with the identical environment globalSetup used. The relay and - * Metro processes are untouched, so we reuse their already-published ports. - * - * This NEVER targets the developer daemon: the port comes from - * `getE2EDaemonPort()`, which refuses 6767, and PASEO_HOME is the isolated E2E - * home globalSetup created. - */ - -function getEnvOrThrow(name: string): string { - const value = process.env[name]; - if (!value) { - throw new Error(`${name} is not set (expected from Playwright globalSetup).`); - } - return value; -} - -async function readSupervisorPid(paseoHome: string): Promise { - const pidPath = path.join(paseoHome, "paseo.pid"); - const content = await readFile(pidPath, "utf8"); - const parsed = JSON.parse(content) as { pid?: unknown }; - if (typeof parsed.pid !== "number") { - throw new Error(`Malformed PID lock at ${pidPath}: ${content}`); - } - return parsed.pid; -} - -function isPidRunning(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -function isPortListening(port: number, host = "127.0.0.1"): Promise { - return new Promise((resolve) => { - const socket = net.connect(port, host, () => { - socket.end(); - resolve(true); - }); - socket.setTimeout(1000, () => { - socket.destroy(); - resolve(false); - }); - socket.on("error", () => resolve(false)); - }); -} - -async function waitUntil( - predicate: () => Promise | boolean, - options: { timeoutMs: number; label: string }, -): Promise { - const deadline = Date.now() + options.timeoutMs; - while (Date.now() < deadline) { - if (await predicate()) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 100)); - } - throw new Error(`Timed out after ${options.timeoutMs}ms waiting for ${options.label}.`); -} - -function spawnSupervisor(args: { - paseoHome: string; - port: string; - relayPort: string; - metroPort: string; - editorRecordPath: string; -}): ChildProcess { - const serverDir = path.resolve(__dirname, "../../../..", "packages/server"); - // Run the supervisor through the resolved tsx CLI under the current node - // binary. Spawning the `node_modules/.bin/tsx` shim directly is unreliable - // inside the Playwright worker (the shim is a .mjs symlink, not an executable), - // so resolve the CLI module and load it with node. - const tsxCli = createRequire(path.join(serverDir, "package.json")).resolve("tsx/cli"); - const env = withDisabledE2ESpeechEnv({ - ...process.env, - PASEO_HOME: args.paseoHome, - PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath, - PASEO_SERVER_ID: "srv_e2e_test_daemon", - PASEO_LISTEN: `0.0.0.0:${args.port}`, - PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`, - PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`, - PASEO_NODE_ENV: "development", - NODE_ENV: "development", - }); - - const child = spawn(process.execPath, [tsxCli, "scripts/supervisor-entrypoint.ts", "--dev"], { - cwd: serverDir, - env, - stdio: ["ignore", "pipe", "pipe"], - detached: false, - }); - - child.stdout?.on("data", (data: Buffer) => { - for (const line of data.toString().split("\n")) { - if (line.trim()) console.log(`[daemon:restart] ${line.trim()}`); - } - }); - child.stderr?.on("data", (data: Buffer) => { - for (const line of data.toString().split("\n")) { - if (line.trim()) console.error(`[daemon:restart] ${line.trim()}`); - } - }); - - // Detach our handles so the spawned supervisor outlives this spec process and - // is reaped by globalSetup's cleanup (the original process tree), not us. - child.unref(); - return child; -} - -export async function restartTestDaemon(): Promise { - const port = getE2EDaemonPort(); - const paseoHome = getEnvOrThrow("E2E_PASEO_HOME"); - const relayPort = getEnvOrThrow("E2E_RELAY_PORT"); - const metroPort = getEnvOrThrow("E2E_METRO_PORT"); - const editorRecordPath = - process.env.E2E_EDITOR_RECORD_PATH ?? path.join(paseoHome, "editor-open-records.jsonl"); - - const pid = await readSupervisorPid(paseoHome); - process.kill(pid, "SIGTERM"); - - await waitUntil(() => !isPidRunning(pid), { - timeoutMs: 15_000, - label: `supervisor PID ${pid} to exit`, - }); - await waitUntil(async () => !(await isPortListening(Number(port))), { - timeoutMs: 15_000, - label: `port ${port} to free`, - }); - - spawnSupervisor({ paseoHome, port, relayPort, metroPort, editorRecordPath }); - - await waitUntil(async () => isPortListening(Number(port)), { - timeoutMs: 30_000, - label: `restarted daemon to listen on port ${port}`, - }); -} diff --git a/packages/app/e2e/helpers/daemon-websocket-gate.ts b/packages/app/e2e/helpers/daemon-websocket-gate.ts new file mode 100644 index 000000000..a6c7f3867 --- /dev/null +++ b/packages/app/e2e/helpers/daemon-websocket-gate.ts @@ -0,0 +1,114 @@ +import type { Page, WebSocketRoute } from "@playwright/test"; +import { daemonWsRoutePattern } from "./daemon-port"; + +export interface DirectoryBootstrapCounts { + agents: number; + workspaces: number; +} + +export interface DirectoryRequestStartCounts { + subscribed: DirectoryBootstrapCounts; + unsubscribed: DirectoryBootstrapCounts; + total: DirectoryBootstrapCounts; +} + +interface ClientRequest { + type?: unknown; + subscribe?: unknown; + page?: { cursor?: unknown }; +} + +function readClientRequest(message: string | Buffer): ClientRequest | null { + if (typeof message !== "string") return null; + try { + const envelope = JSON.parse(message) as { + type?: unknown; + message?: ClientRequest; + }; + return envelope.type === "session" ? (envelope.message ?? null) : envelope; + } catch { + return null; + } +} + +function directoryForRequest(request: ClientRequest): keyof DirectoryBootstrapCounts | null { + if (request.page?.cursor) return null; + if (request.type === "fetch_agents_request") return "agents"; + if (request.type === "fetch_workspaces_request") return "workspaces"; + return null; +} + +export async function installDaemonWebSocketGate(page: Page) { + let acceptingConnections = true; + const activeSockets = new Set(); + const directoryStarts: DirectoryRequestStartCounts = { + subscribed: { agents: 0, workspaces: 0 }, + unsubscribed: { agents: 0, workspaces: 0 }, + total: { agents: 0, workspaces: 0 }, + }; + const clientRequestCounts = new Map(); + + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + if (!acceptingConnections) { + void ws.close({ code: 1008, reason: "Blocked by reconnect test." }); + return; + } + + activeSockets.add(ws); + const server = ws.connectToServer(); + + ws.onMessage((message) => { + if (!acceptingConnections) return; + const request = readClientRequest(message); + if (typeof request?.type === "string") { + clientRequestCounts.set(request.type, (clientRequestCounts.get(request.type) ?? 0) + 1); + const directory = directoryForRequest(request); + if (directory) { + const subscription = request.subscribe === undefined ? "unsubscribed" : "subscribed"; + directoryStarts[subscription][directory] += 1; + directoryStarts.total[directory] += 1; + } + } + try { + server.send(message); + } catch { + activeSockets.delete(ws); + } + }); + + server.onMessage((message) => { + if (!acceptingConnections) return; + try { + ws.send(message); + } catch { + activeSockets.delete(ws); + } + }); + }); + + return { + async drop(): Promise { + acceptingConnections = false; + const sockets = Array.from(activeSockets); + activeSockets.clear(); + await Promise.all( + sockets.map((ws) => + ws.close({ code: 1008, reason: "Dropped by reconnect test." }).catch(() => undefined), + ), + ); + }, + restore(): void { + acceptingConnections = true; + }, + getDirectoryRequestStartCounts(): DirectoryRequestStartCounts { + return { + subscribed: { ...directoryStarts.subscribed }, + unsubscribed: { ...directoryStarts.unsubscribed }, + total: { ...directoryStarts.total }, + }; + }, + getClientRequestCount(type: string): number { + return clientRequestCounts.get(type) ?? 0; + }, + }; +} diff --git a/packages/app/e2e/helpers/directory-bootstrap-scenario.ts b/packages/app/e2e/helpers/directory-bootstrap-scenario.ts new file mode 100644 index 000000000..97192be9f --- /dev/null +++ b/packages/app/e2e/helpers/directory-bootstrap-scenario.ts @@ -0,0 +1,155 @@ +import { expect, type Page } from "@playwright/test"; +import { buildHostAgentDetailRoute } from "@/utils/host-routes"; +import { installDaemonWebSocketGate } from "./daemon-websocket-gate"; +import { seedWorkspace, type SeededWorkspace } from "./seed-client"; +import { getServerId } from "./server-id"; +import { waitForWorkspaceTabsVisible } from "./workspace-tabs"; +import { expectReconnectingToastGone, expectReconnectingToastVisible } from "./workspace-ui"; + +interface SeededDirectoryAgent { + id: string; + title: string; +} + +async function createRunningMockAgent( + workspace: SeededWorkspace, + title: string, +): Promise { + const agent = await workspace.client.createAgent({ + provider: "mock", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title, + modeId: "load-test", + model: "five-minute-stream", + initialPrompt: `Keep ${title} running for directory synchronization.`, + }); + const running = await workspace.client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 30_000, + ); + expect(running.status).toBe("running"); + return { id: agent.id, title }; +} + +async function openCommandCenter(page: Page): Promise { + await page.getByRole("button", { name: "Open command center" }).click(); +} + +export class DirectoryBootstrapScenario { + private readonly workspaces: SeededWorkspace[] = []; + private disconnectedWorkspace: SeededWorkspace | null = null; + private disconnectedAgent: SeededDirectoryAgent | null = null; + + private constructor( + private readonly page: Page, + private readonly gate: Awaited>, + ) {} + + static async open(page: Page): Promise { + const gate = await installDaemonWebSocketGate(page); + const scenario = new DirectoryBootstrapScenario(page, gate); + const workspace = await scenario.seedWorkspace("directory-bootstrap-initial-"); + const agent = await createRunningMockAgent(workspace, "Initial directory agent"); + await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, workspace.workspaceId)); + await page.waitForURL( + (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), + ); + await waitForWorkspaceTabsVisible(page); + await expect(page.getByRole("button", { name: agent.title, exact: true })).toBeVisible(); + return scenario; + } + + async expectDirectoryStarts(expectedPerDirectory: number): Promise { + await expect + .poll(() => this.gate.getDirectoryRequestStartCounts()) + .toEqual({ + subscribed: { agents: expectedPerDirectory, workspaces: expectedPerDirectory }, + unsubscribed: { agents: 0, workspaces: 0 }, + total: { agents: expectedPerDirectory, workspaces: expectedPerDirectory }, + }); + } + + async stayConnectedWithoutRefetchAndApplyDeltas(): Promise { + const workspace = await this.seedWorkspace("directory-bootstrap-background-"); + const agent = await createRunningMockAgent(workspace, "Background directory agent"); + + const workspaceLink = this.page.getByText(workspace.projectDisplayName, { exact: true }); + await expect(workspaceLink).toHaveCount(1); + await expect(workspaceLink).toBeVisible(); + await openCommandCenter(this.page); + const agentLink = this.page.getByText(agent.title, { exact: true }); + await expect(agentLink).toHaveCount(1); + await expect(agentLink).toBeVisible(); + await this.page.keyboard.press("Escape"); + await this.expectDirectoryStarts(1); + } + + async disconnectMutateAndReconnect(): Promise { + await this.gate.drop(); + await expectReconnectingToastVisible(this.page); + + this.disconnectedWorkspace = await this.seedWorkspace("directory-bootstrap-reconnect-"); + this.disconnectedAgent = await createRunningMockAgent( + this.disconnectedWorkspace, + "Reconnected directory agent", + ); + await expect( + this.page.getByText(this.disconnectedWorkspace.projectDisplayName, { exact: true }), + ).toHaveCount(0); + await expect(this.page.getByText(this.disconnectedAgent.title, { exact: true })).toHaveCount(0); + + this.gate.restore(); + await expectReconnectingToastGone(this.page); + await this.expectDirectoryStarts(2); + } + + async expectVisibleReconciliationAndNavigateAgent(): Promise { + const workspace = this.requireDisconnectedWorkspace(); + const agent = this.requireDisconnectedAgent(); + const workspaceLink = this.page.getByText(workspace.projectDisplayName, { exact: true }); + await expect(workspaceLink).toHaveCount(1); + await expect(workspaceLink).toBeVisible(); + await openCommandCenter(this.page); + const agentLink = this.page.getByText(agent.title, { exact: true }); + await expect(agentLink).toHaveCount(1); + await expect(agentLink).toBeVisible(); + await agentLink.click(); + await expect(this.page).toHaveURL( + new RegExp( + `/workspace/${workspace.workspaceId}/agent/${agent.id}|/workspace/${workspace.workspaceId}`, + ), + ); + await expect(this.page.getByRole("button", { name: agent.title, exact: true })).toHaveAttribute( + "aria-selected", + "true", + ); + const pings = this.gate.getClientRequestCount("ping"); + await expect + .poll(() => this.gate.getClientRequestCount("ping"), { timeout: 30_000 }) + .toBeGreaterThan(pings); + await this.expectDirectoryStarts(2); + } + + async cleanup(): Promise { + this.gate.restore(); + await Promise.all(this.workspaces.map((workspace) => workspace.cleanup())); + } + + private async seedWorkspace(prefix: string): Promise { + const workspace = await seedWorkspace({ repoPrefix: prefix }); + this.workspaces.push(workspace); + return workspace; + } + + private requireDisconnectedWorkspace(): SeededWorkspace { + if (!this.disconnectedWorkspace) throw new Error("Reconnect workspace was not seeded."); + return this.disconnectedWorkspace; + } + + private requireDisconnectedAgent(): SeededDirectoryAgent { + if (!this.disconnectedAgent) throw new Error("Reconnect agent was not seeded."); + return this.disconnectedAgent; + } +} diff --git a/packages/app/e2e/helpers/isolated-host-daemon.ts b/packages/app/e2e/helpers/isolated-host-daemon.ts index d98d40e4f..1d2d6a0db 100644 --- a/packages/app/e2e/helpers/isolated-host-daemon.ts +++ b/packages/app/e2e/helpers/isolated-host-daemon.ts @@ -9,6 +9,7 @@ import { withDisabledE2ESpeechEnv } from "./speech-env"; export interface IsolatedHostDaemon { serverId: string; port: number; + restart(): Promise; close(): Promise; } @@ -85,43 +86,61 @@ export async function startIsolatedHostDaemon(serverId: string): Promise => { + const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], { + cwd: serverDir, + env: withDisabledE2ESpeechEnv({ + ...process.env, + PASEO_HOME: paseoHome, + PASEO_SERVER_ID: serverId, + PASEO_LISTEN: `127.0.0.1:${port}`, + PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`, + PASEO_RELAY_ENABLED: "0", + PASEO_NODE_ENV: "development", + NODE_ENV: "development", + }), + stdio: ["ignore", "ignore", "pipe"], + detached: false, + }); - let stderr = ""; - child.stderr?.on("data", (chunk: Buffer) => { - stderr += chunk.toString("utf8"); - stderr = stderr.split("\n").slice(-40).join("\n"); - }); + let stderr = ""; + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + stderr = stderr.split("\n").slice(-40).join("\n"); + }); + try { + await waitForServer(port, child); + return child; + } catch (error) { + await stopProcess(child); + throw new Error( + `${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`, + { cause: error }, + ); + } + }; + + let child: ChildProcess; try { - await waitForServer(port, child); + child = await spawnDaemon(); } catch (error) { - await stopProcess(child); await rm(paseoHome, { recursive: true, force: true }); - throw new Error( - `${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`, - { cause: error }, - ); + throw error; } + let closed = false; return { serverId, port, + restart: async () => { + if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`); + await stopProcess(child); + child = await spawnDaemon(); + }, close: async () => { + if (closed) return; + closed = true; await stopProcess(child); await rm(paseoHome, { recursive: true, force: true }); }, diff --git a/packages/app/e2e/helpers/new-workspace.ts b/packages/app/e2e/helpers/new-workspace.ts index 13c267ab3..e8ef24e32 100644 --- a/packages/app/e2e/helpers/new-workspace.ts +++ b/packages/app/e2e/helpers/new-workspace.ts @@ -89,9 +89,12 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul return decodeWorkspaceIdFromPathSegment(match[1]); } -export async function connectNewWorkspaceDaemonClient(): Promise { +export async function connectNewWorkspaceDaemonClient(options?: { + port?: number; +}): Promise { return connectDaemonClient({ clientIdPrefix: "app-e2e-new-workspace", + port: options?.port, }); } diff --git a/packages/app/e2e/helpers/seed-client.ts b/packages/app/e2e/helpers/seed-client.ts index 6a04bbd51..2b243022f 100644 --- a/packages/app/e2e/helpers/seed-client.ts +++ b/packages/app/e2e/helpers/seed-client.ts @@ -158,10 +158,11 @@ export interface SeedDaemonClient { killTerminal(terminalId: string): Promise<{ error: string | null }>; } -export async function connectSeedClient(): Promise { +export async function connectSeedClient(options?: { port?: number }): Promise { return connectDaemonClient({ clientIdPrefix: "seed", appVersion: loadAppVersion(), + port: options?.port, }); } diff --git a/packages/app/e2e/helpers/sidebar.ts b/packages/app/e2e/helpers/sidebar.ts index c6d0fb3ce..dc06f26cc 100644 --- a/packages/app/e2e/helpers/sidebar.ts +++ b/packages/app/e2e/helpers/sidebar.ts @@ -39,6 +39,13 @@ export async function clickArchiveWorkspaceMenuItem( await archiveItem.click(); } +export async function pinWorkspaceFromSidebar(page: Page, workspaceId: string): Promise { + const serverId = await openWorkspaceSidebarKebab(page, workspaceId); + const pinItem = page.getByTestId(`sidebar-workspace-menu-pin-${serverId}:${workspaceId}`); + await expect(pinItem).toBeVisible({ timeout: 10_000 }); + await pinItem.click(); +} + export async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Promise { // A clean workspace archives with no prompt. Managed worktree backing may raise // a browser confirm for unsynced work, so accept it when present. diff --git a/packages/app/e2e/helpers/timeline-delivery.ts b/packages/app/e2e/helpers/timeline-delivery.ts new file mode 100644 index 000000000..1ac692b56 --- /dev/null +++ b/packages/app/e2e/helpers/timeline-delivery.ts @@ -0,0 +1,107 @@ +import { expect, type Page } from "@playwright/test"; + +type WebSocketMessage = string | Buffer; + +interface SessionMessage { + type?: unknown; + payload?: unknown; +} + +function readSessionMessage(message: WebSocketMessage): SessionMessage | null { + if (typeof message !== "string") return null; + try { + const envelope = JSON.parse(message) as { + type?: unknown; + message?: SessionMessage; + }; + return envelope.type === "session" ? (envelope.message ?? null) : envelope; + } catch { + return null; + } +} + +export function observeTimelineSubscriptions(page: Page) { + let acknowledgedAgentIds: string[] | null = null; + + page.on("websocket", (socket) => { + socket.on("framereceived", ({ payload }) => { + const message = readSessionMessage(payload); + if (message?.type !== "agent.timeline.set_subscription.response") return; + const response = message.payload as { agentIds?: unknown } | undefined; + if (!Array.isArray(response?.agentIds)) return; + acknowledgedAgentIds = response.agentIds.filter( + (agentId): agentId is string => typeof agentId === "string", + ); + }); + }); + + return { + async waitForSubscribedAgents(agentIds: string[]): Promise { + const expected = [...new Set(agentIds)].sort(); + await expect + .poll(() => acknowledgedAgentIds?.slice().sort() ?? null, { timeout: 15_000 }) + .toEqual(expected); + }, + }; +} + +interface AssistantFrameState { + active: boolean; + lastText: string | null; + snapshots: string[]; +} + +export async function observeLastAssistantFrames(page: Page) { + await page.evaluate(() => { + const state: AssistantFrameState = { + active: true, + lastText: null, + snapshots: [], + }; + const sample = () => { + const hasPaintedHistoryOverlay = Array.from( + document.querySelectorAll('[data-testid="agent-history-overlay"]'), + ).some((element) => { + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }); + if (!hasPaintedHistoryOverlay) { + const messages = Array.from( + document.querySelectorAll('[data-testid="assistant-message"]'), + ).filter((element) => { + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }); + const text = messages.at(-1)?.textContent?.trim() ?? ""; + if (text && text !== state.lastText) { + state.lastText = text; + state.snapshots.push(text); + } + } + if (state.active) requestAnimationFrame(sample); + }; + Object.assign(window, { __assistantFrameState: state }); + requestAnimationFrame(sample); + }); + + return { + async stop(): Promise { + return page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => { + const state = ( + window as typeof window & { __assistantFrameState?: AssistantFrameState } + ).__assistantFrameState; + if (!state) { + resolve([]); + return; + } + state.active = false; + resolve([...state.snapshots]); + }); + }), + ); + }, + }; +} diff --git a/packages/app/e2e/projects-settings.spec.ts b/packages/app/e2e/projects-settings.spec.ts index 1669586b5..6330b8cef 100644 --- a/packages/app/e2e/projects-settings.spec.ts +++ b/packages/app/e2e/projects-settings.spec.ts @@ -208,7 +208,7 @@ test.describe("Projects settings", () => { page, gitlabRemoteProject, }) => { - expect(gitlabRemoteProject.name).toBe("acme/app"); + expect(gitlabRemoteProject.name).toBe(path.basename(gitlabRemoteProject.path)); await openProjects(page); await openProjectSettings(page, gitlabRemoteProject.name); await editWorktreeSetup(page, updatedSetup); diff --git a/packages/app/e2e/sidebar-help.spec.ts b/packages/app/e2e/sidebar-help.spec.ts index abf6919f1..129251516 100644 --- a/packages/app/e2e/sidebar-help.spec.ts +++ b/packages/app/e2e/sidebar-help.spec.ts @@ -81,6 +81,32 @@ test("opens support and release destinations", async ({ page }) => { await expectExternalPage(page, "sidebar-help-changelog", CHANGELOG_DESTINATION); }); +test("searches keyboard shortcuts from the sidebar help menu", async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(navigator, "platform", { get: () => "MacIntel" }); + }); + await gotoAppShell(page); + await openHelpMenu(page); + await page.getByTestId("sidebar-help-shortcuts").click(); + + const dialog = page.getByTestId("keyboard-shortcuts-dialog"); + const search = page.getByPlaceholder("Search shortcuts"); + + await search.fill("command+n"); + await expect(dialog.getByText("New workspace", { exact: true })).toBeVisible(); + + await search.fill("interrupt"); + + await expect(dialog.getByText("Interrupt agent", { exact: true })).toBeVisible(); + await expect(dialog.getByText("New workspace", { exact: true })).toHaveCount(0); + + await search.fill("no matching shortcut"); + await expect(dialog.getByText("No results found", { exact: true })).toBeVisible(); + + await search.fill(""); + await expect(dialog.getByText("New workspace", { exact: true })).toBeVisible(); +}); + test("keeps diagnostics available from Settings after globalizing the sheet", async ({ page }) => { await gotoAppShell(page); await openSettings(page); diff --git a/packages/app/e2e/sidebar-reorder.spec.ts b/packages/app/e2e/sidebar-reorder.spec.ts new file mode 100644 index 000000000..0979694f0 --- /dev/null +++ b/packages/app/e2e/sidebar-reorder.spec.ts @@ -0,0 +1,71 @@ +import type { Locator } from "@playwright/test"; +import { expect, test } from "./fixtures"; +import { gotoAppShell } from "./helpers/app"; +import { getServerId } from "./helpers/server-id"; +import { seedWorkspace } from "./helpers/seed-client"; +import { waitForSidebarHydration } from "./helpers/workspace-ui"; + +async function rowTestIds(rows: Locator) { + return rows.evaluateAll((elements) => + elements.map((element) => element.getAttribute("data-testid")), + ); +} + +async function visibleBoundingBox(row: Locator) { + const box = await row.boundingBox(); + if (!box) throw new Error("Expected a visible draggable row"); + return box; +} + +async function quickDragFirstRowAfterSecond(rows: Locator) { + await expect(rows).toHaveCount(2); + const before = await rowTestIds(rows); + const sourceBox = await visibleBoundingBox(rows.nth(0)); + const targetBox = await visibleBoundingBox(rows.nth(1)); + + const page = rows.page(); + const source = { x: sourceBox.x + sourceBox.width / 2, y: sourceBox.y + sourceBox.height / 2 }; + const target = { x: targetBox.x + targetBox.width / 2, y: targetBox.y + targetBox.height / 2 }; + + await page.mouse.move(source.x, source.y); + await page.mouse.down(); + await page.mouse.move(source.x, source.y + 7); + await page.mouse.move(target.x, target.y, { steps: 4 }); + await page.mouse.up(); + + await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]); +} + +test("projects and workspaces reorder with an immediate mouse drag", async ({ page }) => { + const firstProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-first-" }); + const secondProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-second-" }); + + try { + const secondWorkspace = await firstProject.client.createWorkspace({ + source: { + kind: "directory", + path: firstProject.repoPath, + projectId: firstProject.projectId, + }, + title: "Second workspace", + }); + if (!secondWorkspace.workspace) { + throw new Error(secondWorkspace.error ?? "Failed to seed a second workspace"); + } + + await gotoAppShell(page); + await waitForSidebarHydration(page); + + await quickDragFirstRowAfterSecond(page.locator('[data-testid^="sidebar-project-row-"]')); + const firstWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${firstProject.workspaceId}`; + const secondWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${secondWorkspace.workspace.id}`; + await quickDragFirstRowAfterSecond( + page.locator( + `[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondWorkspaceTestId}"]`, + ), + ); + } finally { + await firstProject.cleanup(); + await secondProject.cleanup(); + } +}); diff --git a/packages/app/e2e/sidebar-resize-handle.spec.ts b/packages/app/e2e/sidebar-resize-handle.spec.ts new file mode 100644 index 000000000..eda7f2c81 --- /dev/null +++ b/packages/app/e2e/sidebar-resize-handle.spec.ts @@ -0,0 +1,27 @@ +import { expect, test, type Page } from "./fixtures"; + +test.use({ viewport: { width: 1600, height: 900 } }); + +async function expectBorderHighlight(page: Page, testID: string) { + const handle = page.getByTestId(testID); + await expect(handle).toBeVisible(); + await expect(page.getByTestId(`${testID}-highlight`)).toHaveCount(0); + + await handle.hover(); + await expect(page.getByTestId(`${testID}-highlight`)).toHaveCount(0); + + const highlight = page.getByTestId(`${testID}-highlight`); + await expect(highlight).toBeVisible(); + await expect(highlight).toHaveCSS("width", "1px"); + await expect(highlight).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); +} + +test("both sidebar borders highlight on hover", async ({ page, withWorkspace }) => { + const workspace = await withWorkspace({ prefix: "sidebar-resize-handle-" }); + await workspace.navigateTo(); + + await expectBorderHighlight(page, "left-sidebar-resize-handle"); + + await page.getByTestId("workspace-explorer-toggle").first().click(); + await expectBorderHighlight(page, "explorer-sidebar-resize-handle"); +}); diff --git a/packages/app/e2e/sidebar-workspace.spec.ts b/packages/app/e2e/sidebar-workspace.spec.ts index d23085852..51fee85cb 100644 --- a/packages/app/e2e/sidebar-workspace.spec.ts +++ b/packages/app/e2e/sidebar-workspace.spec.ts @@ -6,6 +6,7 @@ import { expectMobileAgentSidebarHidden, expectMobileAgentSidebarVisible, openMobileAgentSidebar, + pinWorkspaceFromSidebar, } from "./helpers/sidebar"; import { seedWorkspace } from "./helpers/seed-client"; import { expectWorkspaceHeader } from "./helpers/workspace-ui"; @@ -46,24 +47,25 @@ async function waitForSidebarWorkspace(page: import("@playwright/test").Page, wo } test.describe("Sidebar workspace list", () => { - test("project with GitHub remote shows owner/repo name in sidebar", async ({ page }) => { + test("project with GitHub remote shows its selected folder name in sidebar", async ({ page }) => { const workspace = await seedWorkspace({ repoPrefix: "sidebar-remote-", repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL }, }); try { + const projectName = path.basename(workspace.repoPath); await gotoAppShell(page); - await waitForSidebarProject(page, "test-owner/test-repo"); + await waitForSidebarProject(page, projectName); await waitForSidebarWorkspace(page, workspace.workspaceId); const projectRow = page .locator('[data-testid^="sidebar-project-row-"]') - .filter({ hasText: "test-owner/test-repo" }) + .filter({ hasText: projectName }) .first(); await expect(projectRow).toBeVisible({ timeout: 30_000 }); - await expect(projectRow).not.toContainText(path.basename(workspace.repoPath)); + await expect(projectRow).not.toContainText("test-owner/test-repo"); } finally { await workspace.cleanup(); } @@ -96,21 +98,24 @@ test.describe("Sidebar workspace list", () => { } }); - test("workspace header shows correct title and subtitle", async ({ page }) => { + test("workspace header uses the selected folder name instead of its GitHub remote", async ({ + page, + }) => { const workspace = await seedWorkspace({ repoPrefix: "sidebar-header-", repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL }, }); try { + const projectName = path.basename(workspace.repoPath); await gotoAppShell(page); - await waitForSidebarProject(page, "test-owner/test-repo"); + await waitForSidebarProject(page, projectName); await waitForSidebarWorkspace(page, workspace.workspaceId); await openWorkspaceFromSidebar(page, workspace.workspaceId); await expectWorkspaceHeader(page, { title: workspace.workspaceName, - subtitle: "test-owner/test-repo", + subtitle: projectName, }); } finally { await workspace.cleanup(); @@ -164,6 +169,30 @@ test.describe("Mobile sidebar panelState transition", () => { await closeMobileAgentSidebar(page); await expectMobileAgentSidebarHidden(page); }); + + test("keeps a pinned workspace rendered while the retained sidebar is closed", async ({ + page, + }) => { + const workspace = await seedWorkspace({ repoPrefix: "sidebar-retained-pin-" }); + + try { + await gotoAppShell(page); + await openMobileAgentSidebar(page); + await expectMobileAgentSidebarVisible(page); + + const row = page.getByTestId(getWorkspaceRowTestId(workspace.workspaceId)); + await expect(row).toBeVisible({ timeout: 30_000 }); + await pinWorkspaceFromSidebar(page, workspace.workspaceId); + await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible(); + + await closeMobileAgentSidebar(page); + await expectMobileAgentSidebarHidden(page); + + await expect(row).toHaveCount(1); + } finally { + await workspace.cleanup(); + } + }); }); test.describe("Half-screen desktop layout", () => { diff --git a/packages/app/e2e/terminal-stuck-size.spec.ts b/packages/app/e2e/terminal-stuck-size.spec.ts index ebdfd0110..9e80f16b5 100644 --- a/packages/app/e2e/terminal-stuck-size.spec.ts +++ b/packages/app/e2e/terminal-stuck-size.spec.ts @@ -1,5 +1,6 @@ import { test, expect, type Page } from "./fixtures"; import { TerminalE2EHarness } from "./helpers/terminal-dsl"; +import { getTerminalBufferText } from "./helpers/terminal-perf"; import { buildHostWorkspaceRoute } from "../src/utils/host-routes"; import { getServerId } from "./helpers/server-id"; @@ -8,25 +9,21 @@ import { getServerId } from "./helpers/server-id"; * once focus returns. * * The PTY is only ever resized by an explicit client claim (`terminal_input` resize). A freshly - * mounted terminal produces exactly one claim, from terminal-pane's pane-focus reflow effect. If - * that claim is emitted while the app is not actively visible (window blurred / document hidden), - * `handleTerminalResize` drops it — and nothing used to re-send it, leaving the PTY at 80x24 - * while xterm renders the real pane size: vim/top squeezed into a corner until the user resizes - * the window. + * mounted terminal starts its claim from terminal-pane's pane-focus reflow effect. Previously, if + * that claim was emitted while the app was not actively visible, `handleTerminalResize` dropped + * it without a retry, leaving the PTY at 80x24 while xterm rendered the real pane size. * * The repro is the mundane user flow: with a workspace already showing a terminal, open another * one and switch to a different app while it spawns. We stage the blur deterministically by - * stubbing `document.hasFocus()` (which `useAppVisible` consults on window focus/blur events) - * instead of racing real OS focus. The first terminal matters: `useAppVisible` only observes - * focus events while a consumer is mounted, so it is what makes the blur visible to the app — - * exactly as in the real flow. + * stubbing `document.hasFocus()` (which `useAppActivelyVisible` consults on focus/blur events) + * instead of racing real OS focus. * * The assertion reads the PTY's own opinion of its size (`stty size`) with input written * daemon-side — clicking or typing in the pane would fire the focus-claim path and mask the bug. */ /** - * Simulates the window losing/regaining OS focus, which is what `useAppVisible` reads through + * Simulates the window losing/regaining OS focus, which is what `useAppActivelyVisible` reads through * `document.hasFocus()` plus the window focus/blur events. * * This has to be stubbed: headless Chromium never actually blurs. Opening a second page and @@ -58,31 +55,6 @@ async function readRenderedTerminalSize(page: Page): Promise { - return await page.evaluate(() => { - const terminal = ( - window as Window & { - __paseoTerminal?: { - buffer: { - active: { - length: number; - getLine(index: number): { translateToString(trim: boolean): string } | undefined; - }; - }; - }; - } - ).__paseoTerminal; - if (!terminal) { - return ""; - } - const lines: string[] = []; - for (let index = 0; index < terminal.buffer.active.length; index += 1) { - lines.push(terminal.buffer.active.getLine(index)?.translateToString(true) ?? ""); - } - return lines.join("\n"); - }); -} - async function createTerminalViaMenu(page: Page): Promise { await page.getByTestId("workspace-new-tab-menu-trigger").click(); await page.getByTestId("workspace-new-tab-menu-terminal").click(); @@ -112,116 +84,10 @@ function requireTerminalSize(size: RenderedTerminalSize | null): RenderedTermina return size; } -function claimsFor(frames: ResizeClaim[], terminalId: string): ResizeClaim[] { - return frames.filter((frame) => frame.terminalId === terminalId); -} - -function parseSttySize(bufferText: string): RenderedTerminalSize { - const match = /S=(\d+) (\d+)=/.exec(bufferText); - if (!match?.[1] || !match[2]) { - throw new Error(`stty size did not print in the terminal buffer:\n${bufferText}`); - } - return { rows: Number(match[1]), cols: Number(match[2]) }; -} - -interface ResizeClaim { - terminalId: string; - rows: number; - cols: number; -} - -/** - * Records every resize claim the app puts on the wire. Claims travel two ways: as a JSON - * `terminal_input` before the stream has a binary slot, and as a binary frame - * ([opcode 0x03][slot][JSON {rows, cols}]) once subscribed. The slot -> terminal mapping comes - * from subscribe_terminal_response on the receive side. - */ -function captureResizeClaims(page: Page): ResizeClaim[] { - const resizeFrames: ResizeClaim[] = []; - const slotTerminals = new Map(); - const unmappedBinaryResizes: Array<{ slot: number; rows: number; cols: number }> = []; - - const recordBinaryResize = (slot: number, size: { rows: number; cols: number }) => { - const terminalId = slotTerminals.get(slot); - if (terminalId) { - resizeFrames.push({ terminalId, ...size }); - } else { - unmappedBinaryResizes.push({ slot, ...size }); - } - }; - - const handleSentFrame = (frame: { payload: string | Buffer }) => { - const payload = frame.payload; - if (typeof payload !== "string") { - if (payload.length >= 2 && payload[0] === 0x03) { - try { - const parsed = JSON.parse(payload.subarray(2).toString("utf8")) as { - rows?: number; - cols?: number; - }; - if (parsed.rows !== undefined && parsed.cols !== undefined) { - recordBinaryResize(payload[1] ?? -1, { rows: parsed.rows, cols: parsed.cols }); - } - } catch { - // not a terminal resize frame — ignore - } - } - return; - } - if (!payload.includes("resize")) { - return; - } - try { - const outer = JSON.parse(payload) as { message?: Record }; - const message = outer.message ?? {}; - if (message.type !== "terminal_input") { - return; - } - const inner = message.message as { type?: string; rows?: number; cols?: number }; - if (inner?.type === "resize" && inner.rows !== undefined && inner.cols !== undefined) { - resizeFrames.push({ - terminalId: String(message.terminalId ?? ""), - rows: inner.rows, - cols: inner.cols, - }); - } - } catch { - // not JSON — ignore - } - }; - - const handleReceivedFrame = (frame: { payload: string | Buffer }) => { - const payload = frame.payload; - if (typeof payload !== "string" || !payload.includes("subscribe_terminal_response")) { - return; - } - try { - const outer = JSON.parse(payload) as { message?: Record }; - const message = outer.message ?? {}; - if (message.type !== "subscribe_terminal_response") { - return; - } - const responsePayload = (message.payload ?? {}) as { terminalId?: string; slot?: number }; - if ( - typeof responsePayload.terminalId === "string" && - typeof responsePayload.slot === "number" - ) { - slotTerminals.set(responsePayload.slot, responsePayload.terminalId); - for (const entry of unmappedBinaryResizes.splice(0)) { - recordBinaryResize(entry.slot, { rows: entry.rows, cols: entry.cols }); - } - } - } catch { - // not JSON — ignore - } - }; - - page.on("websocket", (ws) => { - ws.on("framesent", handleSentFrame); - ws.on("framereceived", handleReceivedFrame); - }); - - return resizeFrames; +function parseLatestSttySize(bufferText: string): RenderedTerminalSize | null { + const matches = [...bufferText.matchAll(/S\d+=(\d+) (\d+)=/g)]; + const match = matches.at(-1); + return match?.[1] && match[2] ? { rows: Number(match[1]), cols: Number(match[2]) } : null; } test.describe("terminal PTY size claim under lost window focus", () => { @@ -239,16 +105,14 @@ test.describe("terminal PTY size claim under lost window focus", () => { page, }) => { await page.setViewportSize({ width: 1280, height: 900 }); - const resizeFrames = captureResizeClaims(page); await page.goto(buildHostWorkspaceRoute(getServerId(), harness.workspaceId)); await expect(page.getByTestId("workspace-new-tab-menu-trigger")).toBeVisible({ timeout: 30_000, }); - // A terminal is already open — this also keeps useAppVisible's listeners mounted so the - // upcoming blur is actually observed by the app. Wait for the daemon to know about it rather - // than sleeping: if it were missing from this snapshot it would be misread as "new" below. + // Wait for the daemon to know about the first terminal rather than sleeping: if it were + // missing from this snapshot it would be misread as "new" below. await createTerminalViaMenu(page); await expect(harness.terminalSurface(page).first()).toBeVisible({ timeout: 30_000 }); const countTerminals = async () => (await listTerminalIds(harness)).length; @@ -265,14 +129,19 @@ test.describe("terminal PTY size claim under lost window focus", () => { await expect.poll(async () => (await findNewTerminalIds()).length, { timeout: 15_000 }).toBe(1); const newTerminalId = exactlyOne(await findNewTerminalIds(), "new terminal"); - // Let every mount-time refit run (the ladder goes out to 2s) while the window stays blurred. - // This one has to be a wait, not a poll: the assertion is that nothing happens. + // Keep the app blurred through the complete mount-time refit ladder, which runs out to 2s. await page.waitForTimeout(2_500); - // Nothing may reach the daemon while the app is hidden — handleTerminalResize drops claims - // that fire while !isAppVisible. Without this, deleting that gate would still leave the test - // green: the claim would simply arrive early instead of after focus returns. - expect(claimsFor(resizeFrames, newTerminalId)).toEqual([]); + await harness.client.subscribeTerminal(newTerminalId); + + // Confirm the PTY is still at its spawn size before focus returns. + harness.client.sendTerminalInput(newTerminalId, { + type: "input", + data: 'echo "S0=$(stty size)="\n', + }); + await expect + .poll(async () => getTerminalBufferText(page), { timeout: 15_000 }) + .toMatch(/S0=24 80=/); // ...and comes back. await setWindowFocused(page, true); @@ -282,29 +151,20 @@ test.describe("terminal PTY size claim under lost window focus", () => { // Sanity: the pane really rendered at a desktop size, not the PTY default. expect(rendered.cols).toBeGreaterThan(80); - // The claim must reach the wire once focus is back. Poll for it rather than sleeping: this - // is the behavior under test, so waiting a fixed duration would trade a real assertion for - // a timing bet. - const claimsForNewTerminal = () => claimsFor(resizeFrames, newTerminalId); - await expect - .poll(claimsForNewTerminal, { timeout: 15_000 }) - .toContainEqual({ terminalId: newTerminalId, rows: rendered.rows, cols: rendered.cols }); - - // And the PTY itself must agree. Ask it via the daemon, never via the page: focusing or + // The PTY itself must agree. Ask it via the daemon, never via the page: focusing or // typing in the pane triggers the focus-claim path and would mask the bug. - await harness.client.subscribeTerminal(newTerminalId); - harness.client.sendTerminalInput(newTerminalId, { - type: "input", - data: 'echo "S=$(stty size)="\n', - }); - + let probe = 1; await expect - .poll(async () => readTerminalBufferText(page), { timeout: 15_000 }) - .toMatch(/S=\d+ \d+=/); - - const ptySize = parseSttySize(await readTerminalBufferText(page)); - - // The PTY must match what xterm rendered — not the daemon's 80x24 spawn default. - expect(ptySize).toEqual({ rows: rendered.rows, cols: rendered.cols }); + .poll( + async () => { + harness.client.sendTerminalInput(newTerminalId, { + type: "input", + data: `echo "S${probe++}=$(stty size)="\n`, + }); + return parseLatestSttySize(await getTerminalBufferText(page)); + }, + { timeout: 15_000, intervals: [100, 250, 500] }, + ) + .toEqual({ rows: rendered.rows, cols: rendered.cols }); }); }); diff --git a/packages/app/e2e/viewed-agent-timelines.spec.ts b/packages/app/e2e/viewed-agent-timelines.spec.ts new file mode 100644 index 000000000..67f5b3289 --- /dev/null +++ b/packages/app/e2e/viewed-agent-timelines.spec.ts @@ -0,0 +1,188 @@ +import { expect, type Page } from "@playwright/test"; +import { buildHostAgentDetailRoute } from "@/utils/host-routes"; +import { test } from "./fixtures"; +import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client"; +import { getServerId } from "./helpers/server-id"; +import { + observeLastAssistantFrames, + observeTimelineSubscriptions, +} from "./helpers/timeline-delivery"; +import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; +import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; +import { + expectReconnectingToastGone, + expectReconnectingToastVisible, +} from "./helpers/workspace-ui"; + +interface ViewedTimelineScenario { + client: SeedDaemonClient; + workspaceId: string; + firstAgentId: string; + secondAgentId: string; + cleanup(): Promise; +} + +async function seedViewedTimelineScenario(): Promise { + const workspace = await seedWorkspace({ repoPrefix: "viewed-timelines-" }); + const createAgent = (title: string) => + workspace.client.createAgent({ + provider: "mock", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title, + modeId: "load-test", + model: "ten-second-stream", + }); + const [firstAgent, secondAgent] = await Promise.all([ + createAgent("First viewed chat"), + createAgent("Second viewed chat"), + ]); + return { + client: workspace.client, + workspaceId: workspace.workspaceId, + firstAgentId: firstAgent.id, + secondAgentId: secondAgent.id, + cleanup: workspace.cleanup, + }; +} + +async function openAgent(page: Page, scenario: ViewedTimelineScenario, agentId: string) { + await page.goto(buildHostAgentDetailRoute(getServerId(), agentId, scenario.workspaceId)); + await page.waitForURL( + (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), + ); + await waitForWorkspaceTabsVisible(page); +} + +async function selectAgent(page: Page, title: string) { + await page.getByRole("button", { name: title, exact: true }).click(); +} + +async function enableMoveTabShortcut(page: Page) { + await page.addInitScript(() => { + Object.defineProperty(navigator, "platform", { get: () => "MacIntel" }); + }); +} + +async function moveActiveTabRight(page: Page) { + await page.keyboard.press("Meta+Alt+Shift+ArrowRight"); +} + +async function commitMessage(scenario: ViewedTimelineScenario, agentId: string, prompt: string) { + await scenario.client.sendAgentMessage(agentId, prompt); + const finish = await scenario.client.waitForFinish(agentId, 30_000); + expect(finish.status).toBe("idle"); +} + +test.describe("Viewed agent timelines", () => { + test("a focused turn streams live and resumes its hidden remainder atomically", async ({ + page, + }) => { + test.setTimeout(90_000); + const subscriptions = observeTimelineSubscriptions(page); + const scenario = await seedViewedTimelineScenario(); + try { + await openAgent(page, scenario, scenario.firstAgentId); + await subscriptions.waitForSubscribedAgents([scenario.firstAgentId]); + + await scenario.client.sendAgentMessage( + scenario.firstAgentId, + "Stream while focused, then finish while hidden.", + ); + await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible(); + await expect( + page.getByTestId("assistant-message").filter({ hasText: "Cycle 1" }).first(), + ).toBeVisible(); + await expect(page.getByText("(end of synthetic stream)", { exact: true })).toHaveCount(0); + + await selectAgent(page, "Second viewed chat"); + await subscriptions.waitForSubscribedAgents([scenario.secondAgentId]); + const finish = await scenario.client.waitForFinish(scenario.firstAgentId, 30_000); + expect(finish.status).toBe("idle"); + + const assistantFrames = await observeLastAssistantFrames(page); + await selectAgent(page, "First viewed chat"); + await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible(); + const snapshots = await assistantFrames.stop(); + + expect(snapshots[0]).toContain("(end of synthetic stream)"); + } finally { + await scenario.cleanup(); + } + }); + + test("a hidden retained chat catches up when shown", async ({ page }) => { + const scenario = await seedViewedTimelineScenario(); + try { + await openAgent(page, scenario, scenario.firstAgentId); + await selectAgent(page, "Second viewed chat"); + await commitMessage( + scenario, + scenario.firstAgentId, + "Committed while the first chat is hidden.", + ); + await expect( + page.getByText("Committed while the first chat is hidden.", { exact: true }), + ).toHaveCount(0); + await selectAgent(page, "First viewed chat"); + await expect( + page.getByText("Committed while the first chat is hidden.", { exact: true }), + ).toBeVisible(); + } finally { + await scenario.cleanup(); + } + }); + + test("two visible split chats both stay current", async ({ page }) => { + const scenario = await seedViewedTimelineScenario(); + try { + await enableMoveTabShortcut(page); + await openAgent(page, scenario, scenario.firstAgentId); + await page.getByRole("button", { name: "Split pane right" }).click(); + await selectAgent(page, "Second viewed chat"); + await moveActiveTabRight(page); + await expect( + page.getByRole("button", { name: "First viewed chat", exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Second viewed chat", exact: true }), + ).toBeVisible(); + await expect(page.getByRole("textbox", { name: "Message agent..." })).toHaveCount(2); + await commitMessage(scenario, scenario.firstAgentId, "First visible pane update."); + await expect(page.getByText("First visible pane update.", { exact: true })).toBeVisible(); + await expect( + page.getByRole("button", { name: "Second viewed chat", exact: true }), + ).toBeVisible(); + } finally { + await scenario.cleanup(); + } + }); + + test("a visible chat catches up after reconnecting", async ({ page }) => { + const gate = await installDaemonWebSocketGate(page); + const scenario = await seedViewedTimelineScenario(); + try { + await openAgent(page, scenario, scenario.firstAgentId); + await expect(page.getByRole("button", { name: "First viewed chat" })).toHaveAttribute( + "aria-selected", + "true", + ); + await gate.drop(); + await expectReconnectingToastVisible(page); + await commitMessage(scenario, scenario.firstAgentId, "Committed while the chat reconnects."); + await expect( + page.getByText("Committed while the chat reconnects.", { exact: true }), + ).toHaveCount(0); + gate.restore(); + await expectReconnectingToastGone(page); + const recoveredMessage = page.getByText("Committed while the chat reconnects.", { + exact: true, + }); + await expect(recoveredMessage).toHaveCount(1); + await expect(recoveredMessage).toBeVisible(); + } finally { + gate.restore(); + await scenario.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/workspace-model-restart.spec.ts b/packages/app/e2e/workspace-model-restart.spec.ts index 295234094..3f459bf46 100644 --- a/packages/app/e2e/workspace-model-restart.spec.ts +++ b/packages/app/e2e/workspace-model-restart.spec.ts @@ -20,7 +20,7 @@ import { import { waitForSidebarHydration } from "./helpers/workspace-ui"; import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs"; -const LEGACY_AGENT_ID = "legacy-cwd-only-agent"; +const LEGACY_AGENT_ID = "10000000-0000-4000-8000-000000000001"; const SERVER_ID = `srv_restart_${randomUUID().replace(/-/g, "").slice(0, 16)}`; interface RestartDaemonClient { diff --git a/packages/app/e2e/workspace-navigation-regression.spec.ts b/packages/app/e2e/workspace-navigation-regression.spec.ts index d1fccbdc3..62b18f6ee 100644 --- a/packages/app/e2e/workspace-navigation-regression.spec.ts +++ b/packages/app/e2e/workspace-navigation-regression.spec.ts @@ -1,5 +1,4 @@ import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes"; -import type { WebSocketRoute } from "@playwright/test"; import { expect, test, type Page } from "./fixtures"; import { gotoAppShell, openSettings } from "./helpers/app"; import { @@ -34,6 +33,7 @@ import { clickSettingsBackToWorkspace } from "./helpers/settings"; import { getServerId } from "./helpers/server-id"; import { injectDesktopBridge } from "./helpers/desktop-updates"; import { expectAppRoute } from "./helpers/route-assertions"; +import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i; @@ -114,61 +114,6 @@ async function expectWorkspaceLocation( }); } -async function installDaemonWebSocketGate(page: Page) { - let acceptingConnections = true; - const activeSockets = new Set(); - - await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { - if (!acceptingConnections) { - void ws.close({ code: 1008, reason: "Blocked by workspace reconnect regression test." }); - return; - } - - activeSockets.add(ws); - const server = ws.connectToServer(); - - ws.onMessage((message) => { - if (!acceptingConnections) { - return; - } - try { - server.send(message); - } catch { - activeSockets.delete(ws); - } - }); - - server.onMessage((message) => { - if (!acceptingConnections) { - return; - } - try { - ws.send(message); - } catch { - activeSockets.delete(ws); - } - }); - }); - - return { - async drop(): Promise { - acceptingConnections = false; - const sockets = Array.from(activeSockets); - activeSockets.clear(); - await Promise.all( - sockets.map((ws) => - ws - .close({ code: 1008, reason: "Dropped by workspace reconnect regression test." }) - .catch(() => undefined), - ), - ); - }, - restore(): void { - acceptingConnections = true; - }, - }; -} - test.describe("Workspace navigation regression", () => { test.describe.configure({ timeout: 240_000 }); diff --git a/packages/app/e2e/worktree-restore-after-restart.spec.ts b/packages/app/e2e/worktree-restore-after-restart.spec.ts index 4ef4c3dce..a11df5daf 100644 --- a/packages/app/e2e/worktree-restore-after-restart.spec.ts +++ b/packages/app/e2e/worktree-restore-after-restart.spec.ts @@ -1,9 +1,10 @@ import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; -import { expect, test } from "./fixtures"; +import { expect, test, type Page } from "@playwright/test"; import { gotoAppShell } from "./helpers/app"; import { createIdleAgent, expectSessionRowArchived, openSessions } from "./helpers/archive-tab"; -import { restartTestDaemon } from "./helpers/daemon-restart"; +import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry"; +import { startIsolatedHostDaemon, type IsolatedHostDaemon } from "./helpers/isolated-host-daemon"; import { archiveWorkspaceFromDaemon, connectNewWorkspaceDaemonClient, @@ -11,11 +12,12 @@ import { openProjectViaDaemon, } from "./helpers/new-workspace"; import { connectSeedClient } from "./helpers/seed-client"; -import { getServerId } from "./helpers/server-id"; import { createTempGitRepo } from "./helpers/workspace"; import { waitForSidebarHydration } from "./helpers/workspace-ui"; test.describe("Worktree restore after daemon restart", () => { + const serverId = `srv_worktree_restart_${randomUUID().replaceAll("-", "").slice(0, 12)}`; + let daemon: IsolatedHostDaemon; let client: Awaited>; let worktreeClient: Awaited>; let tempRepo: { path: string; cleanup: () => Promise }; @@ -25,8 +27,9 @@ test.describe("Worktree restore after daemon restart", () => { test.describe.configure({ retries: 0, timeout: 180_000 }); test.beforeEach(async () => { - client = await connectSeedClient(); - worktreeClient = await connectNewWorkspaceDaemonClient(); + daemon = await startIsolatedHostDaemon(serverId); + client = await connectSeedClient({ port: daemon.port }); + worktreeClient = await connectNewWorkspaceDaemonClient({ port: daemon.port }); tempRepo = await createTempGitRepo("wt-restart-"); }); @@ -42,13 +45,33 @@ test.describe("Worktree restore after daemon restart", () => { await client?.close().catch(() => undefined); await worktreeClient?.close().catch(() => undefined); await tempRepo?.cleanup().catch(() => undefined); + await daemon?.close().catch(() => undefined); }); + async function seedBrowser(page: Page) { + const nowIso = new Date().toISOString(); + await page.addInitScript( + ({ host, preferences }) => { + localStorage.setItem("@paseo:e2e", "1"); + localStorage.setItem("@paseo:daemon-registry", JSON.stringify([host])); + localStorage.removeItem("@paseo:settings"); + localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); + }, + { + host: buildSeededHost({ + serverId, + endpoint: `127.0.0.1:${daemon.port}`, + label: "restart daemon", + nowIso, + }), + preferences: buildCreateAgentPreferences(serverId), + }, + ); + } + test("after archiving a worktree and restarting the daemon, History shows the worktree branch (not main) before any restore", async ({ page, }) => { - const serverId = getServerId(); - // A paseo worktree is cut on its own branch named after the slug, and the // worktree workspace is displayed under the same name. These are the values // the History table cells must show after restore — never "main". @@ -76,14 +99,16 @@ test.describe("Worktree restore after daemon restart", () => { .poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 }) .toBe(false); - // Bounce the isolated test daemon on the SAME home and port so it rebuilds - // all workspace/agent links from persisted state. Then reconnect both clients. + // Restart this spec's daemon on the same home and port so it rebuilds all + // workspace/agent links from persisted state without replacing the shared + // Playwright daemon owned by global setup. await client.close().catch(() => undefined); await worktreeClient.close().catch(() => undefined); - await restartTestDaemon(); - client = await connectSeedClient(); - worktreeClient = await connectNewWorkspaceDaemonClient(); + await daemon.restart(); + client = await connectSeedClient({ port: daemon.port }); + worktreeClient = await connectNewWorkspaceDaemonClient({ port: daemon.port }); + await seedBrowser(page); await gotoAppShell(page); await waitForSidebarHydration(page); await openSessions(page); diff --git a/packages/app/e2e/worktree-restore.spec.ts b/packages/app/e2e/worktree-restore.spec.ts index 4497b568e..480bdbad4 100644 --- a/packages/app/e2e/worktree-restore.spec.ts +++ b/packages/app/e2e/worktree-restore.spec.ts @@ -249,7 +249,7 @@ test.describe("Worktree restore", () => { try { await page.getByTestId("workspace-recovery-action").click(); await expect(page.getByTestId("workspace-recovery-error")).toHaveText( - "The project directory needed to restore this worktree no longer exists.", + "The source repository needed to restore this worktree no longer exists.", ); await expect(page.getByTestId("workspace-recovery-action")).toHaveText("Retry"); expect(existsSync(worktree.workspaceDirectory)).toBe(false); diff --git a/packages/app/eas.json b/packages/app/eas.json index 805217b89..1550f441f 100644 --- a/packages/app/eas.json +++ b/packages/app/eas.json @@ -25,6 +25,7 @@ "extends": "production", "distribution": "internal", "android": { + "resourceClass": "large", "buildType": "apk", "gradleCommand": ":app:assembleRelease -x lint -x lintVitalAnalyzeRelease -x lintVitalRelease -x generateReleaseLintModel -x generateReleaseLintVitalModel" } diff --git a/packages/app/package.json b/packages/app/package.json index 5c8418de2..7846d16ab 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.1.110", + "version": "0.2.0-beta.1", "private": true, "main": "index.ts", "scripts": { diff --git a/packages/app/src/add-project-flow/model.test.ts b/packages/app/src/add-project-flow/model.test.ts index 8319771c3..ff496f53c 100644 --- a/packages/app/src/add-project-flow/model.test.ts +++ b/packages/app/src/add-project-flow/model.test.ts @@ -16,6 +16,7 @@ import { type AddProjectHost, } from "./model"; import { + addProjectMethodEmptyText, buildAddProjectMethods, buildCloneLocationOptions, buildManualGithubRepositoryChoices, @@ -110,6 +111,13 @@ describe("Add Project navigation", () => { }); describe("Add Project options", () => { + it("hides every mutating method when the host lacks stable project identity", () => { + const outdatedHost = { ...HOST, canAddProject: false }; + + expect(buildAddProjectMethods(outdatedHost)).toEqual([]); + expect(addProjectMethodEmptyText(outdatedHost)).toBe("Update the host to use Add Project."); + }); + it("keeps host-upgrade methods discoverable while hiding local-only Browse", () => { expect( buildAddProjectMethods({ diff --git a/packages/app/src/add-project-flow/options.ts b/packages/app/src/add-project-flow/options.ts index 0c968cc35..1c9fec5bf 100644 --- a/packages/app/src/add-project-flow/options.ts +++ b/packages/app/src/add-project-flow/options.ts @@ -34,14 +34,13 @@ export function filterAddProjectHosts(hosts: AddProjectHost[], query: string): A } export function buildAddProjectMethods(host: AddProjectHost): AddProjectMethodOption[] { + if (!host.canAddProject) return []; const options: AddProjectMethodOption[] = []; - if (host.canAddProject) { - options.push({ - id: "directory-search", - label: "Search for directory", - description: `Find a directory on ${host.label}`, - }); - } + options.push({ + id: "directory-search", + label: "Search for directory", + description: `Find a directory on ${host.label}`, + }); if (host.canBrowse) { options.push({ id: "browse", @@ -66,6 +65,12 @@ export function buildAddProjectMethods(host: AddProjectHost): AddProjectMethodOp return options; } +export function addProjectMethodEmptyText(host: AddProjectHost | null): string { + return host?.canAddProject === false + ? "Update the host to use Add Project." + : "No matching options"; +} + function githubMethodDescription(host: AddProjectHost): string { if (!host.canCloneGithubRepositories) { return "Update this host to clone GitHub repositories"; diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index a1354787f..c67092029 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -1234,7 +1234,9 @@ function PermissionActionButton({ onPress, }: PermissionActionButtonProps) { const handlePress = useCallback(() => onPress(action), [onPress, action]); - const optionTextStyle = isPrimary ? optionTextPrimaryStyle : permissionStyles.optionText; + const optionTextStyle = isPrimary + ? [permissionStyles.optionText, permissionStyles.optionTextPrimary] + : permissionStyles.optionText; const colorMapping = isPrimary ? primaryColorMapping : mutedColorMapping; return ( @@ -1622,8 +1624,6 @@ const permissionStyles = StyleSheet.create((theme) => ({ }, })); -const optionTextPrimaryStyle = [permissionStyles.optionText, permissionStyles.optionTextPrimary]; - interface StreamItemWrapperProps { gapBelow: number; children: ReactNode; diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 16aa4b0f6..2f7fcd2fd 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -21,7 +21,8 @@ import { GestureDetector, GestureHandlerRootView } from "react-native-gesture-ha import { KeyboardProvider } from "react-native-keyboard-controller"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; -import { CommandCenter } from "@/components/command-center"; +import { CommandCenter, CommandCenterRootActions } from "@/command-center/command-center"; +import { CommandCenterProvider } from "@/command-center/provider"; import { AddProjectFlowHost } from "@/components/add-project-flow-host"; import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source"; import { DownloadToast } from "@/components/download-toast"; @@ -553,6 +554,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon + @@ -570,7 +572,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon surface ); - return content; + return {content}; } function SidebarChrome({ diff --git a/packages/app/src/app/pair-scan.tsx b/packages/app/src/app/pair-scan.tsx index c73dc6d93..4408ef33c 100644 --- a/packages/app/src/app/pair-scan.tsx +++ b/packages/app/src/app/pair-scan.tsx @@ -257,10 +257,10 @@ export default function PairScanScreen() { /> - - - - + + + + {isPairing ? {t("pairing.scan.pairing")} : null} @@ -272,7 +272,3 @@ export default function PairScanScreen() { } const BARCODE_SCANNER_SETTINGS: BarcodeSettings = { barcodeTypes: ["qr"] }; -const CORNER_TL_STYLE = [styles.corner, styles.cornerTL]; -const CORNER_TR_STYLE = [styles.corner, styles.cornerTR]; -const CORNER_BL_STYLE = [styles.corner, styles.cornerBL]; -const CORNER_BR_STYLE = [styles.corner, styles.cornerBR]; diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx new file mode 100644 index 000000000..584c80bfc --- /dev/null +++ b/packages/app/src/command-center/command-center.tsx @@ -0,0 +1,792 @@ +import { + FlatList, + Modal, + Pressable, + Text, + TextInput, + View, + type ListRenderItemInfo, + type PressableStateCallbackType, +} from "react-native"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { router, type Href } from "expo-router"; +import { useTranslation } from "react-i18next"; +import { Check, ChevronRight, Folder, Home, Plus, Settings } from "lucide-react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { + BottomSheetBackdrop, + BottomSheetFlatList, + BottomSheetTextInput, + type BottomSheetFlatListMethods, +} from "@gorhom/bottom-sheet"; +import { AgentStatusDot } from "@/components/agent-status-dot"; +import { Shortcut } from "@/components/ui/shortcut"; +import { + IsolatedBottomSheetModal, + useIsolatedBottomSheetVisibility, +} from "@/components/ui/isolated-bottom-sheet-modal"; +import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout"; +import { isNative, isWeb } from "@/constants/platform"; +import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents"; +import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; +import { useOpenAddProject } from "@/hooks/use-open-add-project"; +import { useProjects } from "@/hooks/use-projects"; +import { useHosts } from "@/runtime/host-runtime"; +import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; +import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; +import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts"; +import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string"; +import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; +import { + clearCommandCenterFocusRestoreElement, + takeCommandCenterFocusRestoreElement, +} from "@/utils/command-center-focus-restore"; +import { focusWithRetries } from "@/utils/web-focus"; +import { buildOpenProjectRoute, buildSettingsRoute } from "@/utils/host-routes"; +import { navigateToAgent } from "@/utils/navigate-to-agent"; +import { formatTimeAgo } from "@/utils/time"; +import { shortenPath } from "@/utils/shorten-path"; +import { getShortcutOs } from "@/utils/shortcut-platform"; +import type { ShortcutKey } from "@/utils/format-shortcut"; +import type { CommandCenterContribution, CommandCenterIconProps } from "./contributions"; +import { useCommandCenterActions, useCommandCenterContributions } from "./provider"; +import { + buildContributionSections, + moveActiveResultId, + preserveActiveResultId, + projectCommandCenterRows, + type CommandCenterAgentResult, + type CommandCenterListRow, + type CommandCenterResult, + type CommandCenterResultSection, + type CommandCenterWorkspaceResult, +} from "./results"; + +const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({ + placeholderTextColor: theme.colors.foregroundMuted, +})); +const ThemedTextInput = withUnistyles(TextInput, (theme) => ({ + placeholderTextColor: theme.colors.foregroundMuted, +})); +const ThemedFolder = withUnistyles(Folder, (theme) => ({ color: theme.colors.foregroundMuted })); +const ThemedCheck = withUnistyles(Check, (theme) => ({ color: theme.colors.foreground })); +const ThemedChevronRight = withUnistyles(ChevronRight, (theme) => ({ + color: theme.colors.foregroundMuted, +})); +const ThemedPlus = withUnistyles(Plus, (theme) => ({ color: theme.colors.foregroundMuted })); +const ThemedSettings = withUnistyles(Settings, (theme) => ({ + color: theme.colors.foregroundMuted, +})); +const ThemedHome = withUnistyles(Home, (theme) => ({ color: theme.colors.foregroundMuted })); +const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"]; +const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const; + +function PlusIcon({ size }: CommandCenterIconProps) { + return ; +} + +function SettingsIcon({ size }: CommandCenterIconProps) { + return ; +} + +function HomeIcon({ size }: CommandCenterIconProps) { + return ; +} + +function resolveActionShortcutKeys( + actionId: string | undefined, + overrides: Record, +): ShortcutKey[][] | undefined { + if (!actionId) return undefined; + const platform = { + isMac: getShortcutOs() === "mac", + isDesktop: getIsElectronRuntime(), + }; + const bindingId = getBindingIdForAction(actionId, platform); + if (!bindingId) return undefined; + const override = overrides[bindingId]; + if (override) return chordStringToShortcutKeys(override); + const defaultKeys = getDefaultKeysForAction(actionId, platform); + return defaultKeys ? [defaultKeys] : undefined; +} + +export function CommandCenterRootActions() { + const { t } = useTranslation(); + const { overrides } = useKeyboardShortcutOverrides(); + const openAddProject = useOpenAddProject(); + const settingsRoute = useMemo(() => buildSettingsRoute(), []); + const homeRoute = useMemo(() => buildOpenProjectRoute(), []); + const actions = useMemo( + () => [ + { + id: "new-agent", + group: "actions", + groupRank: 0, + rank: 0, + keywords: ["open", "project", "folder", "workspace", "repo"], + visibility: "always", + run: () => { + clearCommandCenterFocusRestoreElement(); + openAddProject(); + }, + presentation: { + kind: "action", + title: t("shell.commandCenter.addProject"), + sectionTitle: t("shell.commandCenter.actions"), + icon: PlusIcon, + shortcutKeys: resolveActionShortcutKeys("new-agent", overrides), + }, + }, + { + id: "home", + group: "actions", + groupRank: 0, + rank: 1, + keywords: ["home", "start", "import", "session", "pair", "device", "providers"], + visibility: "always", + run: () => { + clearCommandCenterFocusRestoreElement(); + router.push(homeRoute); + }, + presentation: { + kind: "action", + title: t("shell.commandCenter.home"), + sectionTitle: t("shell.commandCenter.actions"), + icon: HomeIcon, + }, + }, + { + id: "settings", + group: "actions", + groupRank: 0, + rank: 2, + keywords: ["settings", "preferences", "config", "configuration"], + visibility: "always", + run: () => { + clearCommandCenterFocusRestoreElement(); + router.push(settingsRoute); + }, + presentation: { + kind: "action", + title: t("sidebar.actions.settings"), + sectionTitle: t("shell.commandCenter.actions"), + icon: SettingsIcon, + }, + }, + ], + [homeRoute, openAddProject, overrides, settingsRoute, t], + ); + + useCommandCenterActions({ sourceId: "root", enabled: true, actions }); + return null; +} + +function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number { + const leftNeedsInput = (left.pendingPermissionCount ?? 0) > 0 ? 1 : 0; + const rightNeedsInput = (right.pendingPermissionCount ?? 0) > 0 ? 1 : 0; + if (leftNeedsInput !== rightNeedsInput) return rightNeedsInput - leftNeedsInput; + const leftAttention = left.requiresAttention ? 1 : 0; + const rightAttention = right.requiresAttention ? 1 : 0; + if (leftAttention !== rightAttention) return rightAttention - leftAttention; + const leftRunning = left.status === "running" ? 1 : 0; + const rightRunning = right.status === "running" ? 1 : 0; + if (leftRunning !== rightRunning) return rightRunning - leftRunning; + return right.lastActivityAt.getTime() - left.lastActivityAt.getTime(); +} + +function matchesQuery(searchText: string, query: string): boolean { + const normalized = query.trim().toLowerCase(); + return !normalized || searchText.includes(normalized); +} + +function useBuiltInSections(open: boolean, query: string): CommandCenterResultSection[] { + const { t } = useTranslation(); + const { agents } = useAggregatedAgents(); + const { projects } = useProjects({ enabled: open }); + const showAgentHost = useHosts().length > 1; + + return useMemo(() => { + if (!open) return []; + const allWorkspaces: CommandCenterWorkspaceResult[] = []; + for (const project of projects) { + for (const host of project.hosts) { + for (const workspace of host.workspaces) { + if (workspace.archivingAt) continue; + const title = workspace.title ?? workspace.name; + const subtitle = workspace.currentBranch + ? `${host.serverName} · ${workspace.currentBranch}` + : host.serverName; + const searchText = `${title} ${subtitle}`.toLowerCase(); + allWorkspaces.push({ + kind: "workspace", + id: `workspace:${host.serverId}:${workspace.id}`, + title, + subtitle, + searchText, + run: () => { + clearCommandCenterFocusRestoreElement(); + navigateToWorkspace({ serverId: host.serverId, workspaceId: workspace.id }); + }, + }); + } + } + } + allWorkspaces.sort((left, right) => { + const titleDelta = left.title.localeCompare(right.title, undefined, { + numeric: true, + sensitivity: "base", + }); + return titleDelta || left.subtitle.localeCompare(right.subtitle); + }); + const workspaceTitleByKey = new Map( + allWorkspaces.map((workspace) => [workspace.id.slice("workspace:".length), workspace.title]), + ); + const workspaces = allWorkspaces.filter((workspace) => + matchesQuery(workspace.searchText, query), + ); + const agentResults = agents + .map((agent) => { + const title = agent.title || t("shell.commandCenter.newAgent"); + const workspaceTitle = agent.workspaceId + ? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`) + : undefined; + const location = workspaceTitle ?? shortenPath(agent.cwd); + const subtitle = [ + showAgentHost ? agent.serverLabel : null, + location, + formatTimeAgo(agent.lastActivityAt), + ] + .filter((part): part is string => Boolean(part)) + .join(" · "); + return { + kind: "agent", + id: `agent:${agent.serverId}:${agent.id}`, + agent, + title, + subtitle, + searchText: `${title} ${subtitle} ${agent.cwd}`.toLowerCase(), + run: () => { + clearCommandCenterFocusRestoreElement(); + navigateToAgent({ serverId: agent.serverId, agentId: agent.id }); + }, + }; + }) + .filter((agent) => matchesQuery(agent.searchText, query)) + .sort((left, right) => sortAgents(left.agent, right.agent)); + return [ + { + id: "workspaces", + rank: 2, + title: t("shell.commandCenter.workspaces"), + results: workspaces, + }, + { id: "agents", rank: 3, title: t("shell.commandCenter.agents"), results: agentResults }, + ]; + }, [agents, open, projects, query, showAgentHost, t]); +} + +interface CommandCenterState { + open: boolean; + query: string; + setQuery(query: string): void; + activeId: string | null; + rows: readonly CommandCenterListRow[]; + results: readonly CommandCenterResult[]; + rowIndexByResultId: ReadonlyMap; + offsets: readonly number[]; + inputRef: React.RefObject; + close(): void; + select(result: CommandCenterResult): void; + key(key: string): boolean; +} + +function useCommandCenterState(): CommandCenterState { + const open = useKeyboardShortcutsStore((state) => state.commandCenterOpen); + const setOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen); + const snapshot = useCommandCenterContributions(); + const inputRef = useRef(null); + const previousOpenRef = useRef(open); + const [query, setQuery] = useState(""); + const [activeId, setActiveId] = useState(null); + const builtInSections = useBuiltInSections(open, query); + const contributionSections = useMemo( + () => buildContributionSections(snapshot.contributions, query), + [query, snapshot.contributions], + ); + const projection = useMemo( + () => projectCommandCenterRows([...contributionSections, ...builtInSections]), + [builtInSections, contributionSections], + ); + const resolvedActiveId = preserveActiveResultId(activeId, projection.selectableResults); + + const close = useCallback(() => setOpen(false), [setOpen]); + const select = useCallback( + (result: CommandCenterResult) => { + setOpen(false); + void result.run(); + }, + [setOpen], + ); + const key = useCallback( + (pressed: string): boolean => { + if (!open) return false; + const results = projection.selectableResults; + if (pressed === "Escape") { + close(); + return true; + } + if (pressed === "Enter") { + const selected = results.find((result) => result.id === resolvedActiveId); + if (!selected) return false; + select(selected); + return true; + } + if (pressed !== "ArrowDown" && pressed !== "ArrowUp") return false; + if (results.length === 0) return false; + const direction = pressed === "ArrowDown" ? "next" : "previous"; + setActiveId(moveActiveResultId(resolvedActiveId, results, direction)); + return true; + }, + [close, open, projection.selectableResults, resolvedActiveId, select], + ); + + useEffect(() => { + const wasOpen = previousOpenRef.current; + previousOpenRef.current = open; + if (open) { + const timer = setTimeout(() => inputRef.current?.focus(), 0); + return () => clearTimeout(timer); + } + setQuery(""); + setActiveId(null); + if (!wasOpen) return; + const element = takeCommandCenterFocusRestoreElement(); + if (!element) return; + const cancel = focusWithRetries({ + focus: () => element.focus(), + isFocused: () => typeof document !== "undefined" && document.activeElement === element, + onTimeout: () => + keyboardActionDispatcher.dispatch({ id: "message-input.focus", scope: "message-input" }), + }); + return cancel; + }, [open]); + + useEffect(() => { + if (!open || !isWeb) return; + const listener = (event: KeyboardEvent) => { + if (key(event.key)) event.preventDefault(); + }; + window.addEventListener("keydown", listener, true); + return () => window.removeEventListener("keydown", listener, true); + }, [key, open]); + + return { + open, + query, + setQuery, + activeId: resolvedActiveId, + rows: projection.rows, + results: projection.selectableResults, + rowIndexByResultId: projection.rowIndexByResultId, + offsets: projection.offsets, + inputRef, + close, + select, + key, + }; +} + +interface ResultRowProps { + result: CommandCenterResult; + active: boolean; + onSelect(result: CommandCenterResult): void; +} + +const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRowProps) { + const press = useCallback(() => onSelect(result), [onSelect, result]); + const style = useCallback( + ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.row, + (result.kind === "agent" || + result.kind === "workspace" || + (result.kind === "contribution" && + result.contribution.presentation.kind === "action" && + Boolean(result.contribution.presentation.subtitle))) && + styles.tallRow, + (Boolean(hovered) || pressed || active) && styles.activeRow, + ], + [active, result], + ); + return ( + + + + ); +}); + +function ResultContent({ result }: { result: CommandCenterResult }) { + if (result.kind === "agent") { + const agent = result.agent; + return ( + + + + + + + + {result.title} + + + {result.subtitle} + + + + + ); + } + if (result.kind === "workspace") { + const key = result.id.slice("workspace:".length); + return ( + + + + + + + + {result.title} + + + {result.subtitle} + + + + + ); + } + const presentation = result.contribution.presentation; + const Icon = presentation.icon; + if (presentation.kind === "action") { + return ( + + + {Icon ? ( + + + + ) : null} + + + {presentation.title} + + {presentation.subtitle ? ( + {presentation.subtitle} + ) : null} + + + {presentation.shortcutKeys ? ( + + ) : null} + + ); + } + return ( + + + {Icon ? ( + + + + ) : null} + + {presentation.path.map((part, index) => ( + + {index > 0 ? : null} + + {part} + + + ))} + + + {presentation.selected ? ( + + + + ) : null} + + ); +} + +function SectionRow({ row }: { row: Extract }) { + let sizeStyle = styles.dividerSection; + if (row.title && row.divider) sizeStyle = styles.dividedSection; + if (row.title && !row.divider) sizeStyle = styles.titledSection; + return ( + + {row.divider ? : null} + {row.title ? {row.title} : null} + + ); +} + +export function CommandCenter() { + const { t } = useTranslation(); + const state = useCommandCenterState(); + const isCompact = useIsCompactFormFactor(); + const showBottomSheet = isCompact && isNative; + const listRef = useRef>(null); + const bottomSheetListRef = useRef(null); + const bottomSheetInputRef = useRef>(null); + const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({ + visible: state.open, + isEnabled: showBottomSheet, + onClose: state.close, + }); + + useEffect(() => { + if (!state.open || !state.activeId) return; + const index = state.rowIndexByResultId.get(state.activeId); + if (index === undefined) return; + const ref = showBottomSheet ? bottomSheetListRef.current : listRef.current; + ref?.scrollToIndex({ index, animated: true, viewPosition: 0.5 }); + }, [showBottomSheet, state.activeId, state.open, state.rowIndexByResultId]); + useEffect(() => { + if (!showBottomSheet || !state.open) return; + const timer = setTimeout(() => bottomSheetInputRef.current?.focus(), 300); + return () => clearTimeout(timer); + }, [showBottomSheet, state.open]); + + const renderItem = useCallback( + ({ item }: ListRenderItemInfo) => + item.kind === "section" ? ( + + ) : ( + + ), + [state.activeId, state.select], + ); + const getItemLayout = useCallback( + (_data: ArrayLike | null | undefined, index: number) => ({ + index, + length: state.rows[index].height, + offset: state.offsets[index], + }), + [state.offsets, state.rows], + ); + const keyExtractor = useCallback((row: CommandCenterListRow) => row.key, []); + const empty = useMemo( + () => {t("shell.commandCenter.noMatches")}, + [t], + ); + const commonListProps = { + data: state.rows, + renderItem, + keyExtractor, + getItemLayout, + ListEmptyComponent: empty, + keyboardShouldPersistTaps: KEYBOARD_SHOULD_PERSIST_TAPS, + showsVerticalScrollIndicator: false, + initialNumToRender: 12, + maxToRenderPerBatch: 10, + windowSize: 5, + }; + const keyPress = useCallback( + ({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => state.key(key), + [state], + ); + const submit = useCallback(() => state.key("Enter"), [state]); + const backdrop = useCallback( + (props: React.ComponentProps) => ( + + ), + [], + ); + + if (showBottomSheet) { + return ( + + + + + + + ); + } + if (!state.open) return null; + return ( + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + overlay: { + flex: 1, + justifyContent: "flex-start", + alignItems: "center", + paddingTop: theme.spacing[12], + }, + backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.5)" }, + panel: { + width: 640, + maxWidth: "92%", + maxHeight: "80%", + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.lg, + overflow: "hidden", + backgroundColor: theme.colors.surface0, + ...theme.shadow.lg, + }, + header: { + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }, + bottomSheetHeader: { + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }, + input: { + fontSize: theme.fontSize.base, + paddingVertical: theme.spacing[1], + color: theme.colors.foreground, + outlineWidth: 0, + }, + results: { flexGrow: 0 }, + sectionLabel: { + paddingHorizontal: theme.spacing[4], + paddingBottom: theme.spacing[2], + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + sectionDivider: { + height: 1, + marginTop: theme.spacing[2], + marginBottom: theme.spacing[2], + backgroundColor: theme.colors.border, + }, + row: { height: 36, paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[2] }, + tallRow: { height: 56 }, + activeRow: { backgroundColor: theme.colors.surface1 }, + rowContent: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[3], + }, + rowMain: { + flex: 1, + minWidth: 0, + flexDirection: "row", + alignItems: "flex-start", + gap: theme.spacing[3], + }, + textContent: { flex: 1, minWidth: 0 }, + title: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + lineHeight: 20, + flexShrink: 1, + }, + subtitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, lineHeight: 18 }, + iconSlot: { width: 16, height: 20, alignItems: "center", justifyContent: "center" }, + rowShortcut: { flexShrink: 0 }, + breadcrumb: { + flex: 1, + minWidth: 0, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + breadcrumbPart: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + flexShrink: 1, + }, + breadcrumbGroup: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + lineHeight: 20, + flexShrink: 0, + }, + emptyText: { + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[6], + textAlign: "center", + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + sheetBackground: { backgroundColor: theme.colors.surface0 }, + sheetHandle: { backgroundColor: theme.colors.palette.zinc[600] }, + titledSection: { height: 32, justifyContent: "flex-end" }, + dividedSection: { height: 49, justifyContent: "flex-end" }, + dividerSection: { height: 17, justifyContent: "flex-end" }, +})); diff --git a/packages/app/src/command-center/contributions.ts b/packages/app/src/command-center/contributions.ts new file mode 100644 index 000000000..ab54cbc7e --- /dev/null +++ b/packages/app/src/command-center/contributions.ts @@ -0,0 +1,53 @@ +import type { ComponentType } from "react"; +import type { ShortcutKey } from "@/utils/format-shortcut"; + +export interface CommandCenterIconProps { + size: number; +} + +export type CommandCenterIcon = ComponentType; + +interface CommandCenterContributionBase { + id: string; + group: string; + groupRank: number; + rank: number; + keywords: readonly string[]; + visibility: "always" | "query"; + run(): void | Promise; +} + +export type CommandCenterContribution = + | (CommandCenterContributionBase & { + presentation: { + kind: "action"; + title: string; + subtitle?: string; + sectionTitle?: string; + icon?: CommandCenterIcon; + shortcutKeys?: ShortcutKey[][]; + }; + }) + | (CommandCenterContributionBase & { + presentation: { + kind: "choice"; + path: readonly [string, ...string[]]; + icon?: CommandCenterIcon; + selected: boolean; + testId?: string; + }; + }); + +export interface CommandCenterContributionSnapshot { + contributions: readonly CommandCenterContribution[]; +} + +export interface CommandCenterRegistrationOwner { + sourceId: string; + token: symbol; +} + +export interface CommandCenterRegistration { + owner: CommandCenterRegistrationOwner; + contributions: readonly CommandCenterContribution[]; +} diff --git a/packages/app/src/command-center/model-contributions.test.tsx b/packages/app/src/command-center/model-contributions.test.tsx new file mode 100644 index 000000000..6e38b33e1 --- /dev/null +++ b/packages/app/src/command-center/model-contributions.test.tsx @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; +import { buildModelChoiceContributions } from "./model-contributions"; + +function TestIcon() { + return null; +} + +function provider(input: { + id: string; + label: string; + state: "models" | "error"; +}): ProviderSelectorProvider { + if (input.state === "error") { + return { + id: input.id, + label: input.label, + modelSelection: { kind: "error", message: "Unavailable" }, + }; + } + return { + id: input.id, + label: input.label, + modelSelection: { + kind: "models", + rows: [ + { + favoriteKey: `${input.id}:model`, + provider: input.id, + providerLabel: input.label, + modelId: "model", + modelLabel: `${input.label} model`, + description: undefined, + }, + ], + }, + }; +} + +describe("Command Center model choices", () => { + it("publishes selectable providers and executes the draft owner's command", () => { + const selections: string[] = []; + const choices = buildModelChoiceContributions({ + serverId: "host", + providers: [ + provider({ id: "claude", label: "Claude", state: "models" }), + provider({ id: "codex", label: "Codex", state: "models" }), + ], + selectedProvider: "codex", + selectedModelId: "model", + groupLabel: "Model", + searchKeywords: "model switch", + getIcon: () => TestIcon, + select: (selectedProvider, modelId) => selections.push(`${selectedProvider}:${modelId}`), + }); + + expect( + choices.map((choice) => ({ + id: choice.id, + selected: choice.presentation.kind === "choice" ? choice.presentation.selected : false, + })), + ).toEqual([ + { id: "host:claude:model", selected: false }, + { id: "host:codex:model", selected: true }, + ]); + choices[0].run(); + choices[1].run(); + expect(selections).toEqual(["claude:model"]); + }); + + it("does not publish unavailable providers or no-op default rows", () => { + const choices = buildModelChoiceContributions({ + serverId: "host", + providers: [ + provider({ id: "unavailable", label: "Unavailable", state: "error" }), + { + id: "empty", + label: "Empty", + modelSelection: { + kind: "models", + rows: [ + { + favoriteKey: "empty:", + provider: "empty", + providerLabel: "Empty", + modelId: "", + modelLabel: "Default", + description: undefined, + }, + ], + }, + }, + ], + selectedProvider: null, + selectedModelId: null, + groupLabel: "Model", + searchKeywords: "model switch", + getIcon: () => TestIcon, + select: () => undefined, + }); + + expect(choices).toEqual([]); + }); +}); diff --git a/packages/app/src/command-center/model-contributions.ts b/packages/app/src/command-center/model-contributions.ts new file mode 100644 index 000000000..182a528c9 --- /dev/null +++ b/packages/app/src/command-center/model-contributions.ts @@ -0,0 +1,51 @@ +import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; +import type { CommandCenterContribution, CommandCenterIcon } from "./contributions"; + +interface ModelChoiceInput { + serverId: string; + providers: readonly ProviderSelectorProvider[]; + selectedProvider: string | null; + selectedModelId: string | null; + groupLabel: string; + searchKeywords: string; + getIcon(provider: AgentProvider): CommandCenterIcon; + select(provider: AgentProvider, modelId: string): void; +} + +export function buildModelChoiceContributions( + input: ModelChoiceInput, +): CommandCenterContribution[] { + const contributions: CommandCenterContribution[] = []; + let rank = 0; + for (const provider of input.providers) { + if (provider.modelSelection.kind !== "models") continue; + const agentProvider = provider.id; + const icon = input.getIcon(agentProvider); + for (const model of provider.modelSelection.rows) { + if (!model.modelId) continue; + const modelId = model.modelId; + const selected = input.selectedProvider === provider.id && input.selectedModelId === modelId; + contributions.push({ + id: `${input.serverId}:${provider.id}:${modelId}`, + group: "models", + groupRank: 1, + rank, + keywords: [modelId, input.searchKeywords], + visibility: "query", + run: () => { + if (!selected) input.select(agentProvider, modelId); + }, + presentation: { + kind: "choice", + path: [input.groupLabel, provider.label, model.modelLabel], + icon, + selected, + testId: `command-center-model-${input.serverId}:${provider.id}:${modelId}`, + }, + }); + rank += 1; + } + } + return contributions; +} diff --git a/packages/app/src/command-center/provider-icon.tsx b/packages/app/src/command-center/provider-icon.tsx new file mode 100644 index 000000000..245d5de27 --- /dev/null +++ b/packages/app/src/command-center/provider-icon.tsx @@ -0,0 +1,20 @@ +import { withUnistyles } from "react-native-unistyles"; +import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import { getProviderIcon } from "@/components/provider-icons"; +import type { CommandCenterIcon, CommandCenterIconProps } from "./contributions"; + +const commandCenterProviderIcons = new Map(); + +export function getCommandCenterProviderIcon(provider: AgentProvider): CommandCenterIcon { + const cached = commandCenterProviderIcons.get(provider); + if (cached) return cached; + + const ProviderIcon = withUnistyles(getProviderIcon(provider), (theme) => ({ + color: theme.colors.foregroundMuted, + })); + function CommandCenterProviderIcon({ size }: CommandCenterIconProps) { + return ; + } + commandCenterProviderIcons.set(provider, CommandCenterProviderIcon); + return CommandCenterProviderIcon; +} diff --git a/packages/app/src/command-center/provider.tsx b/packages/app/src/command-center/provider.tsx new file mode 100644 index 000000000..2f1cc706c --- /dev/null +++ b/packages/app/src/command-center/provider.tsx @@ -0,0 +1,56 @@ +import { + createContext, + useContext, + useEffect, + useRef, + useSyncExternalStore, + type ReactNode, +} from "react"; +import type { CommandCenterContribution } from "./contributions"; +import { createCommandCenterRegistry, type CommandCenterRegistry } from "./registry"; + +const CommandCenterRegistryContext = createContext(null); + +export function CommandCenterProvider({ children }: { children: ReactNode }) { + const registryRef = useRef(null); + if (!registryRef.current) registryRef.current = createCommandCenterRegistry(); + + return ( + + {children} + + ); +} + +function useCommandCenterRegistry(): CommandCenterRegistry { + const registry = useContext(CommandCenterRegistryContext); + if (!registry) throw new Error("CommandCenterProvider is required"); + return registry; +} + +export function useCommandCenterContributions() { + const registry = useCommandCenterRegistry(); + return useSyncExternalStore(registry.subscribe, registry.getSnapshot, registry.getSnapshot); +} + +export function useCommandCenterActions(input: { + sourceId: string; + enabled: boolean; + actions: readonly CommandCenterContribution[]; +}): void { + const registry = useCommandCenterRegistry(); + const ownerRef = useRef({ sourceId: input.sourceId, token: Symbol(input.sourceId) }); + if (ownerRef.current.sourceId !== input.sourceId) { + ownerRef.current = { sourceId: input.sourceId, token: Symbol(input.sourceId) }; + } + const owner = ownerRef.current; + + useEffect(() => { + if (!input.enabled) { + registry.remove(owner); + return; + } + registry.replace({ owner, contributions: input.actions }); + return () => registry.remove(owner); + }, [input.actions, input.enabled, owner, registry]); +} diff --git a/packages/app/src/command-center/registry.test.ts b/packages/app/src/command-center/registry.test.ts new file mode 100644 index 000000000..cf8d60684 --- /dev/null +++ b/packages/app/src/command-center/registry.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import type { CommandCenterContribution, CommandCenterRegistrationOwner } from "./contributions"; +import { createCommandCenterRegistry } from "./registry"; + +function action(id: string, rank: number): CommandCenterContribution { + return { + id, + group: "actions", + groupRank: 0, + rank, + keywords: [], + visibility: "always", + run: () => undefined, + presentation: { kind: "action", title: id }, + }; +} + +function owner(sourceId: string): CommandCenterRegistrationOwner { + return { sourceId, token: Symbol(sourceId) }; +} + +describe("Command Center registry", () => { + it("atomically replaces a source and preserves a no-op snapshot", () => { + const registry = createCommandCenterRegistry(); + const source = owner("root"); + const first = [action("first", 0)]; + let notifications = 0; + registry.subscribe(() => { + notifications += 1; + }); + + registry.replace({ owner: source, contributions: first }); + const snapshot = registry.getSnapshot(); + registry.replace({ owner: source, contributions: first }); + expect(registry.getSnapshot()).toBe(snapshot); + expect(notifications).toBe(1); + + registry.replace({ owner: source, contributions: [action("second", 0)] }); + expect(registry.getSnapshot().contributions.map((item) => item.id)).toEqual(["root:second"]); + expect(notifications).toBe(2); + }); + + it("does not let stale cleanup remove a replacement owner", () => { + const registry = createCommandCenterRegistry(); + const stale = owner("draft:tab"); + const current = owner("draft:tab"); + registry.replace({ owner: stale, contributions: [action("old", 0)] }); + registry.replace({ owner: current, contributions: [action("new", 0)] }); + + registry.remove(stale); + expect(registry.getSnapshot().contributions.map((item) => item.id)).toEqual(["draft:tab:new"]); + registry.remove(current); + expect(registry.getSnapshot().contributions).toEqual([]); + }); + + it("orders independently of registration order and rejects duplicate active ids", () => { + const registry = createCommandCenterRegistry(); + registry.replace({ owner: owner("later"), contributions: [action("z", 2)] }); + registry.replace({ owner: owner("earlier"), contributions: [action("a", 1)] }); + expect(registry.getSnapshot().contributions.map((item) => item.id)).toEqual([ + "earlier:a", + "later:z", + ]); + + const duplicateOwner = owner("duplicate"); + expect(() => + registry.replace({ + owner: duplicateOwner, + contributions: [action("same", 0), action("same", 1)], + }), + ).toThrow("Duplicate Command Center contribution id: duplicate:same"); + }); +}); diff --git a/packages/app/src/command-center/registry.ts b/packages/app/src/command-center/registry.ts new file mode 100644 index 000000000..f4c383621 --- /dev/null +++ b/packages/app/src/command-center/registry.ts @@ -0,0 +1,100 @@ +import type { + CommandCenterContribution, + CommandCenterContributionSnapshot, + CommandCenterRegistration, + CommandCenterRegistrationOwner, +} from "./contributions"; + +export interface CommandCenterRegistry { + getSnapshot(): CommandCenterContributionSnapshot; + subscribe(listener: () => void): () => void; + replace(registration: CommandCenterRegistration): void; + remove(owner: CommandCenterRegistrationOwner): void; +} + +interface ActiveRegistration { + owner: CommandCenterRegistrationOwner; + contributions: readonly CommandCenterContribution[]; +} + +const EMPTY_SNAPSHOT: CommandCenterContributionSnapshot = { contributions: [] }; + +function contributionId(sourceId: string, id: string): string { + return `${sourceId}:${id}`; +} + +function compareContributions( + left: CommandCenterContribution, + right: CommandCenterContribution, +): number { + if (left.groupRank !== right.groupRank) return left.groupRank - right.groupRank; + const groupDelta = left.group.localeCompare(right.group); + if (groupDelta !== 0) return groupDelta; + if (left.rank !== right.rank) return left.rank - right.rank; + return left.id.localeCompare(right.id); +} + +function sameContributions( + left: readonly CommandCenterContribution[], + right: readonly CommandCenterContribution[], +): boolean { + return left.length === right.length && left.every((item, index) => item === right[index]); +} + +export function createCommandCenterRegistry(): CommandCenterRegistry { + const registrations = new Map(); + const listeners = new Set<() => void>(); + let snapshot = EMPTY_SNAPSHOT; + + function publish(): void { + const contributions: CommandCenterContribution[] = []; + const ids = new Set(); + + for (const registration of registrations.values()) { + for (const contribution of registration.contributions) { + const id = contributionId(registration.owner.sourceId, contribution.id); + if (ids.has(id)) { + throw new Error(`Duplicate Command Center contribution id: ${id}`); + } + ids.add(id); + contributions.push({ ...contribution, id }); + } + } + contributions.sort(compareContributions); + + if (sameContributions(snapshot.contributions, contributions)) return; + snapshot = contributions.length === 0 ? EMPTY_SNAPSHOT : { contributions }; + for (const listener of listeners) listener(); + } + + return { + getSnapshot: () => snapshot, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + replace(registration) { + const current = registrations.get(registration.owner.sourceId); + if ( + current?.owner.token === registration.owner.token && + sameContributions(current.contributions, registration.contributions) + ) { + return; + } + const ids = new Set(); + for (const contribution of registration.contributions) { + const id = contributionId(registration.owner.sourceId, contribution.id); + if (ids.has(id)) throw new Error(`Duplicate Command Center contribution id: ${id}`); + ids.add(id); + } + registrations.set(registration.owner.sourceId, registration); + publish(); + }, + remove(owner) { + const current = registrations.get(owner.sourceId); + if (current?.owner.token !== owner.token) return; + registrations.delete(owner.sourceId); + publish(); + }, + }; +} diff --git a/packages/app/src/command-center/results.test.ts b/packages/app/src/command-center/results.test.ts new file mode 100644 index 000000000..3405a6a51 --- /dev/null +++ b/packages/app/src/command-center/results.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import type { CommandCenterContribution } from "./contributions"; +import { + buildContributionSections, + moveActiveResultId, + preserveActiveResultId, + projectCommandCenterRows, + type CommandCenterWorkspaceResult, +} from "./results"; + +function contribution(input: { + id: string; + group: string; + groupRank: number; + visibility?: "always" | "query"; +}): CommandCenterContribution { + return { + ...input, + rank: 0, + keywords: [input.id], + visibility: input.visibility ?? "always", + run: () => undefined, + presentation: { + kind: "action", + title: input.id, + sectionTitle: input.group, + }, + }; +} + +function workspace(id: string): CommandCenterWorkspaceResult { + return { + kind: "workspace", + id, + title: id, + subtitle: "host", + searchText: id, + run: () => undefined, + }; +} + +function sectionResultIds(sections: ReturnType): string[] { + const ids: string[] = []; + for (const section of sections) { + for (const result of section.results) ids.push(result.id); + } + return ids; +} + +describe("Command Center result projection", () => { + it("query-gates model choices and creates one flat row index", () => { + const contributions = [ + contribution({ id: "settings", group: "actions", groupRank: 0 }), + contribution({ id: "opus", group: "models", groupRank: 1, visibility: "query" }), + ]; + const emptySections = buildContributionSections(contributions, ""); + expect(sectionResultIds(emptySections)).toEqual(["settings"]); + + const sections = buildContributionSections(contributions, "o"); + const projection = projectCommandCenterRows([ + ...sections, + { id: "workspaces", rank: 2, title: "Workspaces", results: [workspace("workspace:1")] }, + ]); + expect(projection.rows.map((row) => row.key)).toEqual([ + "section:models", + "opus", + "section:workspaces", + "workspace:1", + ]); + expect(projection.rowIndexByResultId.get("workspace:1")).toBe(3); + expect(projection.offsets).toEqual([0, 32, 68, 117]); + }); + + it("preserves active selection by id and falls back to the first result", () => { + const first = workspace("workspace:first"); + const second = workspace("workspace:second"); + expect(preserveActiveResultId(second.id, [first, second])).toBe(second.id); + expect(preserveActiveResultId("missing", [first, second])).toBe(first.id); + expect(preserveActiveResultId(first.id, [])).toBeNull(); + }); + + it("wraps keyboard selection in both directions", () => { + const first = workspace("workspace:first"); + const second = workspace("workspace:second"); + expect(moveActiveResultId(second.id, [first, second], "next")).toBe(first.id); + expect(moveActiveResultId(first.id, [first, second], "previous")).toBe(second.id); + }); + + it("keeps keyboard selection aligned with rows beyond the render window", () => { + const workspaces = Array.from({ length: 200 }, (_, index) => workspace(`workspace:${index}`)); + const projection = projectCommandCenterRows([ + { id: "workspaces", rank: 0, title: "Workspaces", results: workspaces }, + ]); + + let activeId: string | null = workspaces[0].id; + for (let index = 0; index < 150; index += 1) { + activeId = moveActiveResultId(activeId, projection.selectableResults, "next"); + } + + const targetId = workspaces[150].id; + expect(activeId).toBe(targetId); + expect(projection.rowIndexByResultId.get(targetId)).toBe(151); + expect(projection.offsets[151]).toBe(32 + 150 * 56); + }); +}); diff --git a/packages/app/src/command-center/results.ts b/packages/app/src/command-center/results.ts new file mode 100644 index 000000000..ab2f3a837 --- /dev/null +++ b/packages/app/src/command-center/results.ts @@ -0,0 +1,181 @@ +import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; +import type { CommandCenterContribution } from "./contributions"; + +export interface CommandCenterWorkspaceResult { + kind: "workspace"; + id: string; + title: string; + subtitle: string; + searchText: string; + run(): void; +} + +export interface CommandCenterAgentResult { + kind: "agent"; + id: string; + agent: AggregatedAgent; + title: string; + subtitle: string; + searchText: string; + run(): void; +} + +export interface CommandCenterContributionResult { + kind: "contribution"; + id: string; + contribution: CommandCenterContribution; + searchText: string; + run(): void | Promise; +} + +export type CommandCenterResult = + | CommandCenterWorkspaceResult + | CommandCenterAgentResult + | CommandCenterContributionResult; + +export interface CommandCenterResultSection { + id: string; + rank: number; + title?: string; + results: readonly CommandCenterResult[]; +} + +interface MutableCommandCenterResultSection { + id: string; + rank: number; + title?: string; + results: CommandCenterResult[]; +} + +export type CommandCenterListRow = + | { kind: "section"; key: string; title?: string; divider: boolean; height: number } + | { kind: "result"; key: string; result: CommandCenterResult; height: number }; + +export interface CommandCenterListProjection { + rows: readonly CommandCenterListRow[]; + selectableResults: readonly CommandCenterResult[]; + rowIndexByResultId: ReadonlyMap; + offsets: readonly number[]; +} + +function matchesQuery(searchText: string, query: string): boolean { + const normalized = query.trim().toLowerCase(); + return !normalized || searchText.includes(normalized); +} + +function contributionSearchText(contribution: CommandCenterContribution): string { + const presentationText = + contribution.presentation.kind === "action" + ? [contribution.presentation.title, contribution.presentation.subtitle ?? ""] + : contribution.presentation.path; + return [...presentationText, ...contribution.keywords].join(" ").toLowerCase(); +} + +function resultHeight(result: CommandCenterResult): number { + if (result.kind === "workspace" || result.kind === "agent") return 56; + if (result.contribution.presentation.kind === "action") { + return result.contribution.presentation.subtitle ? 56 : 36; + } + return 36; +} + +export function buildContributionSections( + contributions: readonly CommandCenterContribution[], + query: string, +): CommandCenterResultSection[] { + const groups = new Map(); + const hasQuery = Boolean(query.trim()); + + for (const contribution of contributions) { + if (contribution.visibility === "query" && !hasQuery) continue; + const searchText = contributionSearchText(contribution); + if (!matchesQuery(searchText, query)) continue; + const existing = groups.get(contribution.group); + const title = + contribution.presentation.kind === "action" + ? contribution.presentation.sectionTitle + : undefined; + const section = existing ?? { + id: contribution.group, + rank: contribution.groupRank, + title, + results: [], + }; + section.results.push({ + kind: "contribution", + id: contribution.id, + contribution, + searchText, + run: contribution.run, + }); + groups.set(contribution.group, section); + } + + return [...groups.values()].sort( + (left, right) => left.rank - right.rank || left.id.localeCompare(right.id), + ); +} + +export function projectCommandCenterRows( + sections: readonly CommandCenterResultSection[], +): CommandCenterListProjection { + const populated = sections + .filter((section) => section.results.length > 0) + .sort((left, right) => left.rank - right.rank || left.id.localeCompare(right.id)); + const rows: CommandCenterListRow[] = []; + const selectableResults: CommandCenterResult[] = []; + const rowIndexByResultId = new Map(); + const offsets: number[] = []; + let offset = 0; + + for (const [sectionIndex, section] of populated.entries()) { + const divider = sectionIndex > 0; + let sectionHeight = 0; + if (section.title && divider) sectionHeight = 49; + if (section.title && !divider) sectionHeight = 32; + if (!section.title && divider) sectionHeight = 17; + if (sectionHeight > 0) { + offsets.push(offset); + rows.push({ + kind: "section", + key: `section:${section.id}`, + title: section.title, + divider, + height: sectionHeight, + }); + offset += sectionHeight; + } + for (const result of section.results) { + const height = resultHeight(result); + offsets.push(offset); + rowIndexByResultId.set(result.id, rows.length); + rows.push({ kind: "result", key: result.id, result, height }); + selectableResults.push(result); + offset += height; + } + } + + return { rows, selectableResults, rowIndexByResultId, offsets }; +} + +export function preserveActiveResultId( + activeId: string | null, + results: readonly CommandCenterResult[], +): string | null { + if (activeId && results.some((result) => result.id === activeId)) return activeId; + return results[0]?.id ?? null; +} + +export function moveActiveResultId( + activeId: string | null, + results: readonly CommandCenterResult[], + direction: "next" | "previous", +): string | null { + if (results.length === 0) return null; + const current = results.findIndex((result) => result.id === activeId); + const delta = direction === "next" ? 1 : -1; + let start = current; + if (current < 0 && direction === "previous") start = 0; + const next = (start + delta + results.length) % results.length; + return results[next].id; +} diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 68814e579..9b91a375e 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -230,7 +230,6 @@ const styles = StyleSheet.create((theme) => ({ }, })); -const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }]; const WEB_EXIT_DURATION_MS = 160; function SheetBackground({ style }: BottomSheetBackgroundProps) { @@ -373,7 +372,7 @@ export function SheetHeaderView({ hosts.map((host) => host.serverId), [hosts]); const connectionStatuses = useHostRuntimeConnectionStatuses(hostIds); const projectAddByHost = useHostFeatureMap(hostIds, "projectAdd"); + // COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15. + const stableProjectIdentityByHost = useHostFeatureMap(hostIds, "stableProjectIdentity"); // COMPAT(projectGithubClone): added in v0.1.108, remove gate after 2027-01-15. const githubCloneByHost = useHostFeatureMap(hostIds, "projectGithubClone"); // COMPAT(workspaceGithubRepositorySearch): added in v0.1.108, remove gate after 2027-01-15. @@ -308,7 +312,9 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { () => hosts.flatMap((host) => { if (connectionStatuses.get(host.serverId) !== "online") return []; - const canAddProject = projectAddByHost.get(host.serverId) === true; + const canAddProject = + projectAddByHost.get(host.serverId) === true && + stableProjectIdentityByHost.get(host.serverId) === true; return [ { serverId: host.serverId, @@ -329,6 +335,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { hosts, localServerId, projectAddByHost, + stableProjectIdentityByHost, ], ); const [state, setState] = useState(() => @@ -893,7 +900,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { rows.length === 0 && page.kind !== "new-directory-name" ? ( - {emptyText(page)} + {emptyText(page, host ?? null)} ) : null} diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index ea999652d..3746c4e63 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -544,7 +544,7 @@ export function AgentList({ @@ -552,7 +552,7 @@ export function AgentList({ @@ -789,6 +789,3 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.base, }, })); - -const SHEET_CANCEL_BUTTON_STYLE = [styles.sheetButton, styles.sheetCancelButton]; -const SHEET_ARCHIVE_BUTTON_STYLE = [styles.sheetButton, styles.sheetArchiveButton]; diff --git a/packages/app/src/components/browser-webview-resident.ts b/packages/app/src/components/browser-webview-resident.ts index a97190018..92577f7da 100644 --- a/packages/app/src/components/browser-webview-resident.ts +++ b/packages/app/src/components/browser-webview-resident.ts @@ -54,22 +54,20 @@ function registerBrowserWhenAttached( identity: BrowserWebviewIdentity, browser: BrowserWebviewProfileHost, ): void { - webview.addEventListener( - "did-attach", - () => { - const webContentsId = webview.getWebContentsId(); - void browser - .registerAttachedBrowser({ - browserId: identity.browserId, - workspaceId: identity.workspaceId, - webContentsId, - }) - .catch((error) => { - console.error("[browser-webview] attached registration failed", error); - }); - }, - { once: true }, - ); + // Reparenting a webview can replace its guest WebContents without replacing + // this DOM element, so every attachment needs a fresh main-process registration. + webview.addEventListener("did-attach", () => { + const webContentsId = webview.getWebContentsId(); + void browser + .registerAttachedBrowser({ + browserId: identity.browserId, + workspaceId: identity.workspaceId, + webContentsId, + }) + .catch((error) => { + console.error("[browser-webview] attached registration failed", error); + }); + }); } function trimNonEmpty(value: string | null | undefined): string | null { diff --git a/packages/app/src/components/command-center.tsx b/packages/app/src/components/command-center.tsx deleted file mode 100644 index 49cafb624..000000000 --- a/packages/app/src/components/command-center.tsx +++ /dev/null @@ -1,726 +0,0 @@ -import { - Modal, - Pressable, - ScrollView, - Text, - TextInput, - View, - type PressableStateCallbackType, -} from "react-native"; -import { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react"; -import { useTranslation } from "react-i18next"; -import { Folder, Home, Plus, Settings } from "lucide-react-native"; -import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; -import { - useCommandCenter, - type CommandCenterActionItem, - type CommandCenterAgentItem, - type CommandCenterItem, - type CommandCenterWorkspaceItem, -} from "@/hooks/use-command-center"; -import { AgentStatusDot } from "@/components/agent-status-dot"; -import { Shortcut } from "@/components/ui/shortcut"; -import { isNative, isWeb } from "@/constants/platform"; -import { useIsCompactFormFactor } from "@/constants/layout"; -import { - IsolatedBottomSheetModal, - useIsolatedBottomSheetVisibility, -} from "@/components/ui/isolated-bottom-sheet-modal"; -import { - BottomSheetBackdrop, - BottomSheetScrollView, - BottomSheetTextInput, -} from "@gorhom/bottom-sheet"; - -const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({ - placeholderTextColor: theme.colors.foregroundMuted, -})); -const ThemedFolder = withUnistyles(Folder, (theme) => ({ - color: theme.colors.foregroundMuted, -})); - -interface CommandCenterRowProps { - active: boolean; - children: ReactNode; - onPress: () => void; - registerRow: (el: View | null) => void; - onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void; -} - -const CommandCenterRow = memo(function CommandCenterRow({ - active, - children, - onPress, - registerRow, - onLayout, -}: CommandCenterRowProps) { - const { theme } = useUnistyles(); - - const pressableStyle = useCallback( - ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ - styles.row, - (Boolean(hovered) || pressed || active) && { - backgroundColor: theme.colors.surface1, - }, - ], - [active, theme.colors.surface1], - ); - - return ( - - {children} - - ); -}); - -interface CommandCenterRowContainerProps { - item: CommandCenterItem; - rowIndex: number; - active: boolean; - rowRefs: React.MutableRefObject>; - onSelect: (item: CommandCenterItem) => void; - onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void; - children: ReactNode; -} - -function CommandCenterRowContainer({ - item, - rowIndex, - active, - rowRefs, - onSelect, - onLayout, - children, -}: CommandCenterRowContainerProps) { - const handlePress = useCallback(() => onSelect(item), [onSelect, item]); - const registerRow = useCallback( - (el: View | null) => { - if (el) rowRefs.current.set(rowIndex, el); - else rowRefs.current.delete(rowIndex); - }, - [rowRefs, rowIndex], - ); - return ( - - {children} - - ); -} - -interface CommandCenterActionRowProps { - item: CommandCenterActionItem; - rowIndex: number; - active: boolean; - rowRefs: React.MutableRefObject>; - onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void; - onSelect: (item: CommandCenterItem) => void; -} - -function CommandCenterActionRow({ - item, - rowIndex, - active, - rowRefs, - onLayout, - onSelect, -}: CommandCenterActionRowProps) { - const { theme } = useUnistyles(); - let actionIcon: React.ReactNode = null; - if (item.icon === "plus") { - actionIcon = ; - } else if (item.icon === "settings") { - actionIcon = ; - } else if (item.icon === "home") { - actionIcon = ; - } - const titleStyle = useMemo( - () => [styles.title, { color: theme.colors.foreground }], - [theme.colors.foreground], - ); - return ( - - - - {actionIcon ? {actionIcon} : null} - - - {item.title} - - - - {item.shortcutKeys ? ( - - ) : null} - - - ); -} - -interface CommandCenterAgentRowContentProps { - item: CommandCenterAgentItem; -} - -function CommandCenterAgentRowContent({ item }: CommandCenterAgentRowContentProps) { - const { theme } = useUnistyles(); - const agent = item.agent; - const titleStyle = useMemo( - () => [styles.title, { color: theme.colors.foreground }], - [theme.colors.foreground], - ); - const subtitleStyle = useMemo( - () => [styles.subtitle, { color: theme.colors.foregroundMuted }], - [theme.colors.foregroundMuted], - ); - return ( - - - - - - - - {item.title} - - - {item.subtitle} - - - - - ); -} - -interface AgentItemsSectionProps { - agentItems: CommandCenterAgentItem[]; - startIndex: number; - activeIndex: number; - rowRefs: React.MutableRefObject>; - onRowLayout: ( - rowIndex: number, - ) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => void; - onSelect: (item: CommandCenterItem) => void; - sectionDividerStyle: React.ComponentProps["style"]; - sectionLabelStyle: React.ComponentProps["style"]; -} - -function AgentItemsSection({ - agentItems, - startIndex, - activeIndex, - rowRefs, - onRowLayout, - onSelect, - sectionDividerStyle, - sectionLabelStyle, -}: AgentItemsSectionProps) { - const { t } = useTranslation(); - - return ( - <> - {startIndex > 0 ? : null} - {t("shell.commandCenter.agents")} - {agentItems.map((item, index) => { - const rowIndex = startIndex + index; - const agent = item.agent; - return ( - - - - ); - })} - - ); -} - -interface WorkspaceItemsSectionProps { - workspaceItems: CommandCenterWorkspaceItem[]; - startIndex: number; - activeIndex: number; - rowRefs: React.MutableRefObject>; - onRowLayout: ( - rowIndex: number, - ) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => void; - onSelect: (item: CommandCenterItem) => void; - sectionDividerStyle: React.ComponentProps["style"]; - sectionLabelStyle: React.ComponentProps["style"]; -} - -function WorkspaceItemsSection({ - workspaceItems, - startIndex, - activeIndex, - rowRefs, - onRowLayout, - onSelect, - sectionDividerStyle, - sectionLabelStyle, -}: WorkspaceItemsSectionProps) { - const { t } = useTranslation(); - - return ( - <> - {startIndex > 0 ? : null} - {t("shell.commandCenter.workspaces")} - {workspaceItems.map((item, index) => { - const rowIndex = startIndex + index; - return ( - - - - - - - - - {item.title} - - - {item.subtitle} - - - - - - ); - })} - - ); -} - -export function CommandCenter() { - const { theme } = useUnistyles(); - const { t } = useTranslation(); - const { - open, - inputRef, - query, - setQuery, - activeIndex, - items, - handleClose, - handleSelectItem, - handleKeyEvent, - } = useCommandCenter(); - const isCompact = useIsCompactFormFactor(); - const showBottomSheet = isCompact && isNative; - const rowRefs = useRef>(new Map()); - const rowLayouts = useRef>(new Map()); - const resultsRef = useRef(null); - const nativeScrollY = useRef(0); - const nativeViewHeight = useRef(0); - // BottomSheetTextInput wraps a different TextInput type (from react-native-gesture-handler). - // Use a loose ref to avoid the type mismatch — same pattern as AdaptiveTextInput. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const bottomSheetInputRef = useRef(null); - - const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({ - visible: open, - isEnabled: showBottomSheet, - onClose: handleClose, - }); - - // Focus the bottom sheet input when the sheet opens on mobile - useEffect(() => { - if (showBottomSheet && open) { - const id = setTimeout(() => bottomSheetInputRef.current?.focus(), 300); - return () => clearTimeout(id); - } - }, [showBottomSheet, open]); - - const renderBackdrop = useCallback( - (props: React.ComponentProps) => ( - - ), - [], - ); - - // Scroll active row into view - useEffect(() => { - if (!open) return; - - if (isWeb) { - const row = rowRefs.current.get(activeIndex); - if (!row || typeof document === "undefined") return; - const scrollNode = - ( - resultsRef.current as - | (ScrollView & { - getScrollableNode?: () => HTMLElement | null; - }) - | null - )?.getScrollableNode?.() ?? null; - const rowEl = row as unknown as HTMLElement; - - if (!scrollNode) { - rowEl.scrollIntoView?.({ block: "nearest" }); - return; - } - - const rowTop = rowEl.offsetTop; - const rowBottom = rowTop + rowEl.offsetHeight; - const visibleTop = scrollNode.scrollTop; - const visibleBottom = visibleTop + scrollNode.clientHeight; - - if (rowTop < visibleTop) { - scrollNode.scrollTop = rowTop; - return; - } - - if (rowBottom > visibleBottom) { - scrollNode.scrollTop = rowBottom - scrollNode.clientHeight; - } - return; - } - - // Native: use onLayout-measured positions - const layout = rowLayouts.current.get(activeIndex); - if (!layout || !resultsRef.current) return; - - const rowTop = layout.y; - const rowBottom = rowTop + layout.height; - const visibleTop = nativeScrollY.current; - const visibleBottom = visibleTop + nativeViewHeight.current; - - if (rowTop < visibleTop) { - resultsRef.current.scrollTo?.({ y: rowTop, animated: true }); - } else if (rowBottom > visibleBottom) { - resultsRef.current.scrollTo?.({ - y: rowBottom - nativeViewHeight.current, - animated: true, - }); - } - }, [activeIndex, open]); - - const handleRowLayout = useCallback( - (rowIndex: number) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => { - rowLayouts.current.set(rowIndex, { - y: event.nativeEvent.layout.y, - height: event.nativeEvent.layout.height, - }); - }, - [], - ); - - const actionItems = useMemo(() => items.filter((item) => item.kind === "action"), [items]); - const workspaceItems = useMemo(() => items.filter((item) => item.kind === "workspace"), [items]); - const agentItems = useMemo(() => items.filter((item) => item.kind === "agent"), [items]); - - const panelStyle = useMemo( - () => [ - styles.panel, - { borderColor: theme.colors.border, backgroundColor: theme.colors.surface0 }, - ], - [theme.colors.border, theme.colors.surface0], - ); - const headerStyle = useMemo( - () => [styles.header, { borderBottomColor: theme.colors.border }], - [theme.colors.border], - ); - const inputStyle = useMemo( - () => [styles.input, { color: theme.colors.foreground }], - [theme.colors.foreground], - ); - const emptyTextStyle = useMemo( - () => [styles.emptyText, { color: theme.colors.foregroundMuted }], - [theme.colors.foregroundMuted], - ); - const sectionLabelStyle = useMemo( - () => [styles.sectionLabel, { color: theme.colors.foregroundMuted }], - [theme.colors.foregroundMuted], - ); - const sectionDividerStyle = useMemo( - () => [styles.sectionDivider, { backgroundColor: theme.colors.border }], - [theme.colors.border], - ); - const sheetBackgroundStyle = useMemo( - () => ({ backgroundColor: theme.colors.surface0 }), - [theme.colors.surface0], - ); - const sheetHandleStyle = useMemo( - () => ({ backgroundColor: theme.colors.palette.zinc[600] }), - [theme.colors.palette.zinc], - ); - - const handleKeyPress = useCallback( - ({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => { - handleKeyEvent(key); - }, - [handleKeyEvent], - ); - - const handleSubmitEditing = useCallback(() => { - handleKeyEvent("Enter"); - }, [handleKeyEvent]); - - const snapPoints = useMemo(() => ["60%", "90%"], []); - - const resultList = - items.length === 0 ? ( - {t("shell.commandCenter.noMatches")} - ) : ( - <> - {actionItems.length > 0 ? ( - <> - {t("shell.commandCenter.actions")} - {actionItems.map((item, index) => ( - - ))} - - ) : null} - - {workspaceItems.length > 0 ? ( - - ) : null} - - {agentItems.length > 0 ? ( - - ) : null} - - ); - - // Mobile: bottom sheet - if (showBottomSheet) { - return ( - - - } - value={query} - onChangeText={setQuery} - onKeyPress={handleKeyPress} - onSubmitEditing={handleSubmitEditing} - placeholder={t("shell.commandCenter.placeholder")} - style={inputStyle} - autoCapitalize="none" - autoCorrect={false} - autoFocus - /> - - - {resultList} - - - ); - } - - if (!open) return null; - - // Desktop web: centered overlay panel - return ( - - - - - - - - - - - {resultList} - - - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - bottomSheetHeader: { - paddingHorizontal: theme.spacing[4], - paddingVertical: theme.spacing[3], - borderBottomWidth: 1, - borderBottomColor: theme.colors.border, - }, - overlay: { - flex: 1, - justifyContent: "flex-start", - alignItems: "center", - paddingTop: theme.spacing[12], - }, - backdrop: { - ...StyleSheet.absoluteFillObject, - backgroundColor: "rgba(0, 0, 0, 0.5)", - }, - panel: { - width: 640, - maxWidth: "92%", - maxHeight: "80%", - borderWidth: 1, - borderRadius: theme.borderRadius.lg, - overflow: "hidden", - ...theme.shadow.lg, - }, - header: { - paddingHorizontal: theme.spacing[4], - paddingVertical: theme.spacing[3], - borderBottomWidth: 1, - }, - input: { - fontSize: theme.fontSize.base, - paddingVertical: theme.spacing[1], - outlineStyle: "none", - } as object, - results: { - flexGrow: 0, - }, - resultsContent: { - paddingVertical: theme.spacing[2], - }, - sectionLabel: { - paddingHorizontal: theme.spacing[4], - paddingTop: 0, - paddingBottom: theme.spacing[2], - fontSize: theme.fontSize.xs, - }, - sectionDivider: { - height: 1, - marginTop: theme.spacing[2], - marginBottom: theme.spacing[2], - }, - row: { - paddingHorizontal: theme.spacing[4], - paddingVertical: theme.spacing[2], - }, - rowContent: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - gap: theme.spacing[3], - }, - rowMain: { - flex: 1, - minWidth: 0, - flexDirection: "row", - alignItems: "flex-start", - gap: theme.spacing[3], - }, - iconSlot: { - width: 16, - height: 20, - alignItems: "center", - justifyContent: "center", - }, - textContent: { - flex: 1, - minWidth: 0, - gap: 2, - }, - rowShortcut: { - marginLeft: theme.spacing[2], - flexShrink: 0, - }, - title: { - fontSize: theme.fontSize.sm, - fontWeight: "400", - lineHeight: 20, - color: theme.colors.foreground, - }, - subtitle: { - fontSize: theme.fontSize.xs, - lineHeight: 18, - color: theme.colors.foregroundMuted, - }, - emptyText: { - paddingHorizontal: theme.spacing[4], - paddingVertical: theme.spacing[4], - fontSize: theme.fontSize.base, - }, -})); diff --git a/packages/app/src/components/dictation-controls.tsx b/packages/app/src/components/dictation-controls.tsx index eb2b14a25..477717d4e 100644 --- a/packages/app/src/components/dictation-controls.tsx +++ b/packages/app/src/components/dictation-controls.tsx @@ -105,7 +105,7 @@ export function DictationControls({ @@ -115,14 +115,14 @@ export function DictationControls({ @@ -240,7 +240,7 @@ export function DictationOverlay({ onPress={onAccept} accessibilityRole="button" accessibilityLabel={t("message.dictation.insert")} - style={OVERLAY_ACCEPT_BUTTON_STYLE} + style={[overlayStyles.actionButton, OVERLAY_ACCEPT_BUTTON_BG]} > ({ }, })); -const ACTION_CONFIRM_STYLE = [styles.actionButton, styles.actionButtonConfirm]; -const ACTION_SECONDARY_STYLE = [styles.actionButton, styles.actionButtonSecondary]; const OVERLAY_ACCEPT_BUTTON_BG = { backgroundColor: "rgba(255, 255, 255, 0.25)" }; -const OVERLAY_ACCEPT_BUTTON_STYLE = [overlayStyles.actionButton, OVERLAY_ACCEPT_BUTTON_BG]; diff --git a/packages/app/src/components/drag-reorder/index.ts b/packages/app/src/components/drag-reorder/index.ts index 4016bd1e6..ba31937e4 100644 --- a/packages/app/src/components/drag-reorder/index.ts +++ b/packages/app/src/components/drag-reorder/index.ts @@ -1,8 +1,9 @@ export { reorderItemsOnDragEnd } from "./reorder-items"; export type { DragEndInput } from "./reorder-items"; export { - getPointerActivationConstraint, - type PointerActivationConfig, + getDragActivationConstraints, + type DragActivationConfig, + type DragActivationConstraints, type PointerActivationConstraint, } from "./pointer-activation"; export { diff --git a/packages/app/src/components/drag-reorder/pointer-activation.test.ts b/packages/app/src/components/drag-reorder/pointer-activation.test.ts index d1c6fa041..921dd79a2 100644 --- a/packages/app/src/components/drag-reorder/pointer-activation.test.ts +++ b/packages/app/src/components/drag-reorder/pointer-activation.test.ts @@ -1,14 +1,21 @@ import { describe, expect, it } from "vitest"; -import { getPointerActivationConstraint } from "./pointer-activation"; +import { getDragActivationConstraints } from "./pointer-activation"; -const config = { defaultDistance: 6, holdDelayMs: 250, holdTolerance: 8 }; +const config = { movementDistance: 6, touchHoldDelayMs: 180, touchHoldTolerance: 8 }; -describe("getPointerActivationConstraint", () => { - it("uses distance activation for default draggable rows", () => { - expect(getPointerActivationConstraint(false, config)).toEqual({ distance: 6 }); +describe("getDragActivationConstraints", () => { + it("starts mouse drags after deliberate pointer movement", () => { + expect(getDragActivationConstraints(true, config).mouse).toEqual({ distance: 6 }); }); - it("requires a held pointer before activating handle-based drags", () => { - expect(getPointerActivationConstraint(true, config)).toEqual({ delay: 250, tolerance: 8 }); + it("requires a short hold before starting touch drags", () => { + expect(getDragActivationConstraints(true, config).touch).toEqual({ + delay: 180, + tolerance: 8, + }); + }); + + it("starts ordinary touch rows after deliberate movement", () => { + expect(getDragActivationConstraints(false, config).touch).toEqual({ distance: 6 }); }); }); diff --git a/packages/app/src/components/drag-reorder/pointer-activation.ts b/packages/app/src/components/drag-reorder/pointer-activation.ts index cb68e2bda..9b929c8f7 100644 --- a/packages/app/src/components/drag-reorder/pointer-activation.ts +++ b/packages/app/src/components/drag-reorder/pointer-activation.ts @@ -2,18 +2,25 @@ export type PointerActivationConstraint = | { distance: number } | { delay: number; tolerance: number }; -export interface PointerActivationConfig { - defaultDistance: number; - holdDelayMs: number; - holdTolerance: number; +export interface DragActivationConfig { + movementDistance: number; + touchHoldDelayMs: number; + touchHoldTolerance: number; } -export function getPointerActivationConstraint( - useDragHandle: boolean, - config: PointerActivationConfig, -): PointerActivationConstraint { - if (useDragHandle) { - return { delay: config.holdDelayMs, tolerance: config.holdTolerance }; - } - return { distance: config.defaultDistance }; +export interface DragActivationConstraints { + mouse: PointerActivationConstraint; + touch: PointerActivationConstraint; +} + +export function getDragActivationConstraints( + useDragHandle: boolean, + config: DragActivationConfig, +): DragActivationConstraints { + const movement = { distance: config.movementDistance }; + const touch = useDragHandle + ? { delay: config.touchHoldDelayMs, tolerance: config.touchHoldTolerance } + : movement; + + return { mouse: movement, touch }; } diff --git a/packages/app/src/components/draggable-list.web.tsx b/packages/app/src/components/draggable-list.web.tsx index 3f16fc072..7c1e683e5 100644 --- a/packages/app/src/components/draggable-list.web.tsx +++ b/packages/app/src/components/draggable-list.web.tsx @@ -4,7 +4,8 @@ import { DndContext, closestCenter, KeyboardSensor, - PointerSensor, + MouseSensor, + TouchSensor, type Modifier, useSensor, useSensors, @@ -17,7 +18,7 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types"; -import { getPointerActivationConstraint, useDragReorderState } from "./drag-reorder"; +import { getDragActivationConstraints, useDragReorderState } from "./drag-reorder"; export type { DraggableListProps, DraggableRenderItemInfo }; @@ -27,10 +28,10 @@ const restrictToVerticalAxis: Modifier = ({ transform }) => ({ }); const DND_MODIFIERS = [restrictToVerticalAxis]; -const POINTER_ACTIVATION_CONFIG = { - defaultDistance: 6, - holdDelayMs: 250, - holdTolerance: 8, +const DRAG_ACTIVATION_CONFIG = { + movementDistance: 6, + touchHoldDelayMs: 180, + touchHoldTolerance: 8, }; interface SortableItemProps { @@ -145,14 +146,14 @@ export function DraggableList({ onDragEnd, onDragBegin, }); - const pointerActivationConstraint = getPointerActivationConstraint( - useDragHandle, - POINTER_ACTIVATION_CONFIG, - ); + const activationConstraints = getDragActivationConstraints(useDragHandle, DRAG_ACTIVATION_CONFIG); const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: pointerActivationConstraint, + useSensor(MouseSensor, { + activationConstraint: activationConstraints.mouse, + }), + useSensor(TouchSensor, { + activationConstraint: activationConstraints.touch, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 0af8a0398..2d7209ae9 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -8,7 +8,7 @@ import { } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated"; -import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import { Gesture } from "react-native-gesture-handler"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { X } from "lucide-react-native"; import { useTranslation } from "react-i18next"; @@ -33,7 +33,7 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useHasOwnedWindowChromeObstruction, WindowChromeSafeArea } from "@/utils/desktop-window"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { RetainedPanelActivity } from "@/components/retained-panel"; -import { isWeb } from "@/constants/platform"; +import { SidebarResizeHandle } from "@/components/sidebar-resize-handle"; import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout"; @@ -211,10 +211,12 @@ export function ExplorerSidebar({ return ( - - - - + + ({ borderLeftColor: theme.colors.border, backgroundColor: theme.colors.surfaceSidebar, }, - resizeHandle: { - position: "absolute", - left: -5, - top: 0, - bottom: 0, - width: 10, - zIndex: 10, - }, sidebarContent: { flex: 1, minHeight: 0, @@ -522,6 +516,3 @@ const styles = StyleSheet.create((theme) => ({ minHeight: 0, }, })); - -const DESKTOP_SIDEBAR_BORDER_STYLE = [styles.desktopSidebarBorder, { flex: 1 }]; -const RESIZE_HANDLE_STYLE = [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)]; diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 6707b417c..2a6f42464 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -575,7 +575,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { } return ( - + {currentSortLabel} @@ -1253,5 +1253,3 @@ const styles = StyleSheet.create((theme) => ({ padding: theme.spacing[4], }, })); - -const TREE_PANE_CONTAINER_STYLE = [styles.treePane, styles.treePaneFill]; diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx index e8cde4141..e78ac348b 100644 --- a/packages/app/src/components/file-pane.tsx +++ b/packages/app/src/components/file-pane.tsx @@ -27,7 +27,7 @@ import { explorerFileFromReadResult } from "@/file-explorer/read-result"; import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target"; import type { WorkspaceFileLocation } from "@/workspace/file-open"; import { useRetainedPanelActive } from "@/components/retained-panel"; -import { useAppVisible } from "@/hooks/use-app-visible"; +import { useAppActivelyVisible } from "@/hooks/use-app-visible"; import { isFileQueryEnabled } from "@/components/file-pane-enabled"; interface CodeLineProps { @@ -386,10 +386,10 @@ export function FilePane({ ); // Re-read the file when this pane becomes visible again (#445). `isActive` - // covers tab switches, `isAppVisible` the whole-app background/foreground; the - // gate itself lives in isFileQueryEnabled. + // covers tab switches; active app visibility covers backgrounding and returning + // from another window after an external edit. The gate lives in isFileQueryEnabled. const isActive = useRetainedPanelActive(); - const isAppVisible = useAppVisible(); + const isAppVisible = useAppActivelyVisible(); const query = useQuery({ queryKey: ["workspaceFile", serverId, readTarget?.cwd ?? null, readTarget?.path ?? null], diff --git a/packages/app/src/components/icons/omp-icon.tsx b/packages/app/src/components/icons/omp-icon.tsx index b674d1744..23780a5a8 100644 --- a/packages/app/src/components/icons/omp-icon.tsx +++ b/packages/app/src/components/icons/omp-icon.tsx @@ -1,4 +1,4 @@ -import Svg, { Circle, Rect } from "react-native-svg"; +import Svg, { Path } from "react-native-svg"; interface OmpIconProps { size?: number; @@ -6,17 +6,9 @@ interface OmpIconProps { } export function OmpIcon({ size = 16, color = "currentColor" }: OmpIconProps) { - // Ported from the Oh My Pi MIT-licensed assets/icon.svg mark. return ( - - - - - - - - - + + ); } diff --git a/packages/app/src/components/keyboard-shortcuts-dialog.tsx b/packages/app/src/components/keyboard-shortcuts-dialog.tsx index ea90e6ed1..770f1193d 100644 --- a/packages/app/src/components/keyboard-shortcuts-dialog.tsx +++ b/packages/app/src/components/keyboard-shortcuts-dialog.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; @@ -6,25 +6,91 @@ import { getIsElectronRuntime } from "@/constants/layout"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { Shortcut } from "@/components/ui/shortcut"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; +import { formatShortcut } from "@/utils/format-shortcut"; import { getShortcutOs } from "@/utils/shortcut-platform"; import { buildKeyboardShortcutHelpSections } from "@/keyboard/keyboard-shortcuts"; const SNAP_POINTS: string[] = ["70%", "92%"]; +function shortcutSearchAliases(keys: string[], shortcutOs: "mac" | "non-mac"): string { + const aliases = keys.map((key) => { + if (shortcutOs === "mac") { + if (key === "mod" || key === "meta") return ["cmd", "command"]; + if (key === "alt") return ["alt", "option"]; + } else { + if (key === "mod" || key === "ctrl") return ["ctrl", "control"]; + if (key === "meta") return ["win", "windows"]; + } + return [key]; + }); + const combinations = aliases.reduce( + (prefixes, choices) => + prefixes.flatMap((prefix) => choices.map((choice) => [...prefix, choice])), + [[]], + ); + return combinations + .flatMap((combination) => [combination.join(" "), combination.join("+")]) + .join(" "); +} + export function KeyboardShortcutsDialog() { const { t } = useTranslation(); const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen); const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen); + const [query, setQuery] = useState(""); - const isMac = getShortcutOs() === "mac"; + const shortcutOs = getShortcutOs(); + const isMac = shortcutOs === "mac"; const isDesktopApp = getIsElectronRuntime(); const sections = useMemo( () => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }), [isDesktopApp, isMac], ); + const visibleSections = useMemo(() => { + const normalizedQuery = query.trim().toLocaleLowerCase(); + if (!normalizedQuery) return sections; + + return sections.flatMap((section) => { + const sectionTitle = t(section.titleKey); + if (sectionTitle.toLocaleLowerCase().includes(normalizedQuery)) { + return [section]; + } + + const rows = section.rows.filter((row) => { + const searchText = [ + t(row.labelKey), + row.noteKey ? t(row.noteKey) : row.note, + row.keys.join(" "), + formatShortcut(row.keys, shortcutOs), + shortcutSearchAliases(row.keys, shortcutOs), + ] + .filter(Boolean) + .join(" ") + .toLocaleLowerCase(); + return searchText.includes(normalizedQuery); + }); + + return rows.length > 0 ? [{ ...section, rows }] : []; + }); + }, [query, sections, shortcutOs, t]); + + useEffect(() => { + if (!open) setQuery(""); + }, [open]); const handleClose = useCallback(() => setOpen(false), [setOpen]); - const header = useMemo(() => ({ title: t("settings.shortcuts.dialogTitle") }), [t]); + const header = useMemo( + () => ({ + title: t("settings.shortcuts.dialogTitle"), + search: { + onChange: setQuery, + resetKey: Number(open), + placeholder: t("settings.shortcuts.searchPlaceholder"), + autoFocus: true, + }, + }), + [open, t], + ); return ( - {sections.map((section) => ( + {visibleSections.map((section) => ( {t(section.titleKey)} @@ -53,6 +119,9 @@ export function KeyboardShortcutsDialog() { ))} + {visibleSections.length === 0 ? ( + {t("common.empty.noResults")} + ) : null} ); @@ -102,4 +171,10 @@ const styles = StyleSheet.create((theme) => ({ rowShortcut: { alignSelf: "flex-start", }, + empty: { + paddingVertical: theme.spacing[6], + textAlign: "center", + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, })); diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 4dbac08be..0e36f9c78 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -20,7 +20,7 @@ import { View, type PressableStateCallbackType, } from "react-native"; -import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import { Gesture } from "react-native-gesture-handler"; import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -30,10 +30,10 @@ import { HostPicker } from "@/components/hosts/host-picker"; import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/sidebar-display-preferences-menu"; import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu"; +import { SidebarResizeHandle } from "@/components/sidebar-resize-handle"; import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; -import { isWeb } from "@/constants/platform"; import { useOpenAddProject } from "@/hooks/use-open-add-project"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { canCreateWorktreeForProjectKind } from "@/projects/host-projects"; @@ -765,11 +765,6 @@ function DesktopSidebar({ () => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome], [ownsTopLeft], ); - const resizeHandleStyle = useMemo( - () => [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)], - [], - ); - return ( - {/* Resize handle - absolutely positioned over right border */} - - - + ); @@ -998,14 +994,6 @@ const styles = StyleSheet.create((theme) => ({ borderRightColor: theme.colors.border, backgroundColor: theme.colors.surfaceSidebar, }, - resizeHandle: { - position: "absolute", - right: -5, - top: 0, - bottom: 0, - width: 10, - zIndex: 10, - }, sidebarDragArea: { position: "relative", }, diff --git a/packages/app/src/components/provider-catalog-list.tsx b/packages/app/src/components/provider-catalog-list.tsx index f3e0835d0..a31e45be0 100644 --- a/packages/app/src/components/provider-catalog-list.tsx +++ b/packages/app/src/components/provider-catalog-list.tsx @@ -155,7 +155,7 @@ export function ProviderCatalogList({ accessibilityLabel={t("providerCatalog.search")} placeholder={t("providerCatalog.search")} // @ts-expect-error - outlineStyle is web-only - style={SEARCH_INPUT_STYLE} + style={[styles.searchInput, isWeb && { outlineStyle: "none" }]} autoCapitalize="none" autoCorrect={false} /> @@ -284,5 +284,3 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, }, })); - -const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }]; diff --git a/packages/app/src/components/provider-diagnostic-sheet.tsx b/packages/app/src/components/provider-diagnostic-sheet.tsx index 9085801e0..b4831acac 100644 --- a/packages/app/src/components/provider-diagnostic-sheet.tsx +++ b/packages/app/src/components/provider-diagnostic-sheet.tsx @@ -225,7 +225,7 @@ function AddCustomModelSubSheet({ autoCorrect={false} returnKeyType="done" // @ts-expect-error - outlineStyle is web-only - style={FORM_INPUT_STYLE} + style={[sheetStyles.formInput, isWeb && { outlineStyle: "none" }]} /> {error ? {error} : null} @@ -437,7 +437,9 @@ function renderProviderSheetFooter({ const contentStyle = isCompact ? sheetStyles.compactFooterContent : sheetStyles.footerContent; const actionsStyle = isCompact ? sheetStyles.compactFooterActions : sheetStyles.footerActions; const buttonStyle = isCompact ? sheetStyles.compactFooterButton : null; - const metaStyle = isCompact ? COMPACT_FOOTER_META_STYLE : sheetStyles.footerMeta; + const metaStyle = isCompact + ? [sheetStyles.footerMeta, sheetStyles.compactFooterMeta] + : sheetStyles.footerMeta; return ( @@ -871,9 +873,6 @@ const sheetStyles = StyleSheet.create((theme) => ({ }, })); -const FORM_INPUT_STYLE = [sheetStyles.formInput, isWeb && { outlineStyle: "none" }]; -const COMPACT_FOOTER_META_STYLE = [sheetStyles.footerMeta, sheetStyles.compactFooterMeta]; - const MAIN_SNAP_POINTS = ["65%", "92%"]; const ADD_SNAP_POINTS = ["40%"]; const DIAGNOSTIC_SNAP_POINTS = ["50%", "85%"]; diff --git a/packages/app/src/components/rewind/use-rewind-agent-mutation.ts b/packages/app/src/components/rewind/use-rewind-agent-mutation.ts index e684c4bf5..037f7c733 100644 --- a/packages/app/src/components/rewind/use-rewind-agent-mutation.ts +++ b/packages/app/src/components/rewind/use-rewind-agent-mutation.ts @@ -8,6 +8,7 @@ import { useRewindComposerRestore } from "./composer-restore"; import { useSessionStore } from "@/stores/session-store"; import { shouldRestoreComposerForRewindMode } from "./rewind-mode"; import { clearOptimisticUserMessages } from "@/types/stream"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; interface UseRewindAgentMutationInput { serverId?: string; @@ -47,7 +48,8 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): { .getState() .sessions[input.serverId]?.agentTimelineCursor.get(input.agentId) : undefined; - await input.client.fetchAgentTimeline(input.agentId, { + if (!input.serverId) throw new Error(t("common.errors.daemonClientUnavailable")); + await getHostRuntimeStore().fetchAgentTimeline(input.serverId, input.agentId, { direction: "tail", projection: "projected", ...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}), diff --git a/packages/app/src/components/schedules/schedule-form-sheet.tsx b/packages/app/src/components/schedules/schedule-form-sheet.tsx index 721ceb225..febfbf6ca 100644 --- a/packages/app/src/components/schedules/schedule-form-sheet.tsx +++ b/packages/app/src/components/schedules/schedule-form-sheet.tsx @@ -13,7 +13,7 @@ import { Text, View } from "react-native"; import { Brain, Folder, GitBranch } from "lucide-react-native"; import { StyleSheet } from "react-native-unistyles"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; -import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types"; +import type { ScheduleCadence, ScheduleSummary } from "@getpaseo/protocol/schedule/types"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { ComboboxItem } from "@/components/ui/combobox"; @@ -71,6 +71,15 @@ function parseMaxRuns(raw: string): number | null { return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } +function requireCronCadence( + cadence: Extract | undefined, +): Extract { + if (!cadence) { + throw new Error("Choose a cron cadence before creating this schedule"); + } + return cadence; +} + function resolveCreateServerId(input: { mode: "create" | "edit"; serverId: string | null | undefined; @@ -306,18 +315,15 @@ function OpenScheduleFormSheet({ ]); const submitAgentTarget = useCallback(async (): Promise => { - if (!schedule) { + if (!schedule || !state.submitCadence) { return false; } await updateSchedule({ id: schedule.id, - name: state.name.trim() || null, - prompt: state.prompt.trim(), cadence: state.submitCadence, - maxRuns: parseMaxRuns(state.maxRuns), }); return true; - }, [schedule, state.maxRuns, state.name, state.prompt, state.submitCadence, updateSchedule]); + }, [schedule, state.submitCadence, updateSchedule]); const submitNewAgent = useCallback(async (): Promise => { const provider = state.selectedProvider; @@ -333,7 +339,7 @@ function OpenScheduleFormSheet({ id: schedule.id, name: state.name.trim() || null, prompt: state.prompt.trim(), - cadence: state.submitCadence, + ...(state.submitCadence ? { cadence: state.submitCadence } : {}), newAgentConfig: { provider, model: state.selectedModel || null, @@ -353,7 +359,7 @@ function OpenScheduleFormSheet({ await createSchedule({ prompt: state.prompt.trim(), name: state.name.trim() || undefined, - cadence: state.submitCadence, + cadence: requireCronCadence(state.submitCadence), target: { type: "new-agent", config: { @@ -394,10 +400,12 @@ function OpenScheduleFormSheet({ void handleSubmit(); }, [handleSubmit]); - const header = useMemo( - () => ({ title: mode === "edit" ? "Edit schedule" : "New schedule" }), - [mode], - ); + const header = useMemo(() => { + if (mode !== "edit") { + return { title: "New schedule" }; + } + return { title: schedule?.target.type === "agent" ? "Edit heartbeat" : "Edit schedule" }; + }, [mode, schedule?.target.type]); const footer = useMemo( () => ( @@ -466,6 +474,21 @@ function ScheduleFormFields({ cadenceError, mutationServerId, }: ScheduleFormFieldsProps): ReactElement { + if (state.targetKind === "agent") { + return ( + <> + + + {state.submitError ? {state.submitError} : null} + + ); + } + return ( <> @@ -502,7 +525,7 @@ function ScheduleFormFields({ model={model} state={state} providerSnapshot={providerSnapshot} - agentTargetLabel={agentTargetLabel} + agentTargetLabel={null} controlSize={controlSize} mutationServerId={mutationServerId} /> diff --git a/packages/app/src/components/schedules/schedule-row.tsx b/packages/app/src/components/schedules/schedule-row.tsx index a5b6b0c4e..faf491a35 100644 --- a/packages/app/src/components/schedules/schedule-row.tsx +++ b/packages/app/src/components/schedules/schedule-row.tsx @@ -16,7 +16,12 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import { settingsStyles } from "@/styles/settings"; import type { Theme } from "@/styles/theme"; import type { ScheduleDerivedState } from "@/schedules/schedule-derivation"; -import { formatCadence, formatNextRun, resolveScheduleTitle } from "@/utils/schedule-format"; +import { + formatCadence, + formatNextRun, + resolveScheduleTitle, + scheduleProductName, +} from "@/utils/schedule-format"; import { formatTimeAgo } from "@/utils/time"; import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types"; @@ -153,9 +158,10 @@ export function ScheduleRow({ const handlePointerLeave = useCallback(() => setIsHovered(false), []); const title = resolveScheduleTitle(schedule); + const productName = scheduleProductName(schedule); const badge = stateBadge(state); const meta = buildMeta(schedule, state, serverName, singleHost ?? false); - const canRun = state === "active" || state === "paused"; + const canRun = schedule.target.type === "new-agent" && (state === "active" || state === "paused"); const rowStyle = useCallback( ({ pressed }: PressableStateCallbackType) => [ @@ -178,7 +184,7 @@ export function ScheduleRow({ style={rowStyle} onPress={onEdit} accessibilityRole="button" - accessibilityLabel={`Edit schedule ${title}`} + accessibilityLabel={`Edit ${productName.toLowerCase()} ${title}`} testID={`schedule-row-${schedule.id}`} > @@ -222,6 +228,66 @@ const resumeLeading = ; const deleteLeading = ; +function ScheduleExecutionMenuItems({ + schedule, + canRun, + pending, + onPause, + onResume, + onRunNow, +}: Pick & { + canRun: boolean; +}): ReactElement | null { + if (schedule.target.type === "agent") { + return null; + } + + let cadenceAction: ReactElement; + if (schedule.status === "paused") { + cadenceAction = ( + + Resume schedule + + ); + } else { + cadenceAction = ( + + Pause schedule + + ); + } + + return ( + <> + {cadenceAction} + + Run now + + + ); +} + function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }): ReactElement { return ( & { canRun: boolean; }): ReactElement { + const productName = scheduleProductName(schedule); + const productNameLower = productName.toLowerCase(); return ( {renderKebabTriggerIcon} @@ -263,41 +331,16 @@ function ScheduleKebabMenu({ onSelect={onEdit} testID={`schedule-menu-edit-${schedule.id}`} > - Edit schedule - - {schedule.status === "paused" ? ( - - Resume schedule - - ) : ( - - Pause schedule - - )} - - Run now + Edit {productNameLower} + - Delete schedule + Delete {productNameLower} diff --git a/packages/app/src/components/schedules/schedules-table.tsx b/packages/app/src/components/schedules/schedules-table.tsx index 037bc1f10..c591b03d4 100644 --- a/packages/app/src/components/schedules/schedules-table.tsx +++ b/packages/app/src/components/schedules/schedules-table.tsx @@ -7,7 +7,7 @@ import type { AggregatedSchedule } from "@/hooks/use-schedules"; import type { ScheduleDerivedState } from "@/schedules/schedule-derivation"; import { settingsStyles } from "@/styles/settings"; import { confirmDialog } from "@/utils/confirm-dialog"; -import { resolveScheduleTitle } from "@/utils/schedule-format"; +import { resolveScheduleTitle, scheduleProductName } from "@/utils/schedule-format"; /** A schedule plus the client-derived fields the row renders. */ export interface ScheduleRowView { @@ -113,8 +113,9 @@ function SchedulesTableRow({ const handleDelete = useCallback(() => { void (async () => { + const productName = scheduleProductName(schedule); const confirmed = await confirmDialog({ - title: "Delete schedule", + title: `Delete ${productName.toLowerCase()}`, message: `Delete "${resolveScheduleTitle(schedule)}"? This cannot be undone.`, confirmLabel: "Delete", destructive: true, diff --git a/packages/app/src/components/sidebar-resize-handle.tsx b/packages/app/src/components/sidebar-resize-handle.tsx new file mode 100644 index 000000000..617d9076b --- /dev/null +++ b/packages/app/src/components/sidebar-resize-handle.tsx @@ -0,0 +1,89 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Pressable, View } from "react-native"; +import { GestureDetector, type GestureType } from "react-native-gesture-handler"; +import { StyleSheet } from "react-native-unistyles"; +import { isWeb } from "@/constants/platform"; + +interface SidebarResizeHandleProps { + edge: "left" | "right"; + gesture: GestureType; + testID: string; +} + +const HIGHLIGHT_DELAY_MS = 100; + +const webResizeCursorStyle = isWeb + ? ({ + cursor: "col-resize", + } as object) + : null; + +export function SidebarResizeHandle({ edge, gesture, testID }: SidebarResizeHandleProps) { + const [highlighted, setHighlighted] = useState(false); + const highlightTimerRef = useRef | null>(null); + const hitAreaStyle = + edge === "left" + ? [styles.hitArea, styles.leftEdge, webResizeCursorStyle] + : [styles.hitArea, styles.rightEdge, webResizeCursorStyle]; + + const cancelHighlightTimer = useCallback(() => { + if (highlightTimerRef.current === null) return; + clearTimeout(highlightTimerRef.current); + highlightTimerRef.current = null; + }, []); + + const handleHoverIn = useCallback(() => { + cancelHighlightTimer(); + highlightTimerRef.current = setTimeout(() => { + highlightTimerRef.current = null; + setHighlighted(true); + }, HIGHLIGHT_DELAY_MS); + }, [cancelHighlightTimer]); + + const handleHoverOut = useCallback(() => { + cancelHighlightTimer(); + setHighlighted(false); + }, [cancelHighlightTimer]); + + useEffect(() => cancelHighlightTimer, [cancelHighlightTimer]); + + return ( + + + {highlighted ? ( + + ) : null} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + hitArea: { + position: "absolute", + top: 0, + bottom: 0, + width: 10, + zIndex: 10, + }, + leftEdge: { + left: -5, + }, + rightEdge: { + right: -5, + }, + highlight: { + position: "absolute", + top: 0, + bottom: 0, + left: 5, + width: 1, + backgroundColor: theme.colors.foreground, + opacity: 0.25, + }, +})); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index bad34715b..44e545ba1 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -2,15 +2,12 @@ import { View, Text, Pressable, - Platform, ActivityIndicator, - StatusBar, ScrollView, type GestureResponderEvent, type PressableStateCallbackType, type ViewStyle, } from "react-native"; -import * as Haptics from "expo-haptics"; import { useMutation } from "@tanstack/react-query"; import { ProjectIconView } from "@/components/project-icon-view"; import { AdaptiveRenameModal } from "@/components/rename-modal"; @@ -87,7 +84,6 @@ import { useToast } from "@/contexts/toast-context"; import { getForgePresentation, normalizeForge } from "@/git/forge"; import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning"; import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder"; -import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration"; import { confirmDialog } from "@/utils/confirm-dialog"; import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; @@ -96,6 +92,7 @@ import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status-list"; import type { StatusGroup } from "@/hooks/sidebar-status-view-model"; import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu"; +import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction"; import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header"; import { SidebarWorkspaceRowFrame, @@ -318,7 +315,9 @@ export function PrBadge({ hint }: { hint: PrHint }) { const handleHoverIn = useCallback(() => setIsHovered(true), []); const handleHoverOut = useCallback(() => setIsHovered(false), []); - const textStyle = isHovered ? prBadgeTextHoveredCombined : prBadgeStyles.text; + const textStyle = isHovered + ? [prBadgeStyles.text, prBadgeStyles.textHovered] + : prBadgeStyles.text; const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state); const presentation = getForgePresentation(normalizeForge(hint.forge)); @@ -398,8 +397,6 @@ const prBadgeStyles = StyleSheet.create((theme) => ({ }, })); -const prBadgeTextHoveredCombined = [prBadgeStyles.text, prBadgeStyles.textHovered]; - function StatusDotOverlay({ dotColorStyle, size, @@ -948,223 +945,6 @@ function NewWorkspaceGhostRow({ ); } -function useLongPressDragInteraction(input: { - drag: () => void; - menuController: ReturnType | null; -}) { - const didLongPressRef = useRef(false); - const dragArmedRef = useRef(false); - const dragActivatedRef = useRef(false); - const didStartDragRef = useRef(false); - const scrollIntentRef = useRef(false); - const menuOpenedRef = useRef(false); - const touchStartRef = useRef<{ x: number; y: number } | null>(null); - const touchCurrentRef = useRef<{ x: number; y: number } | null>(null); - const dragArmTimerRef = useRef | null>(null); - const contextMenuTimerRef = useRef | null>(null); - - const clearTimers = useCallback(() => { - if (dragArmTimerRef.current) { - clearTimeout(dragArmTimerRef.current); - dragArmTimerRef.current = null; - } - if (contextMenuTimerRef.current) { - clearTimeout(contextMenuTimerRef.current); - contextMenuTimerRef.current = null; - } - }, []); - - const openContextMenuAtStartPoint = useCallback(() => { - if (!input.menuController || !touchStartRef.current) { - return; - } - const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; - input.menuController.setAnchorRect({ - x: touchStartRef.current.x, - y: touchStartRef.current.y + statusBarHeight, - width: 0, - height: 0, - }); - input.menuController.setOpen(true); - menuOpenedRef.current = true; - didLongPressRef.current = true; - }, [input.menuController]); - - const handleLongPress = useCallback(() => { - // Manual timers own long-press behavior on mobile. - }, []); - - useEffect(() => { - return () => { - clearTimers(); - }; - }, [clearTimers]); - - const armTimers = useCallback(() => { - clearTimers(); - - const DRAG_ARM_DELAY_MS = 180; - const DRAG_ARM_STATIONARY_SLOP_PX = 4; - const CONTEXT_MENU_DELAY_MS = 450; - const CONTEXT_MENU_STATIONARY_SLOP_PX = 6; - - dragArmTimerRef.current = setTimeout(() => { - if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) { - return; - } - const start = touchStartRef.current; - const current = touchCurrentRef.current ?? start; - if (!start || !current) { - return; - } - const dx = current.x - start.x; - const dy = current.y - start.y; - const distance = Math.sqrt(dx * dx + dy * dy); - if (distance > DRAG_ARM_STATIONARY_SLOP_PX) { - return; - } - dragArmedRef.current = true; - dragActivatedRef.current = true; - didLongPressRef.current = true; - void Haptics.selectionAsync().catch(() => {}); - input.drag(); - }, DRAG_ARM_DELAY_MS); - - if (!input.menuController || platformIsWeb) { - return; - } - - contextMenuTimerRef.current = setTimeout(() => { - if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) { - return; - } - const start = touchStartRef.current; - const current = touchCurrentRef.current ?? start; - if (!start || !current) { - return; - } - const dx = current.x - start.x; - const dy = current.y - start.y; - const distance = Math.sqrt(dx * dx + dy * dy); - if (distance > CONTEXT_MENU_STATIONARY_SLOP_PX) { - return; - } - void Haptics.selectionAsync().catch(() => {}); - openContextMenuAtStartPoint(); - }, CONTEXT_MENU_DELAY_MS); - }, [clearTimers, input, openContextMenuAtStartPoint]); - - const handleDragIntent = useCallback( - (_details: { dx: number; dy: number; distance: number }) => { - if (!dragActivatedRef.current) { - return; - } - didStartDragRef.current = true; - didLongPressRef.current = true; - clearTimers(); - void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {}); - }, - [clearTimers], - ); - - const handleScrollIntent = useCallback( - (_details: { dx: number; dy: number; distance: number }) => { - scrollIntentRef.current = true; - didLongPressRef.current = true; - clearTimers(); - }, - [clearTimers], - ); - - const handleSwipeIntent = useCallback( - (_details: { dx: number; dy: number; distance: number }) => { - didLongPressRef.current = true; - clearTimers(); - }, - [clearTimers], - ); - - const handlePressIn = useCallback( - (event: GestureResponderEvent) => { - didLongPressRef.current = false; - dragArmedRef.current = false; - dragActivatedRef.current = false; - didStartDragRef.current = false; - scrollIntentRef.current = false; - menuOpenedRef.current = false; - touchStartRef.current = { - x: event.nativeEvent.pageX, - y: event.nativeEvent.pageY, - }; - touchCurrentRef.current = { - x: event.nativeEvent.pageX, - y: event.nativeEvent.pageY, - }; - armTimers(); - }, - [armTimers], - ); - - const handleTouchMove = useCallback( - (event: GestureResponderEvent) => { - const start = touchStartRef.current; - if (!start || didStartDragRef.current || menuOpenedRef.current) { - return; - } - - const touch = event?.nativeEvent?.touches?.[0] ?? event?.nativeEvent; - const x = touch?.pageX; - const y = touch?.pageY; - if (typeof x !== "number" || typeof y !== "number") { - return; - } - - const current = { x, y }; - touchCurrentRef.current = current; - const dx = current.x - start.x; - const dy = current.y - start.y; - const distance = Math.sqrt(dx * dx + dy * dy); - const decision = decideLongPressMove({ - dragArmed: dragArmedRef.current, - didStartDrag: didStartDragRef.current, - startPoint: start, - currentPoint: current, - }); - - if (decision === "vertical_scroll") { - handleScrollIntent({ dx, dy, distance }); - return; - } - - if (decision === "horizontal_swipe" || decision === "cancel_long_press") { - handleSwipeIntent({ dx, dy, distance }); - return; - } - - if (decision === "start_drag") { - handleDragIntent({ dx, dy, distance }); - } - }, - [handleDragIntent, handleScrollIntent, handleSwipeIntent], - ); - - const handlePressOut = useCallback(() => { - clearTimers(); - dragArmedRef.current = false; - dragActivatedRef.current = false; - touchStartRef.current = null; - touchCurrentRef.current = null; - }, [clearTimers]); - - return { - didLongPressRef, - handleLongPress, - handlePressIn, - handleTouchMove, - handlePressOut, - }; -} - function ProjectHeaderRow({ project, displayName, diff --git a/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts b/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts index 80f15d926..747f2b6bc 100644 --- a/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts +++ b/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts @@ -59,6 +59,9 @@ export function useLongPressDragInteraction(input: { const armTimers = useCallback(() => { clearTimers(); + if (platformIsWeb) { + return; + } const DRAG_ARM_DELAY_MS = 180; const DRAG_ARM_STATIONARY_SLOP_PX = 4; @@ -87,7 +90,7 @@ export function useLongPressDragInteraction(input: { input.drag(); }, DRAG_ARM_DELAY_MS); - if (!input.menuController || platformIsWeb) { + if (!input.menuController) { return; } diff --git a/packages/app/src/components/terminal-pane-focus-claim.test.ts b/packages/app/src/components/terminal-pane-focus-claim.test.ts new file mode 100644 index 000000000..24edd4a57 --- /dev/null +++ b/packages/app/src/components/terminal-pane-focus-claim.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; +import { + EMPTY_FOCUS_CLAIM_STATE, + canRequestFocusClaim, + reconcileFocusClaim, + settleFocusClaim, + type FocusClaimState, +} from "./terminal-pane-focus-claim"; + +function request( + state: FocusClaimState, + input: { key: string | null; canRequest: boolean }, +): FocusClaimState { + return reconcileFocusClaim(state, input).state; +} + +describe("terminal pane focus claim", () => { + it("waits for both the client and renderer before requesting a claim", () => { + const withoutClient = canRequestFocusClaim({ + isWorkspaceFocused: true, + isAppActivelyVisible: true, + isClientReady: false, + isConnected: true, + isRendererReady: true, + }); + const withoutRenderer = canRequestFocusClaim({ + isWorkspaceFocused: true, + isAppActivelyVisible: true, + isClientReady: true, + isConnected: true, + isRendererReady: false, + }); + + expect([withoutClient, withoutRenderer]).toEqual([false, false]); + }); + + it("does not deliver a requested claim after the host disconnects", () => { + const disconnected = canRequestFocusClaim({ + isWorkspaceFocused: true, + isAppActivelyVisible: true, + isClientReady: true, + isConnected: false, + isRendererReady: true, + }); + + expect(disconnected).toBe(false); + }); + + it("claims once per continuous pane-focus period after send", () => { + const firstRequest = reconcileFocusClaim(EMPTY_FOCUS_CLAIM_STATE, { + key: "ws:term-1", + canRequest: true, + }); + const sent = settleFocusClaim(firstRequest.state, { + key: "ws:term-1", + sent: true, + }); + const repeated = reconcileFocusClaim(sent, { + key: "ws:term-1", + canRequest: true, + }); + + expect(firstRequest.shouldRequest).toBe(true); + expect(repeated).toEqual({ + state: { claimedKey: "ws:term-1", requestedKey: null }, + shouldRequest: false, + }); + }); + + it("defers until the claim can be requested", () => { + const unavailable = reconcileFocusClaim(EMPTY_FOCUS_CLAIM_STATE, { + key: "ws:term-1", + canRequest: false, + }); + const available = reconcileFocusClaim(unavailable.state, { + key: "ws:term-1", + canRequest: true, + }); + + expect(unavailable.shouldRequest).toBe(false); + expect(available.shouldRequest).toBe(true); + }); + + it("retries when readiness changes before the requested claim lands", () => { + const requested = request(EMPTY_FOCUS_CLAIM_STATE, { + key: "ws:term-1", + canRequest: true, + }); + const hiddenBeforeDelivery = request(requested, { + key: "ws:term-1", + canRequest: false, + }); + const visibleAgain = reconcileFocusClaim(hiddenBeforeDelivery, { + key: "ws:term-1", + canRequest: true, + }); + + expect(visibleAgain).toEqual({ + state: { claimedKey: null, requestedKey: "ws:term-1" }, + shouldRequest: true, + }); + }); + + it("retries a requested claim that was not sent", () => { + const requested = request(EMPTY_FOCUS_CLAIM_STATE, { + key: "ws:term-1", + canRequest: true, + }); + const dropped = settleFocusClaim(requested, { + key: "ws:term-1", + sent: false, + }); + const retry = reconcileFocusClaim(dropped, { + key: "ws:term-1", + canRequest: true, + }); + + expect(retry.shouldRequest).toBe(true); + }); + + it("re-arms after pane blur or terminal change", () => { + const requested = request(EMPTY_FOCUS_CLAIM_STATE, { + key: "ws:term-1", + canRequest: true, + }); + const sent = settleFocusClaim(requested, { key: "ws:term-1", sent: true }); + const blurred = request(sent, { key: null, canRequest: true }); + const refocused = reconcileFocusClaim(blurred, { key: "ws:term-1", canRequest: true }); + const changed = reconcileFocusClaim(sent, { key: "ws:term-2", canRequest: true }); + + expect(refocused.shouldRequest).toBe(true); + expect(changed.shouldRequest).toBe(true); + }); +}); diff --git a/packages/app/src/components/terminal-pane-focus-claim.ts b/packages/app/src/components/terminal-pane-focus-claim.ts new file mode 100644 index 000000000..21118ad19 --- /dev/null +++ b/packages/app/src/components/terminal-pane-focus-claim.ts @@ -0,0 +1,73 @@ +export interface FocusClaimState { + claimedKey: string | null; + requestedKey: string | null; +} + +export interface FocusClaimStep { + state: FocusClaimState; + shouldRequest: boolean; +} + +interface FocusClaimReadiness { + isWorkspaceFocused: boolean; + isAppActivelyVisible: boolean; + isClientReady: boolean; + isConnected: boolean; + isRendererReady: boolean; +} + +export const EMPTY_FOCUS_CLAIM_STATE: FocusClaimState = { + claimedKey: null, + requestedKey: null, +}; + +export function canRequestFocusClaim(input: FocusClaimReadiness): boolean { + return ( + input.isWorkspaceFocused && + input.isAppActivelyVisible && + input.isClientReady && + input.isConnected && + input.isRendererReady + ); +} + +export function reconcileFocusClaim( + state: FocusClaimState, + input: { key: string | null; canRequest: boolean }, +): FocusClaimStep { + if (input.key === null) { + return { state: EMPTY_FOCUS_CLAIM_STATE, shouldRequest: false }; + } + if (state.claimedKey === input.key) { + return { + state: { claimedKey: input.key, requestedKey: null }, + shouldRequest: false, + }; + } + if (!input.canRequest) { + return { + state: { claimedKey: state.claimedKey, requestedKey: null }, + shouldRequest: false, + }; + } + if (state.requestedKey === input.key) { + return { state, shouldRequest: false }; + } + return { + state: { claimedKey: state.claimedKey, requestedKey: input.key }, + shouldRequest: true, + }; +} + +export function settleFocusClaim( + state: FocusClaimState, + input: { key: string; sent: boolean }, +): FocusClaimState { + if (state.requestedKey !== input.key) { + return state; + } + return { + claimedKey: input.sent ? input.key : state.claimedKey, + requestedKey: null, + }; +} diff --git a/packages/app/src/components/terminal-pane-focus-latch.test.ts b/packages/app/src/components/terminal-pane-focus-latch.test.ts deleted file mode 100644 index 41dd60de2..000000000 --- a/packages/app/src/components/terminal-pane-focus-latch.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveFocusLatchStep, type FocusLatchStep } from "./terminal-pane-focus-latch"; - -/** - * Pins the ordering that caused "terminal stuck at 80x24": the latch must record that the action - * RAN, not that the effect ran. A pane that mounts before its workspace has focus (or while the - * app is hidden) must still fire once readiness arrives — burning the latch on the attempt - * permanently disarms the only resize claim a fresh terminal ever sends. - */ - -function run(steps: Array<{ key: string | null; canFire: boolean }>): FocusLatchStep[] { - const results: FocusLatchStep[] = []; - let latchedKey: string | null = null; - for (const step of steps) { - const result = resolveFocusLatchStep({ ...step, latchedKey }); - latchedKey = result.latchedKey; - results.push(result); - } - return results; -} - -describe("resolveFocusLatchStep", () => { - it("fires immediately when the pane is focused and ready at mount", () => { - const [step] = run([{ key: "ws:term-1", canFire: true }]); - expect(step).toEqual({ latchedKey: "ws:term-1", fire: true }); - }); - - it("defers instead of burning the latch when readiness arrives late", () => { - // The bug: pane focused while the workspace is not (or the app is hidden). The old code - // latched on the first pass and never fired again. - const steps = run([ - { key: "ws:term-1", canFire: false }, // mount: pane focused, workspace not focused yet - { key: "ws:term-1", canFire: true }, // workspace focus / visibility arrives - ]); - expect(steps[0]).toEqual({ latchedKey: null, fire: false }); - expect(steps[1]).toEqual({ latchedKey: "ws:term-1", fire: true }); - }); - - it("fires exactly once per continuous pane-focus period", () => { - const steps = run([ - { key: "ws:term-1", canFire: true }, - { key: "ws:term-1", canFire: true }, // unrelated re-render - { key: "ws:term-1", canFire: false }, // workspace blurs... - { key: "ws:term-1", canFire: true }, // ...and refocuses: not a new pane-focus period - ]); - expect(steps).toEqual([ - { latchedKey: "ws:term-1", fire: true }, - { latchedKey: "ws:term-1", fire: false }, - { latchedKey: "ws:term-1", fire: false }, - { latchedKey: "ws:term-1", fire: false }, - ]); - }); - - it("re-arms when the pane blurs and fires again on refocus", () => { - const steps = run([ - { key: "ws:term-1", canFire: true }, - { key: null, canFire: true }, // pane blurred / terminal gone - { key: "ws:term-1", canFire: true }, - ]); - expect(steps).toEqual([ - { latchedKey: "ws:term-1", fire: true }, - { latchedKey: null, fire: false }, - { latchedKey: "ws:term-1", fire: true }, - ]); - }); - - it("fires for a different terminal in the same pane", () => { - const steps = run([ - { key: "ws:term-1", canFire: true }, - { key: "ws:term-2", canFire: true }, - ]); - expect(steps).toEqual([ - { latchedKey: "ws:term-1", fire: true }, - { latchedKey: "ws:term-2", fire: true }, - ]); - }); - - it("stays deferred across repeated not-ready passes, then fires once", () => { - const steps = run([ - { key: "ws:term-1", canFire: false }, - { key: "ws:term-1", canFire: false }, - { key: "ws:term-1", canFire: true }, - { key: "ws:term-1", canFire: true }, - ]); - expect(steps).toEqual([ - { latchedKey: null, fire: false }, - { latchedKey: null, fire: false }, - { latchedKey: "ws:term-1", fire: true }, - { latchedKey: "ws:term-1", fire: false }, - ]); - }); -}); diff --git a/packages/app/src/components/terminal-pane-focus-latch.ts b/packages/app/src/components/terminal-pane-focus-latch.ts deleted file mode 100644 index 9d3d6196b..000000000 --- a/packages/app/src/components/terminal-pane-focus-latch.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Once-per-pane-focus latch for terminal-pane's focus/reflow effects. - * - * Each effect wants to fire exactly once per continuous pane-focus period per terminal, but only - * while the action can actually land (workspace focused, and for resize claims the app visible — - * `handleTerminalResize` drops claims emitted while hidden and nothing re-sends them). The latch - * must therefore record "the action ran", never "we tried": latching before the readiness gate - * permanently disarms the effect when a pane mounts before its workspace has focus, which is how - * a terminal ends up rendering full-size while its PTY stays at the daemon's 80x24 default. - */ -export interface FocusLatchStep { - latchedKey: string | null; - fire: boolean; -} - -export function resolveFocusLatchStep(input: { - /** Identity of the pane-focus period; null resets the latch (pane blurred, no terminal). */ - key: string | null; - latchedKey: string | null; - /** Whether the action can land right now; while false the latch stays untouched. */ - canFire: boolean; -}): FocusLatchStep { - if (input.key === null) { - return { latchedKey: null, fire: false }; - } - if (input.latchedKey === input.key) { - return { latchedKey: input.latchedKey, fire: false }; - } - if (!input.canFire) { - return { latchedKey: input.latchedKey, fire: false }; - } - return { latchedKey: input.key, fire: true }; -} diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 52955c206..f5ba2864f 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -13,7 +13,7 @@ import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-m import { useTranslation } from "react-i18next"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; -import { useAppVisible } from "@/hooks/use-app-visible"; +import { useAppActivelyVisible } from "@/hooks/use-app-visible"; import { useStableEvent } from "@/hooks/use-stable-event"; import { hasPendingTerminalModifiers, @@ -21,7 +21,12 @@ import { resolvePendingModifierDataInput, } from "@/utils/terminal-keys"; import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session"; -import { resolveFocusLatchStep } from "./terminal-pane-focus-latch"; +import { + EMPTY_FOCUS_CLAIM_STATE, + canRequestFocusClaim, + reconcileFocusClaim, + settleFocusClaim, +} from "./terminal-pane-focus-claim"; import { TerminalStreamController, type TerminalStreamControllerStatus, @@ -172,7 +177,7 @@ export function TerminalPane({ onOpenWorkspaceFile, }: TerminalPaneProps) { const { t } = useTranslation(); - const isAppVisible = useAppVisible(); + const isAppActivelyVisible = useAppActivelyVisible(); const { theme } = useUnistyles(); const { settings } = useAppSettings(); const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme]); @@ -222,7 +227,7 @@ export function TerminalPane({ const pendingTerminalInputRef = useRef([]); const keyboardRefitTimeoutsRef = useRef>>([]); const lastAutoFocusKeyRef = useRef(null); - const lastPaneFocusResizeKeyRef = useRef(null); + const paneFocusResizeClaimRef = useRef(EMPTY_FOCUS_CLAIM_STATE); const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId }); useEffect(() => { @@ -261,40 +266,48 @@ export function TerminalPane({ ); useEffect(() => { - // Latch only when the focus actually ran: latching before the workspace-focus gate would - // permanently disarm this effect for panes that mount before their workspace has focus. - const step = resolveFocusLatchStep({ - key: isMobile || !isPaneFocused || !terminalId ? null : `${scopeKey}:${terminalId}`, - latchedKey: lastAutoFocusKeyRef.current, - canFire: isWorkspaceFocused, - }); - lastAutoFocusKeyRef.current = step.latchedKey; - if (step.fire) { + if (isMobile || !isPaneFocused || !terminalId) { + lastAutoFocusKeyRef.current = null; + return; + } + if (!isWorkspaceFocused) { + return; + } + const focusKey = `${scopeKey}:${terminalId}`; + if (lastAutoFocusKeyRef.current !== focusKey) { + lastAutoFocusKeyRef.current = focusKey; requestTerminalFocus(); } }, [isMobile, isPaneFocused, isWorkspaceFocused, requestTerminalFocus, scopeKey, terminalId]); useEffect(() => { - // This reflow produces the one resize claim a freshly mounted terminal ever sends; defer it - // (without burning the latch) until the claim can land — handleTerminalResize drops claims - // while the workspace is unfocused or the app is hidden, and nothing re-sends a dropped one. - const step = resolveFocusLatchStep({ - key: !isPaneFocused || !terminalId ? null : `${scopeKey}:${terminalId}`, - latchedKey: lastPaneFocusResizeKeyRef.current, - canFire: isWorkspaceFocused && isAppVisible, + const canRequest = canRequestFocusClaim({ + isWorkspaceFocused, + isAppActivelyVisible, + isClientReady: client !== null, + isConnected, + isRendererReady: rendererReadyStreamKey === terminalStreamKey, }); - lastPaneFocusResizeKeyRef.current = step.latchedKey; - if (step.fire) { + const step = reconcileFocusClaim(paneFocusResizeClaimRef.current, { + key: !isPaneFocused || !terminalId ? null : `${scopeKey}:${terminalId}`, + canRequest, + }); + paneFocusResizeClaimRef.current = step.state; + if (step.shouldRequest) { lastSentTerminalSizeRef.current = null; requestTerminalReflow(); } }, [ - isAppVisible, + client, + isAppActivelyVisible, + isConnected, isPaneFocused, isWorkspaceFocused, + rendererReadyStreamKey, requestTerminalReflow, scopeKey, terminalId, + terminalStreamKey, ]); const handleTerminalFocus = useCallback(() => { @@ -635,23 +648,40 @@ export function TerminalPane({ const normalizedCols = Math.floor(cols); const nextSize = { rows: normalizedRows, cols: normalizedCols }; measuredTerminalSizeRef.current = nextSize; - if (!input.shouldClaim || !client || !terminalId || !isWorkspaceFocused || !isAppVisible) { + if (!input.shouldClaim) { return; } - const previousSent = lastSentTerminalSizeRef.current; - if ( - previousSent && - previousSent.rows === normalizedRows && - previousSent.cols === normalizedCols - ) { - return; - } - lastSentTerminalSizeRef.current = nextSize; - client.sendTerminalInput(terminalId, { - type: "resize", - rows: normalizedRows, - cols: normalizedCols, + let sent = false; + const canSend = canRequestFocusClaim({ + isWorkspaceFocused, + isAppActivelyVisible, + isClientReady: client !== null, + isConnected, + isRendererReady: true, }); + if (client && terminalId && canSend) { + const previousSent = lastSentTerminalSizeRef.current; + if ( + !previousSent || + previousSent.rows !== normalizedRows || + previousSent.cols !== normalizedCols + ) { + lastSentTerminalSizeRef.current = nextSize; + client.sendTerminalInput(terminalId, { + type: "resize", + rows: normalizedRows, + cols: normalizedCols, + }); + } + sent = true; + } + const requestedKey = paneFocusResizeClaimRef.current.requestedKey; + if (requestedKey) { + paneFocusResizeClaimRef.current = settleFocusClaim(paneFocusResizeClaimRef.current, { + key: requestedKey, + sent, + }); + } }, ); diff --git a/packages/app/src/components/toast-host.tsx b/packages/app/src/components/toast-host.tsx index 082a03787..e275f0bb0 100644 --- a/packages/app/src/components/toast-host.tsx +++ b/packages/app/src/components/toast-host.tsx @@ -6,7 +6,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { useIsCompactFormFactor } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; -import { AlertTriangle, CheckCircle2 } from "lucide-react-native"; +import { AlertTriangle, CheckCircle2, Info } from "lucide-react-native"; import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root"; import { HEADER_INNER_HEIGHT, @@ -14,7 +14,7 @@ import { HEADER_TOP_PADDING_MOBILE, } from "@/constants/layout"; -export type ToastVariant = "default" | "success" | "error"; +export type ToastVariant = "default" | "info" | "success" | "warning" | "error"; export interface ToastShowOptions { icon?: ReactNode; @@ -230,7 +230,9 @@ export function ToastViewport({ const toastAnimatedStyle = useMemo( () => [ styles.toast, + toastVariant === "info" ? styles.toastInfo : null, toastVariant === "success" ? styles.toastSuccess : null, + toastVariant === "warning" ? styles.toastWarning : null, toastVariant === "error" ? styles.toastError : null, { marginTop: topOffset, @@ -250,8 +252,12 @@ export function ToastViewport({ } let defaultIcon: ReactNode = null; - if (toast.variant === "success") { + if (toast.variant === "info") { + defaultIcon = ; + } else if (toast.variant === "success") { defaultIcon = ; + } else if (toast.variant === "warning") { + defaultIcon = ; } else if (toast.variant === "error") { defaultIcon = ; } @@ -313,6 +319,12 @@ const styles = StyleSheet.create((theme) => ({ toastSuccess: { borderColor: theme.colors.border, }, + toastInfo: { + borderColor: theme.colors.palette.blue[300], + }, + toastWarning: { + borderColor: theme.colors.palette.amber[500], + }, toastError: { borderColor: theme.colors.destructive, }, diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index b3e6cd463..72ea48cef 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -490,12 +490,19 @@ function FetchDetailSection({ url, result, ds }: FetchDetailProps) { ); } -function PlainTextSection({ text }: { text: string }) { +function ScrollablePlainTextSection({ text, ds }: { text: string; ds: DetailStyles }) { return ( - - - {text} - + + + + {text} + + ); } @@ -576,13 +583,7 @@ function buildUnknownSections(detail: UnknownDetail, ds: DetailStyles, t: TFunct typeof detail.input === "string" && detail.output === null ? detail.input : null; if (plainInputText !== null) { - return [ - - - {plainInputText} - - , - ]; + return []; } const sectionsFromTopLevel = [ @@ -698,7 +699,7 @@ function buildDetailSections( } if (detail.type === "plain_text") { if (!detail.text) return []; - return []; + return []; } if (detail.type === "unknown") { return buildUnknownSections(detail, ds, t); @@ -710,7 +711,7 @@ function ErrorSection({ errorText, ds }: { errorText: string; ds: DetailStyles } const { t } = useTranslation(); return ( - {t("toolCallDetails.error")} + {t("toolCallDetails.error")} - + {errorText} @@ -807,10 +812,6 @@ const styles = StyleSheet.create((theme) => { flex: 1, minHeight: 0, }, - plainTextSection: { - gap: theme.spacing[2], - padding: theme.spacing[3], - }, plainText: { fontFamily: theme.fontFamily.ui, fontSize: theme.fontSize.base, @@ -951,6 +952,3 @@ const styles = StyleSheet.create((theme) => { }, }; }); - -const SECTION_TITLE_ERROR_STYLE = [styles.sectionTitle, styles.errorText]; -const SCROLL_TEXT_ERROR_STYLE = [styles.scrollText, styles.errorText]; diff --git a/packages/app/src/components/tree-primitives.tsx b/packages/app/src/components/tree-primitives.tsx index 26c71f2c2..11949c140 100644 --- a/packages/app/src/components/tree-primitives.tsx +++ b/packages/app/src/components/tree-primitives.tsx @@ -51,7 +51,7 @@ export function TreeIndentGuides({ depth }: { depth: number }) { /** Rotating disclosure chevron for a directory row (points right; rotates down when expanded). */ export function TreeChevron({ expanded }: { expanded: boolean }) { return ( - + ); @@ -76,7 +76,3 @@ const styles = StyleSheet.create((theme: Theme) => ({ transform: [{ rotate: "90deg" }], }, })); - -// Stable module-level style ref so TreeChevron passes a constant array, not one created -// per render — satisfies react-perf (no inline-array prop) without a per-render useMemo. -const CHEVRON_EXPANDED_STYLE = [styles.chevron, styles.chevronExpanded]; diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 279954404..81c8f0ef0 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -199,7 +199,7 @@ export function SearchInput({ ({ justifyContent: "flex-end", }, })); - -const SEARCH_INPUT_STYLE = [styles.searchInput, IS_WEB && { outlineStyle: "none" }]; diff --git a/packages/app/src/components/workspace-hover-card.tsx b/packages/app/src/components/workspace-hover-card.tsx index ca2930c25..37cd114fc 100644 --- a/packages/app/src/components/workspace-hover-card.tsx +++ b/packages/app/src/components/workspace-hover-card.tsx @@ -418,7 +418,8 @@ function CopyableInfoRow({ if (copied || isHovered) { iconUniProps = foregroundColorMapping; } - const textStyle = copied || isHovered ? cardInfoTextHoveredCombined : styles.cardInfoText; + const textStyle = + copied || isHovered ? [styles.cardInfoText, styles.cardInfoTextHovered] : styles.cardInfoText; return ( ({ color: theme.colors.statusSuccess, }, })); - -const checksSummaryLabelHoveredCombined = [ - styles.checksSummaryLabel, - styles.checksSummaryLabelHovered, -]; - -const cardInfoTextHoveredCombined = [styles.cardInfoText, styles.cardInfoTextHovered]; diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index 6e4024e39..a7ca3f624 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -62,6 +62,9 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import { useToast } from "@/contexts/toast-context"; import { toErrorMessage } from "@/utils/error-messages"; import { showProviderNoticeToast } from "@/utils/provider-notice-toast"; +import { useCommandCenterActions } from "@/command-center/provider"; +import { buildModelChoiceContributions } from "@/command-center/model-contributions"; +import { getCommandCenterProviderIcon } from "@/command-center/provider-icon"; interface AgentControlOption { id: string; @@ -130,6 +133,7 @@ export interface DraftAgentControlsProps { interface AgentControlsProps { agentId: string; serverId: string; + isPaneFocused: boolean; onDropdownClose?: () => void; isCompactLayout?: boolean; } @@ -1362,9 +1366,11 @@ function ThinkingComboboxOption({ export const AgentControls = memo(function AgentControls({ agentId, serverId, + isPaneFocused, onDropdownClose, isCompactLayout, }: AgentControlsProps) { + const { t } = useTranslation(); const { preferences, updatePreferences } = useFormPreferences(); const agent = useSessionStore( useShallow((state) => selectAgentControlsSlice(state, serverId, agentId)), @@ -1436,29 +1442,49 @@ export const AgentControls = memo(function AgentControls({ const activeModelId = modelSelection.activeModelId; const handleSelectModel = useCallback( - (modelId: string) => { + async (modelId: string) => { if (!client || !agentProvider) { return; } - void updatePreferences((current) => - mergeProviderPreferences({ - preferences: current, - provider: agentProvider, - updates: { - model: modelId, - }, - }), - ).catch((error) => { - console.warn("[AgentControls] persist model preference failed", error); - }); - void client.setAgentModel(agentId, modelId).catch((error) => { - console.warn("[AgentControls] setAgentModel failed", error); + try { + await client.setAgentModel(agentId, modelId); + await updatePreferences((current) => + mergeProviderPreferences({ + preferences: current, + provider: agentProvider, + updates: { + model: modelId, + }, + }), + ); + } catch (error) { + console.warn("[AgentControls] setAgentModel or persist preference failed", error); toast.error(toErrorMessage(error)); - }); + } }, [agentId, agentProvider, client, toast, updatePreferences], ); + const commandCenterModelActions = useMemo( + () => + buildModelChoiceContributions({ + serverId, + providers: agentModelSelectorProviders, + selectedProvider: agentProvider ?? null, + selectedModelId: activeModelId, + groupLabel: t("shell.commandCenter.modelGroupLabel"), + searchKeywords: t("shell.commandCenter.modelSearchKeywords"), + getIcon: getCommandCenterProviderIcon, + select: (_provider, modelId) => handleSelectModel(modelId), + }), + [activeModelId, agentModelSelectorProviders, agentProvider, handleSelectModel, serverId, t], + ); + useCommandCenterActions({ + sourceId: `agent:${serverId}:${agentId}`, + enabled: isPaneFocused && Boolean(client), + actions: commandCenterModelActions, + }); + const handleToggleFavoriteModel = useCallback( (provider: string, modelId: string) => { void updatePreferences((current) => diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 4398448cc..3ac357fb8 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -24,6 +24,9 @@ import { useCreateFlowStore } from "@/stores/create-flow-store"; import type { Agent } from "@/stores/session-store"; import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; +import { useCommandCenterActions } from "@/command-center/provider"; +import { buildModelChoiceContributions } from "@/command-center/model-contributions"; +import { getCommandCenterProviderIcon } from "@/command-center/provider-icon"; import { encodeImages } from "@/utils/encode-images"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus"; @@ -375,6 +378,28 @@ export function WorkspaceDraftAgentTab({ if (!composerState) { throw new Error("Workspace draft composer state is required"); } + + const draftModelActions = useMemo( + () => + buildModelChoiceContributions({ + serverId, + providers: composerState.modelSelectorProviders, + selectedProvider: composerState.selectedProvider, + selectedModelId: composerState.effectiveModelId || null, + groupLabel: t("shell.commandCenter.modelGroupLabel"), + searchKeywords: t("shell.commandCenter.modelSearchKeywords"), + getIcon: getCommandCenterProviderIcon, + select: composerState.setProviderAndModelFromUser, + }), + [ + composerState.effectiveModelId, + composerState.modelSelectorProviders, + composerState.selectedProvider, + composerState.setProviderAndModelFromUser, + serverId, + t, + ], + ); const clearDraftInput = draftInput.clear; const setDraftText = draftInput.setText; const setDraftAttachments = draftInput.setAttachments; @@ -517,6 +542,11 @@ export function WorkspaceDraftAgentTab({ onCreated(result); }, }); + useCommandCenterActions({ + sourceId: `draft:${serverId}:${tabId}`, + enabled: isPaneFocused && !isSubmitting, + actions: draftModelActions, + }); const isReadyForPendingAutoSubmit = Boolean( pendingAutoSubmit && diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index d214d794b..c2e3e5dbf 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -258,10 +258,11 @@ interface RenderLeftContentArgs { serverId: string; focusInput: () => void; isCompactLayout: boolean; + isPaneFocused: boolean; } function renderLeftContent(args: RenderLeftContentArgs): ReactElement { - const { agentControls, agentId, serverId, focusInput, isCompactLayout } = args; + const { agentControls, agentId, serverId, focusInput, isCompactLayout, isPaneFocused } = args; if (resolveAgentControlsMode(agentControls) === "draft" && agentControls) { return ; } @@ -269,6 +270,7 @@ function renderLeftContent(args: RenderLeftContentArgs): ReactElement { @@ -568,7 +570,7 @@ function QueuedMessageRow({ @@ -1845,8 +1847,9 @@ export function Composer({ serverId, focusInput, isCompactLayout, + isPaneFocused, }), - [agentControls, agentId, focusInput, isCompactLayout, serverId], + [agentControls, agentId, focusInput, isCompactLayout, isPaneFocused, serverId], ); const handleAttachButtonRef = useCallback((node: View | null) => { @@ -2234,8 +2237,6 @@ const styles = StyleSheet.create((theme: Theme) => ({ }, })) as unknown as Record; -const QUEUE_SEND_BUTTON_STYLE = [styles.queueActionButton, styles.queueSendButton]; - const ThemedPencil = withUnistyles(Pencil); const ThemedArrowUp = withUnistyles(ArrowUp); const ThemedGitPullRequest = withUnistyles(GitPullRequest); diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 6d57619ea..8a58c9e98 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -25,7 +25,6 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native"; -import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated"; import { useDictation } from "@/hooks/use-dictation"; import { DictationOverlay } from "@/components/dictation-controls"; import { RealtimeVoiceOverlay } from "@/components/realtime-voice-overlay"; @@ -68,6 +67,7 @@ import { } from "./labels"; import { computeCanStartDictation, + resolveComposerSurfacePresentation, runAlternateSendAction, runDefaultSendAction, stopRealtimeVoice, @@ -1290,7 +1290,6 @@ export const MessageInput = forwardRef( getNativeElement: () => (isWeb ? getTextInputNativeElement(textInputRef.current) : null), })); const inputHeightRef = useRef(MIN_INPUT_HEIGHT); - const overlayTransition = useSharedValue(0); const sendAfterTranscriptRef = useRef(false); const valueRef = useRef(value); const serverInfo = useSessionStore( @@ -1407,6 +1406,7 @@ export const MessageInput = forwardRef( ); const showRealtimeOverlay = isRealtimeVoiceForCurrentAgent; const showOverlay = showDictationOverlay || showRealtimeOverlay; + const surfacePresentation = resolveComposerSurfacePresentation(showOverlay); useEffect(() => { if (isDictating || isDictationProcessing) { @@ -1427,22 +1427,6 @@ export const MessageInput = forwardRef( [canStartDictation, dictationUnavailableMessage, startDictation, toast], ); - // Animate overlay - useEffect(() => { - overlayTransition.value = withTiming(showOverlay ? 1 : 0, { - duration: 200, - }); - }, [overlayTransition, showOverlay]); - - const overlayAnimatedStyle = useAnimatedStyle(() => ({ - opacity: overlayTransition.value, - pointerEvents: overlayTransition.value > 0.5 ? "auto" : "none", - })); - - const inputAnimatedStyle = useAnimatedStyle(() => ({ - opacity: 1 - overlayTransition.value, - })); - const handleVoicePress = useCallback( () => handleVoicePressImpl({ @@ -1760,8 +1744,12 @@ export const MessageInput = forwardRef( }, [handleStopRealtimeVoice]); const inputWrapperCombinedStyle = useMemo( - () => [styles.inputWrapper, inputWrapperStyle, inputAnimatedStyle], - [inputWrapperStyle, inputAnimatedStyle], + () => [ + styles.inputWrapper, + inputWrapperStyle, + { opacity: surfacePresentation.input.opacity }, + ], + [inputWrapperStyle, surfacePresentation.input.opacity], ); const textInputStyle = useMemo( () => [styles.textInput, computeTextInputHeightStyle(inputHeight, maxInputHeight)], @@ -1772,8 +1760,8 @@ export const MessageInput = forwardRef( [isSendButtonDisabled], ); const overlayContainerStyle = useMemo( - () => [styles.overlayContainer, overlayAnimatedStyle], - [overlayAnimatedStyle], + () => [styles.overlayContainer, { opacity: surfacePresentation.overlay.opacity }], + [surfacePresentation.overlay.opacity], ); const renderAttachButtonIcon = useCallback( @@ -1802,7 +1790,11 @@ export const MessageInput = forwardRef( return ( {/* Regular input */} - + {attachmentSlot} {/* Text input */} @@ -1881,9 +1873,12 @@ export const MessageInput = forwardRef( /> - + - + ( onDiscardFailedRecording={handleDiscardFailedRecording} onRealtimeVoiceStop={handleRealtimeVoiceStop} /> - + ); }, diff --git a/packages/app/src/composer/input/state.test.ts b/packages/app/src/composer/input/state.test.ts index 8a11806ed..5e8420e77 100644 --- a/packages/app/src/composer/input/state.test.ts +++ b/packages/app/src/composer/input/state.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { computeCanStartDictation, + resolveComposerSurfacePresentation, runAlternateSendAction, runDefaultSendAction, stopRealtimeVoice, @@ -9,6 +10,22 @@ import { const connected = { isConnected: true } as never; const disconnected = { isConnected: false } as never; +describe("composer surface presentation", () => { + it("shows only the input when no voice overlay is active", () => { + expect(resolveComposerSurfacePresentation(false)).toEqual({ + input: { opacity: 1, pointerEvents: "auto" }, + overlay: { opacity: 0, pointerEvents: "none" }, + }); + }); + + it("shows only the voice overlay while voice UI is active", () => { + expect(resolveComposerSurfacePresentation(true)).toEqual({ + input: { opacity: 0, pointerEvents: "none" }, + overlay: { opacity: 1, pointerEvents: "auto" }, + }); + }); +}); + describe("computeCanStartDictation", () => { it("returns false when socket is disconnected", () => { expect( diff --git a/packages/app/src/composer/input/state.ts b/packages/app/src/composer/input/state.ts index e2f714623..716433f4b 100644 --- a/packages/app/src/composer/input/state.ts +++ b/packages/app/src/composer/input/state.ts @@ -3,6 +3,32 @@ import type { MessagePayload } from "@/composer/types"; export type SendBehavior = "interrupt" | "queue"; +interface ComposerSurfaceState { + opacity: 0 | 1; + pointerEvents: "auto" | "none"; +} + +export interface ComposerSurfacePresentation { + input: ComposerSurfaceState; + overlay: ComposerSurfaceState; +} + +const INPUT_PRESENTATION: ComposerSurfacePresentation = { + input: { opacity: 1, pointerEvents: "auto" }, + overlay: { opacity: 0, pointerEvents: "none" }, +}; + +const OVERLAY_PRESENTATION: ComposerSurfacePresentation = { + input: { opacity: 0, pointerEvents: "none" }, + overlay: { opacity: 1, pointerEvents: "auto" }, +}; + +export function resolveComposerSurfacePresentation( + showOverlay: boolean, +): ComposerSurfacePresentation { + return showOverlay ? OVERLAY_PRESENTATION : INPUT_PRESENTATION; +} + interface StopRealtimeVoiceContext { voice: { stopVoice: () => Promise } | null | undefined; isRealtimeVoiceForCurrentAgent: boolean; diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index b03702a81..ed2f62fb0 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -4,9 +4,12 @@ import { AppState } from "react-native"; import { useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { useClientActivity } from "@/hooks/use-client-activity"; +import { useAppVisible } from "@/hooks/use-app-visible"; import { usePushTokenRegistration } from "@/hooks/use-push-token-registration"; -import { clearArchiveAgentPending } from "@/hooks/use-archive-agent"; -import { refreshAgentInitializationTimeout } from "@/hooks/use-agent-initialization"; +import { + createSetAgentInitializing, + refreshAgentInitializationTimeout, +} from "@/hooks/use-agent-initialization"; import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { generateMessageId, type StreamItem } from "@/types/stream"; import { @@ -16,12 +19,12 @@ import { type TimelineReducerSideEffect, } from "@/timeline/session-stream-reducers"; import { useCreateFlowStore } from "@/stores/create-flow-store"; +import { isTimelineCatchUpComplete } from "@/timeline/timeline-sync-plan"; import { - isTimelineCatchUpComplete, - planResumeTimelineSync, - planTimelineCatchUpAfter, - planTimelineCatchUpFollowUp, -} from "@/timeline/timeline-sync-plan"; + createViewedTimelineSync, + type TimelineDeliveryMode, + type ViewedTimelineSync, +} from "@/timeline/viewed-timeline-sync"; import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { parseServerInfoStatusPayload } from "@getpaseo/protocol/messages"; import { @@ -29,7 +32,6 @@ import { type AgentAttentionNotificationPayload, type NotificationPermissionRequest, } from "@getpaseo/protocol/agent-attention-notification"; -import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { AgentSessionConfig } from "@getpaseo/protocol/agent-types"; import type { GitSetupOptions } from "@getpaseo/protocol/messages"; @@ -37,51 +39,27 @@ import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types"; import { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context"; import type { AudioPlaybackSource } from "@/voice/audio-engine-types"; -import { - useSessionStore, - type Agent, - type MessageEntry, - type SessionState, - type WorkspaceDescriptor, - type EmptyProjectDescriptor, - normalizeWorkspaceDescriptor, - normalizeEmptyProjectDescriptor, -} from "@/stores/session-store"; -import { useDraftStore } from "@/stores/draft-store"; +import { useSessionStore, type MessageEntry, type SessionState } from "@/stores/session-store"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { sendOsNotification } from "@/utils/os-notifications"; -import { getIsAppActivelyVisible } from "@/utils/app-visibility"; +import { getIsAppActivelyVisible, getIsAppVisible } from "@/utils/app-visibility"; import { getInitKey, getInitDeferred, + createInitDeferred, resolveInitDeferred, rejectInitDeferred, } from "@/utils/agent-initialization"; import { encodeImages } from "@/utils/encode-images"; -import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; -import { resolveProjectPlacement } from "@/utils/project-placement"; -import { buildDraftStoreKey } from "@/stores/draft-keys"; +import { derivePendingPermissionKey } from "@/utils/agent-snapshots"; import type { AttachmentMetadata } from "@/attachments/types"; -import { - resolveComposerAttachmentSubmitFormat, - splitComposerAttachmentsForSubmit, -} from "@/composer/attachments/submit"; -import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking"; import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts"; -import { - clearWorkspaceArchivePending, - shouldSuppressWorkspaceForLocalArchive, -} from "@/contexts/session-workspace-upserts"; -import { isNative } from "@/constants/platform"; import { useToast } from "@/contexts/toast-context"; import { toErrorMessage } from "@/utils/error-messages"; import { showProviderNoticeToast } from "@/utils/provider-notice-toast"; import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache"; -import { - applyLegacyDaemonWorkspaceOwnership, - backfillLegacyDaemonWorkspaceDirectoryIfEmpty, -} from "@/workspace/legacy-daemon-workspaces"; import { useProviderSubagentStore } from "@/subagents/provider-store"; +import { revalidateSessionAfterResume } from "@/contexts/session-resume-revalidation"; // Re-export types from session-store and draft-store for backward compatibility export type { DraftInput } from "@/stores/draft-store"; @@ -96,25 +74,6 @@ export type { AgentFileExplorerState, } from "@/stores/session-store"; -const HISTORY_STALE_AFTER_MS = 60_000; -const AUTHORITATIVE_REVALIDATION_DEBOUNCE_MS = 300; - -function hasAgentUsageChanged( - incomingUsage: Agent["lastUsage"] | undefined, - currentUsage: Agent["lastUsage"] | undefined, -): boolean { - const keys: Array> = [ - "inputTokens", - "outputTokens", - "cachedInputTokens", - "totalCostUsd", - "contextWindowMaxTokens", - "contextWindowUsedTokens", - ]; - - return keys.some((key) => incomingUsage?.[key] !== currentUsage?.[key]); -} - type AudioOutputPayload = Extract["payload"]; interface BufferedAudioChunk { @@ -124,54 +83,9 @@ interface BufferedAudioChunk { id: string; } -interface WorkspaceHydrationSnapshot { - workspaces: Map; - emptyProjects: Map; -} - -async function fetchWorkspaceHydrationSnapshot(input: { - client: DaemonClient; - serverId: string; - subscribe: boolean; - isCancelled?: () => boolean; -}): Promise { - const workspaces = new Map(); - const emptyProjects = new Map(); - let cursor: string | null = null; - let includeSubscribe = input.subscribe; - - while (true) { - const payload = await input.client.fetchWorkspaces({ - sort: [{ key: "activity_at", direction: "desc" }], - ...(includeSubscribe ? { subscribe: {} } : {}), - page: cursor ? { limit: 200, cursor } : { limit: 200 }, - }); - if (input.isCancelled?.()) { - return null; - } - - for (const entry of payload.entries) { - const workspace = normalizeWorkspaceDescriptor(entry); - if (shouldSuppressWorkspaceForLocalArchive({ serverId: input.serverId, workspace })) { - continue; - } - workspaces.set(workspace.id, workspace); - } - - // Project parents with no active workspaces only ride on the first page. - for (const project of payload.emptyProjects ?? []) { - const descriptor = normalizeEmptyProjectDescriptor(project); - emptyProjects.set(descriptor.projectId, descriptor); - } - - if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) { - break; - } - cursor = payload.pageInfo.nextCursor; - includeSubscribe = false; - } - - return { workspaces, emptyProjects }; +// COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12. +function getTimelineDeliveryMode(selectiveAgentTimeline?: boolean): TimelineDeliveryMode { + return selectiveAgentTimeline ? "selective" : "legacy"; } function decodeBase64Chunk(base64: string): Uint8Array { @@ -240,54 +154,11 @@ const getLatestPermissionRequest = ( return null; }; -type AgentUpdatePayload = Extract["payload"]; type WorkspaceSetupProgressPayload = Extract< SessionOutboundMessage, { type: "workspace_setup_progress" } >["payload"]; -const getAgentIdFromUpdate = (update: AgentUpdatePayload): string => - update.kind === "remove" ? update.agentId : update.agent.id; - -// --------------------------------------------------------------------------- -// Module-level pending agent updates buffer (scoped by serverId) -// --------------------------------------------------------------------------- -const pendingAgentUpdates = new Map(); - -function pendingKey(serverId: string, agentId: string): string { - return `${serverId}:${agentId}`; -} - -export function bufferPendingAgentUpdate( - serverId: string, - agentId: string, - update: AgentUpdatePayload, -): void { - pendingAgentUpdates.set(pendingKey(serverId, agentId), update); -} - -export function flushPendingAgentUpdate( - serverId: string, - agentId: string, -): AgentUpdatePayload | undefined { - const key = pendingKey(serverId, agentId); - const update = pendingAgentUpdates.get(key); - pendingAgentUpdates.delete(key); - return update; -} - -export function deletePendingAgentUpdate(serverId: string, agentId: string): void { - pendingAgentUpdates.delete(pendingKey(serverId, agentId)); -} - -export function clearPendingAgentUpdates(serverId: string): void { - for (const key of Array.from(pendingAgentUpdates.keys())) { - if (key.startsWith(`${serverId}:`)) { - pendingAgentUpdates.delete(key); - } - } -} - type SessionStoreActions = ReturnType; type SetInitializingAgents = SessionStoreActions["setInitializingAgents"]; type SetAgentStreamTail = SessionStoreActions["setAgentStreamTail"]; @@ -401,20 +272,12 @@ function applyTimelineStreamPatches(input: { function executeTimelineSideEffects(input: { sideEffects: TimelineReducerSideEffect[]; agentId: string; - serverId: string; - requestCanonicalCatchUp: (agentId: string, cursor: { epoch: string; endSeq: number }) => void; - applyAgentUpdatePayload: (payload: AgentUpdatePayload) => void; + recoverTimelineGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void; }): void { - const { sideEffects, agentId, serverId, requestCanonicalCatchUp, applyAgentUpdatePayload } = - input; + const { sideEffects, agentId, recoverTimelineGap } = input; for (const effect of sideEffects) { if (effect.type === "catch_up") { - requestCanonicalCatchUp(agentId, effect.cursor); - } else if (effect.type === "flush_pending_updates") { - const deferredUpdate = flushPendingAgentUpdate(serverId, agentId); - if (deferredUpdate) { - applyAgentUpdatePayload(deferredUpdate); - } + recoverTimelineGap(agentId, effect.cursor); } } } @@ -446,13 +309,16 @@ function finalizeTimelineApplication(input: { if (shouldMarkAuthoritativeHistoryApplied) { setAgentAuthoritativeHistoryApplied(serverId, agentId, true); useCreateFlowStore.getState().clearByAgent({ serverId, agentId }); + markAgentHistorySynchronized(serverId, agentId); + const session = useSessionStore.getState().sessions[serverId]; + const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId); + if (agent && agent.status !== "running") { + getHostRuntimeStore().drainQueuedAgentMessage(serverId, agentId); + } } if (result.initResolution === "resolve") { resolveInitDeferred(initKey); } - if (result.clearInitializing) { - markAgentHistorySynchronized(serverId, agentId); - } } function applyToolResultToMessages( @@ -540,24 +406,14 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const setAgentAuthoritativeHistoryApplied = useSessionStore( (state) => state.setAgentAuthoritativeHistoryApplied, ); - const setHasHydratedAgents = useSessionStore((state) => state.setHasHydratedAgents); - const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); const setAgents = useSessionStore((state) => state.setAgents); const setWorkspaces = useSessionStore((state) => state.setWorkspaces); - const setEmptyProjects = useSessionStore((state) => state.setEmptyProjects); - const addEmptyProject = useSessionStore((state) => state.addEmptyProject); - const removeEmptyProject = useSessionStore((state) => state.removeEmptyProject); - const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); - const removeWorkspace = useSessionStore((state) => state.removeWorkspace); - const setAgentLastActivity = useSessionStore((state) => state.setAgentLastActivity); const flushAgentLastActivity = useSessionStore((state) => state.flushAgentLastActivity); const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions); - const clearDraftInput = useDraftStore((state) => state.clearDraftInput); - const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages); const updateSessionClient = useSessionStore((state) => state.updateSessionClient); const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo); + const setViewedTimelineSync = useSessionStore((state) => state.setViewedTimelineSync); const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress); - const removeWorkspaceSetup = useWorkspaceSetupStore((state) => state.removeWorkspace); const clearWorkspaceSetupServer = useWorkspaceSetupStore((state) => state.clearServer); // Track focused agent for heartbeat @@ -567,28 +423,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const focusedTerminalId = useSessionStore( (state) => state.sessions[serverId]?.focusedTerminalId ?? null, ); - const sessionAgents = useSessionStore((state) => state.sessions[serverId]?.agents); - - const previousAgentStatusRef = useRef>(new Map()); - const sendAgentMessageRef = useRef< - | (( - agentId: string, - message: string, - images?: AttachmentMetadata[], - attachments?: AgentAttachment[], - ) => Promise) - | null - >(null); const _sessionStateTimeoutRef = useRef | null>(null); const attentionNotifiedRef = useRef>(new Map()); const appStateRef = useRef(AppState.currentState); - const revalidationTimerRef = useRef | null>(null); - const revalidationInFlightRef = useRef | null>(null); - const revalidationQueuedRef = useRef(false); - const timelineCatchUpInFlightRef = useRef>(new Set()); - const wasConnectedRef = useRef(isConnected); + const viewedTimelineSyncRef = useRef(null); const audioOutputBuffersRef = useRef>(new Map()); const activeAudioGroupsRef = useRef>(new Set()); + const isAppVisible = useAppVisible(); useEffect(() => { const subscription = AppState.addEventListener("change", (nextState) => { @@ -601,290 +442,31 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider }, []); useEffect(() => { - previousAgentStatusRef.current = reconcilePreviousAgentStatuses( - previousAgentStatusRef.current, - sessionAgents, - ); - }, [sessionAgents]); + viewedTimelineSyncRef.current?.setActive(isAppVisible); + }, [isAppVisible]); - const hydrateWorkspaces = useCallback( - async (options?: { subscribe?: boolean; isCancelled?: () => boolean }) => { - if (!client || !isConnected) { - return; - } - - const snapshot = await fetchWorkspaceHydrationSnapshot({ - client, - serverId, - subscribe: options?.subscribe ?? false, - isCancelled: options?.isCancelled, - }); - if (!snapshot || options?.isCancelled?.()) { - return; - } - - const didBackfillLegacy = await backfillLegacyDaemonWorkspaceDirectoryIfEmpty({ - client, - serverId, - workspaces: snapshot.workspaces, - emptyProjects: snapshot.emptyProjects, - isCancelled: options?.isCancelled, - }); - if (didBackfillLegacy) { - return; - } - - setWorkspaces(serverId, snapshot.workspaces); - setEmptyProjects(serverId, snapshot.emptyProjects.values()); - setHasHydratedWorkspaces(serverId, true); - }, - [client, isConnected, serverId, setEmptyProjects, setHasHydratedWorkspaces, setWorkspaces], - ); - - const applyAuthoritativeAgentSnapshot = useCallback( - (agent: Agent) => { - setAgents(serverId, (prev) => { - const current = prev.get(agent.id); - if (current && agent.updatedAt.getTime() < current.updatedAt.getTime()) { - const hasUsageUpdate = hasAgentUsageChanged(agent.lastUsage, current.lastUsage); - if (hasUsageUpdate) { - const next = new Map(prev); - next.set(agent.id, { - ...current, - lastUsage: agent.lastUsage, - }); - return next; - } - return prev; - } - const next = new Map(prev); - next.set(agent.id, agent); - return next; - }); - - if (agent.archivedAt) { - clearArchiveAgentPending({ - queryClient, - serverId, - agentId: agent.id, - }); - } - - setAgentLastActivity(agent.id, agent.lastActivityAt); - - setPendingPermissions(serverId, (prev) => { - const existingKeysForAgent: string[] = []; - for (const [key, pending] of prev.entries()) { - if (pending.agentId === agent.id) { - existingKeysForAgent.push(key); - } - } - - const nextEntries = agent.pendingPermissions.map((request) => ({ - key: derivePendingPermissionKey(agent.id, request), - agentId: agent.id, - request, - })); - - let changed = existingKeysForAgent.length !== nextEntries.length; - if (!changed) { - const existingKeySet = new Set(existingKeysForAgent); - for (const entry of nextEntries) { - const existing = prev.get(entry.key); - if (!existingKeySet.has(entry.key) || !existing) { - changed = true; - break; - } - - const currentRequest = existing.request; - if ( - currentRequest.id !== entry.request.id || - currentRequest.kind !== entry.request.kind || - currentRequest.name !== entry.request.name || - currentRequest.title !== entry.request.title || - currentRequest.description !== entry.request.description - ) { - changed = true; - break; - } - } - } - - if (!changed) { - return prev; - } - - const next = new Map(prev); - for (const key of existingKeysForAgent) { - next.delete(key); - } - for (const entry of nextEntries) { - next.set(entry.key, entry); - } - return next; - }); - - const prevStatus = previousAgentStatusRef.current.get(agent.id); - if (prevStatus === "running" && agent.status !== "running") { - const session = useSessionStore.getState().sessions[serverId]; - const queue = session?.queuedMessages.get(agent.id); - if (queue && queue.length > 0) { - const [next, ...rest] = queue; - if (sendAgentMessageRef.current) { - const supportsForgeSearch = - useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch === - true; - const wirePayload = splitComposerAttachmentsForSubmit(next.attachments, { - format: resolveComposerAttachmentSubmitFormat({ - supportsForgeAttachments: supportsForgeSearch, - }), - }); - void sendAgentMessageRef.current( - agent.id, - next.text, - wirePayload.images, - wirePayload.attachments, - ); - } - setQueuedMessages(serverId, (prev) => { - const updated = new Map(prev); - updated.set(agent.id, rest); - return updated; - }); - } - } - - previousAgentStatusRef.current.set(agent.id, agent.status); - }, - [ - queryClient, - serverId, - setAgentLastActivity, - setAgents, - setPendingPermissions, - setQueuedMessages, - ], - ); - - const runAuthoritativeRevalidation = useCallback(async () => { - await Promise.all([ - getHostRuntimeStore().refreshAgentDirectory({ serverId }), - hydrateWorkspaces(), - ]); - }, [hydrateWorkspaces, serverId]); - - const flushAuthoritativeRevalidation = useCallback(() => { - if (!client || !isConnected) { - return; - } - if (revalidationInFlightRef.current) { - revalidationQueuedRef.current = true; - return; - } - - const run = runAuthoritativeRevalidation() - .catch((error) => { - console.error("[Session] authoritative revalidation failed", { - serverId, - error, - }); - }) - .finally(() => { - if (revalidationInFlightRef.current === run) { - revalidationInFlightRef.current = null; - } - if (!revalidationQueuedRef.current) { - return; - } - revalidationQueuedRef.current = false; - if (revalidationTimerRef.current) { - clearTimeout(revalidationTimerRef.current); - } - revalidationTimerRef.current = setTimeout(() => { - revalidationTimerRef.current = null; - flushAuthoritativeRevalidation(); - }, AUTHORITATIVE_REVALIDATION_DEBOUNCE_MS); - }); - - revalidationInFlightRef.current = run; - }, [client, isConnected, runAuthoritativeRevalidation, serverId]); - - const scheduleAuthoritativeRevalidation = useCallback(() => { - if (!client || !isConnected) { - return; - } - - revalidationQueuedRef.current = true; - if (revalidationTimerRef.current) { - return; - } - revalidationTimerRef.current = setTimeout(() => { - revalidationTimerRef.current = null; - if (!revalidationQueuedRef.current) { - return; - } - revalidationQueuedRef.current = false; - flushAuthoritativeRevalidation(); - }, AUTHORITATIVE_REVALIDATION_DEBOUNCE_MS); - }, [client, flushAuthoritativeRevalidation, isConnected]); - - const requestCanonicalCatchUp = useCallback( + const recoverTimelineGap = useCallback( (agentId: string, cursor: { epoch: string; endSeq: number }) => { - const request = planTimelineCatchUpAfter({ epoch: cursor.epoch, seq: cursor.endSeq }); - const key = `${agentId}:${request.cursor.epoch}:${request.cursor.seq}`; - const inFlight = timelineCatchUpInFlightRef.current; - if (inFlight.has(key)) { - return; - } - inFlight.add(key); - void client - .fetchAgentTimeline(agentId, request) - .catch((error) => { - console.warn("[Session] failed to fetch canonical catch-up timeline", agentId, error); - }) - .finally(() => { - inFlight.delete(key); - }); + viewedTimelineSyncRef.current?.recoverGap(agentId, cursor); }, - [client], + [], ); const handleAppResumed = useCallback( (awayMs: number) => { - scheduleAuthoritativeRevalidation(); - - if (isNative) { - const session = useSessionStore.getState().sessions[serverId]; - const agentId = session?.focusedAgentId; - if (agentId) { - const plan = planResumeTimelineSync({ - cursor: session?.agentTimelineCursor.get(agentId), - }); - if (plan.direction === "after") { - requestCanonicalCatchUp(agentId, { - epoch: plan.cursor.epoch, - endSeq: plan.cursor.seq, - }); - } else { - void client.fetchAgentTimeline(agentId, plan).catch((error) => { - console.warn("[Session] failed to fetch tail timeline on resume", agentId, error); - }); - } - } - } - - if (awayMs < HISTORY_STALE_AFTER_MS) { - return; - } - bumpHistorySyncGeneration(serverId); + void revalidateSessionAfterResume({ + awayMs, + serverId, + bumpHistorySyncGeneration, + refreshDirectories: () => getHostRuntimeStore().refreshDirectories(serverId), + }).catch((error) => { + console.error("[SessionProvider] resume revalidation failed", { + serverId, + error: toErrorMessage(error), + }); + }); }, - [ - bumpHistorySyncGeneration, - client, - requestCanonicalCatchUp, - scheduleAuthoritativeRevalidation, - serverId, - ], + [bumpHistorySyncGeneration, serverId], ); // Client activity tracking (heartbeat, push token registration) @@ -944,11 +526,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider // Initialize session in store useEffect(() => { - initializeSession(serverId, client); + const generation = getHostRuntimeStore().getSnapshot(serverId)?.clientGeneration ?? 0; + initializeSession(serverId, client, generation); }, [serverId, client, initializeSession]); useEffect(() => { - updateSessionClient(serverId, client); + const generation = getHostRuntimeStore().getSnapshot(serverId)?.clientGeneration ?? 0; + updateSessionClient(serverId, client, generation); }, [serverId, client, updateSessionClient]); useEffect(() => { @@ -1024,112 +608,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider useEffect(() => { if (!isConnected) { flushAgentLastActivity(); - clearPendingAgentUpdates(serverId); setInitializingAgents(serverId, new Map()); } }, [flushAgentLastActivity, serverId, isConnected, setInitializingAgents]); - useEffect(() => { - if (!client || !isConnected) { - return () => {}; - } - - let cancelled = false; - void (async () => { - try { - await hydrateWorkspaces({ - subscribe: true, - isCancelled: () => cancelled, - }); - } catch (error) { - console.error("[Session] Failed to hydrate workspaces:", error); - } - })(); - - return () => { - cancelled = true; - }; - }, [client, hydrateWorkspaces, isConnected]); - - const applyAgentUpdatePayload = useCallback( - (update: AgentUpdatePayload) => { - if (update.kind === "remove") { - const agentId = update.agentId; - previousAgentStatusRef.current.delete(agentId); - deletePendingAgentUpdate(serverId, agentId); - clearArchiveAgentPending({ queryClient, serverId, agentId }); - - setAgents(serverId, (prev) => { - if (!prev.has(agentId)) { - return prev; - } - const next = new Map(prev); - next.delete(agentId); - return next; - }); - - setPendingPermissions(serverId, (prev) => { - if (prev.size === 0) { - return prev; - } - let changed = false; - const next = new Map(prev); - for (const [key, pending] of Array.from(next.entries())) { - if (pending.agentId === agentId) { - next.delete(key); - changed = true; - } - } - return changed ? next : prev; - }); - - setQueuedMessages(serverId, (prev) => { - if (!prev.has(agentId)) { - return prev; - } - const next = new Map(prev); - next.delete(agentId); - return next; - }); - - setAgentTimelineCursor(serverId, (prev) => { - if (!prev.has(agentId)) { - return prev; - } - const next = new Map(prev); - next.delete(agentId); - return next; - }); - setAgentAuthoritativeHistoryApplied(serverId, agentId, false); - return; - } - - const normalized = normalizeAgentSnapshot(update.agent, serverId); - const agent = applyLegacyDaemonWorkspaceOwnership({ - serverId, - agent: { - ...normalized, - projectPlacement: resolveProjectPlacement({ - projectPlacement: update.project, - cwd: normalized.cwd, - }), - }, - }); - - applyAuthoritativeAgentSnapshot(agent); - }, - [ - applyAuthoritativeAgentSnapshot, - queryClient, - serverId, - setAgentAuthoritativeHistoryApplied, - setAgents, - setAgentTimelineCursor, - setPendingPermissions, - setQueuedMessages, - ], - ); - const applyWorkspaceSetupProgress = useCallback( (payload: WorkspaceSetupProgressPayload) => { upsertWorkspaceSetupProgress({ serverId, payload }); @@ -1172,19 +654,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider return next; }); - if (payload.agent) { - const normalized = normalizeAgentSnapshot(payload.agent, serverId); - applyAuthoritativeAgentSnapshot( - applyLegacyDaemonWorkspaceOwnership({ - serverId, - agent: { - ...normalized, - projectPlacement: session?.agents.get(agentId)?.projectPlacement ?? null, - }, - }), - ); - } - // Call pure reducer const result = processTimelineResponse({ payload, @@ -1222,34 +691,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider executeTimelineSideEffects({ sideEffects: result.sideEffects, agentId, - serverId, - requestCanonicalCatchUp, - applyAgentUpdatePayload, + recoverTimelineGap, }); - const followUp = planTimelineCatchUpFollowUp({ - direction: payload.direction, - hasNewer: payload.hasNewer, - endCursor: payload.endCursor, - error: payload.error, - }); - if (followUp?.direction === "after") { - refreshAgentInitializationTimeout({ - key: initKey, - agentId, - setAgentInitializing: (id, initializing) => { - if (initializing) { - return; - } - clearAgentInitializingFlag(setInitializingAgents, serverId, id); - }, - }); - requestCanonicalCatchUp(agentId, { - epoch: followUp.cursor.epoch, - endSeq: followUp.cursor.seq, - }); - } - finalizeTimelineApplication({ result, agentId, @@ -1262,11 +706,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider }); }, [ - applyAuthoritativeAgentSnapshot, - applyAgentUpdatePayload, clearAgentStreamHead, markAgentHistorySynchronized, - requestCanonicalCatchUp, + recoverTimelineGap, serverId, setAgentAuthoritativeHistoryApplied, setAgentStreamHead, @@ -1278,54 +720,82 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider ); useEffect(() => { - if (isConnected) { - return; - } - clearPendingAgentUpdates(serverId); - }, [isConnected, serverId]); + const setAgentInitializing = createSetAgentInitializing(serverId, setInitializingAgents); + const initialDeliveryMode = getTimelineDeliveryMode( + client.getLastServerInfoMessage()?.features?.selectiveAgentTimeline, + ); + const sync = createViewedTimelineSync({ + initialDeliveryMode, + setSubscription: (agentIds) => client.setAgentTimelineSubscription(agentIds), + readCursor: (agentId) => + useSessionStore.getState().sessions[serverId]?.agentTimelineCursor.get(agentId), + hasAuthoritativeHistory: (agentId) => + useSessionStore + .getState() + .sessions[serverId]?.agentAuthoritativeHistoryApplied.get(agentId) === true, + fetchPage: async (agentId, request) => { + const session = useSessionStore.getState().sessions[serverId]; + const initKey = getInitKey(serverId, agentId); + const shouldInitialize = session?.agentAuthoritativeHistoryApplied.get(agentId) !== true; + if (shouldInitialize) { + if (!getInitDeferred(initKey)) { + const deferred = createInitDeferred(initKey, request.direction ?? "tail"); + void deferred.promise.catch(() => undefined); + } + refreshAgentInitializationTimeout({ + key: initKey, + agentId, + setAgentInitializing, + }); + setAgentInitializing(agentId, true); + } + try { + const page = await getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, request); + if (shouldInitialize && getInitDeferred(initKey)) { + refreshAgentInitializationTimeout({ key: initKey, agentId, setAgentInitializing }); + } + return page; + } catch (error) { + if (shouldInitialize) { + setAgentInitializing(agentId, false); + rejectInitDeferred(initKey, error instanceof Error ? error : new Error(String(error))); + } + throw error; + } + }, + reportError: (error) => { + console.warn("[Session] viewed timeline synchronization failed", { serverId, error }); + }, + schedule: (task, delayMs) => { + const timeout = setTimeout(task, delayMs); + return () => clearTimeout(timeout); + }, + }); + viewedTimelineSyncRef.current = sync; + setViewedTimelineSync(serverId, sync); + sync.setActive(getIsAppVisible(appStateRef.current)); - useEffect(() => { - const wasConnected = wasConnectedRef.current; - wasConnectedRef.current = isConnected; - if (!wasConnected && isConnected) { - scheduleAuthoritativeRevalidation(); - } - }, [isConnected, scheduleAuthoritativeRevalidation]); - - useEffect(() => { return () => { - if (revalidationTimerRef.current) { - clearTimeout(revalidationTimerRef.current); + if (viewedTimelineSyncRef.current === sync) { + viewedTimelineSyncRef.current = null; } + setViewedTimelineSync(serverId, null); + sync.dispose(); }; - }, []); + }, [client, serverId, setInitializingAgents, setViewedTimelineSync]); + + useEffect(() => { + viewedTimelineSyncRef.current?.setConnected(isConnected); + }, [isConnected]); // Daemon message handlers - directly update Zustand store useEffect(() => { - const unsubAgentUpdate = client.on("agent_update", (message) => { - if (message.type !== "agent_update") return; - const update = message.payload; - const agentId = getAgentIdFromUpdate(update); - const initKey = getInitKey(serverId, agentId); - const session = useSessionStore.getState().sessions[serverId]; - const isSyncingHistory = - session?.initializingAgents.get(agentId) === true && Boolean(getInitDeferred(initKey)); - - if (isSyncingHistory) { - bufferPendingAgentUpdate(serverId, agentId, update); - return; - } - - deletePendingAgentUpdate(serverId, agentId); - applyAgentUpdatePayload(update); - }); - const agentStreamReducerQueue = createSessionAgentStreamReducerQueue({ serverId, setAgentStreamState, setAgentTimelineCursor, setAgents, - requestCanonicalCatchUp, + recoverTimelineGap, }); const unsubAgentStream = client.on("agent_stream", (message) => { @@ -1342,18 +812,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider voiceRuntime?.onTurnEvent(serverId, agentId, event.type); } - // Attention notification stays in React (not extractable to pure reducer) - if (event.type === "attention_required") { - if (event.shouldNotify) { - notifyAgentAttention({ - agentId, - reason: event.reason, - timestamp: event.timestamp, - notification: event.notification, - }); - } - } - agentStreamReducerQueue.enqueue(agentId, { event: streamEvent, seq, @@ -1366,6 +824,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider // on status changes, which is sufficient for sorting and display purposes. }); + const unsubAgentAttention = client.onAgentAttentionRequired((notification) => { + if (notification.shouldNotify) { + notifyAgentAttention(notification); + } + }); + const unsubAgentTimeline = client.on("fetch_agent_timeline_response", (message) => { if (message.type !== "fetch_agent_timeline_response") return; agentStreamReducerQueue.flushAgent(message.payload.agentId); @@ -1377,30 +841,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider useProviderSubagentStore.getState().applyUpdate(serverId, message.payload); }); - const unsubWorkspaceUpdate = client.on("workspace_update", (message) => { - if (message.type !== "workspace_update") return; - if (message.payload.kind === "remove") { - clearWorkspaceArchivePending({ - serverId, - workspaceId: message.payload.id, - }); - removeWorkspaceSetup({ serverId, workspaceId: message.payload.id }); - removeWorkspace(serverId, message.payload.id); - if (message.payload.emptyProject) { - addEmptyProject(serverId, normalizeEmptyProjectDescriptor(message.payload.emptyProject)); - } - if (message.payload.removedProjectId) { - removeEmptyProject(serverId, message.payload.removedProjectId); - } - return; - } - const workspace = normalizeWorkspaceDescriptor(message.payload.workspace); - if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) { - return; - } - mergeWorkspaces(serverId, [workspace]); - }); - const unsubScriptStatusUpdate = client.on("script_status_update", (message) => { if (message.type !== "script_status_update") return; setWorkspaces(serverId, (prev) => patchWorkspaceScripts(prev, message.payload)); @@ -1431,6 +871,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider if (message.type !== "status") return; const serverInfo = parseServerInfoStatusPayload(message.payload); if (serverInfo) { + viewedTimelineSyncRef.current?.setDeliveryMode( + getTimelineDeliveryMode(serverInfo.features?.selectiveAgentTimeline), + ); updateSessionServerInfo(serverId, { serverId: serverInfo.serverId, hostname: serverInfo.hostname, @@ -1662,102 +1105,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider voiceRuntime?.onServerSpeechStateChanged(serverId, message.payload.isSpeaking); }); - const unsubAgentDeleted = client.on("agent_deleted", (message) => { - if (message.type !== "agent_deleted") { - return; - } - const { agentId } = message.payload; - deletePendingAgentUpdate(serverId, agentId); - clearArchiveAgentPending({ queryClient, serverId, agentId }); - - setAgents(serverId, (prev) => { - if (!prev.has(agentId)) { - return prev; - } - const next = new Map(prev); - next.delete(agentId); - return next; - }); - - // Remove from agentLastActivity slice (top-level) - useSessionStore.setState((state) => { - if (!state.agentLastActivity.has(agentId)) { - return state; - } - const nextActivity = new Map(state.agentLastActivity); - nextActivity.delete(agentId); - return { - ...state, - agentLastActivity: nextActivity, - }; - }); - - setAgentStreamTail(serverId, (prev) => { - if (!prev.has(agentId)) { - return prev; - } - const next = new Map(prev); - next.delete(agentId); - return next; - }); - clearAgentStreamHead(serverId, agentId); - setAgentTimelineCursor(serverId, (prev) => { - if (!prev.has(agentId)) { - return prev; - } - const next = new Map(prev); - next.delete(agentId); - return next; - }); - - // Remove draft input - clearDraftInput({ - draftKey: buildDraftStoreKey({ serverId, agentId }), - }); - - setPendingPermissions(serverId, (prev) => { - let changed = false; - const next = new Map(prev); - for (const [key, pending] of prev.entries()) { - if (pending.agentId === agentId) { - next.delete(key); - changed = true; - } - } - return changed ? next : prev; - }); - - setInitializingAgents(serverId, (prev) => { - if (!prev.has(agentId)) { - return prev; - } - const next = new Map(prev); - next.delete(agentId); - return next; - }); - }); - - const unsubAgentArchived = client.on("agent_archived", (message) => { - if (message.type !== "agent_archived") { - return; - } - const { agentId, archivedAt } = message.payload; - clearArchiveAgentPending({ queryClient, serverId, agentId }); - - setAgents(serverId, (prev) => { - const existing = prev.get(agentId); - if (!existing) { - return prev; - } - const next = new Map(prev); - next.set(agentId, { - ...existing, - archivedAt: new Date(archivedAt), - }); - return next; - }); - }); - const unsubTerminalAttention = client.on("terminal_attention_required", (message) => { if (message.type !== "terminal_attention_required") { return; @@ -1780,11 +1127,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider }); return () => { - unsubAgentUpdate(); unsubAgentStream(); unsubAgentTimeline(); unsubProviderSubagentUpdate(); - unsubWorkspaceUpdate(); + unsubAgentAttention(); unsubScriptStatusUpdate(); unsubCheckoutStatusUpdate(); unsubWorkspaceSetupProgress(); @@ -1797,8 +1143,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider unsubChunk(); unsubTranscription(); unsubVoiceInputState(); - unsubAgentDeleted(); - unsubAgentArchived(); unsubTerminalAttention(); agentStreamReducerQueue.dispose({ flush: true }); }; @@ -1817,18 +1161,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider setInitializingAgents, setAgents, setWorkspaces, - mergeWorkspaces, - removeWorkspace, - removeWorkspaceSetup, - addEmptyProject, - removeEmptyProject, - setAgentLastActivity, setPendingPermissions, - setHasHydratedAgents, - clearDraftInput, notifyAgentAttention, - requestCanonicalCatchUp, - applyAgentUpdatePayload, + recoverTimelineGap, applyWorkspaceSetupProgress, applyTimelineResponse, updateSessionServerInfo, @@ -1837,67 +1172,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider voiceAudioEngine, ]); - const sendAgentMessage = useCallback( - async ( - agentId: string, - message: string, - images?: AttachmentMetadata[], - attachments?: AgentAttachment[], - ) => { - const messageId = generateMessageId(); - const userMessage: StreamItem = { - kind: "user_message", - id: messageId, - text: message, - timestamp: new Date(), - optimistic: true, - ...(images && images.length > 0 ? { images } : {}), - ...(attachments && attachments.length > 0 ? { attachments } : {}), - }; - - // Append to head if streaming (keeps the user message with the current - // turn so late text_deltas still find the existing assistant_message). - // Otherwise append to tail. - const currentHead = useSessionStore - .getState() - .sessions[serverId]?.agentStreamHead?.get(agentId); - if (currentHead && currentHead.length > 0) { - setAgentStreamHead(serverId, (prev) => { - const head = prev.get(agentId) || []; - const updated = new Map(prev); - updated.set(agentId, [...head, userMessage]); - return updated; - }); - } else { - setAgentStreamTail(serverId, (prev) => { - const currentStream = prev.get(agentId) || []; - const updated = new Map(prev); - updated.set(agentId, [...currentStream, userMessage]); - return updated; - }); - } - - const imagesData = await encodeImages(images); - if (!client) { - console.warn("[Session] sendAgentMessage skipped: daemon unavailable"); - return; - } - void client - .sendAgentMessage(agentId, message, { - messageId, - ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), - ...(attachments && attachments.length > 0 ? { attachments } : {}), - }) - .catch((error) => { - console.error("[Session] Failed to send agent message:", error); - }); - }, - [serverId, client, setAgentStreamTail, setAgentStreamHead], - ); - - // Keep the ref updated so the agent_update handler can call it - sendAgentMessageRef.current = sendAgentMessage; - const _cancelAgentRun = useCallback( (agentId: string) => { if (!client) { diff --git a/packages/app/src/contexts/session-resume-revalidation.test.ts b/packages/app/src/contexts/session-resume-revalidation.test.ts new file mode 100644 index 000000000..ebc90b68a --- /dev/null +++ b/packages/app/src/contexts/session-resume-revalidation.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { + revalidateSessionAfterResume, + SESSION_STALE_AFTER_MS, +} from "./session-resume-revalidation"; + +describe("session resume revalidation", () => { + it("refreshes both directories and timeline history after a stale resume", async () => { + const calls: string[] = []; + + const revalidated = await revalidateSessionAfterResume({ + awayMs: SESSION_STALE_AFTER_MS, + serverId: "server", + bumpHistorySyncGeneration: (serverId) => calls.push(`history:${serverId}`), + refreshDirectories: async () => calls.push("directories"), + }); + + expect(revalidated).toBe(true); + expect(calls).toEqual(["history:server", "directories"]); + }); + + it("does nothing after a brief background interval", async () => { + const calls: string[] = []; + + const revalidated = await revalidateSessionAfterResume({ + awayMs: SESSION_STALE_AFTER_MS - 1, + serverId: "server", + bumpHistorySyncGeneration: () => calls.push("history"), + refreshDirectories: async () => calls.push("directories"), + }); + + expect(revalidated).toBe(false); + expect(calls).toEqual([]); + }); +}); diff --git a/packages/app/src/contexts/session-resume-revalidation.ts b/packages/app/src/contexts/session-resume-revalidation.ts new file mode 100644 index 000000000..8360316f5 --- /dev/null +++ b/packages/app/src/contexts/session-resume-revalidation.ts @@ -0,0 +1,16 @@ +export const SESSION_STALE_AFTER_MS = 60_000; + +export async function revalidateSessionAfterResume(input: { + awayMs: number; + serverId: string; + bumpHistorySyncGeneration: (serverId: string) => void; + refreshDirectories: () => Promise; +}): Promise { + if (input.awayMs < SESSION_STALE_AFTER_MS) { + return false; + } + + input.bumpHistorySyncGeneration(input.serverId); + await input.refreshDirectories(); + return true; +} diff --git a/packages/app/src/contexts/workspace-directory-reconciliation.test.ts b/packages/app/src/contexts/workspace-directory-reconciliation.test.ts new file mode 100644 index 000000000..851f784c1 --- /dev/null +++ b/packages/app/src/contexts/workspace-directory-reconciliation.test.ts @@ -0,0 +1,63 @@ +import { expect, it } from "vitest"; +import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages"; +import { normalizeWorkspaceDescriptor } from "@/stores/session-store"; +import { + clearWorkspaceArchivePending, + markWorkspaceArchivePending, +} from "./session-workspace-upserts"; +import { reconcileWorkspaceDirectory } from "./workspace-directory-reconciliation"; + +const SERVER_ID = "workspace-directory-reconciliation"; + +function workspace(id: string, title: string): WorkspaceDescriptorPayload { + return { + id, + projectId: "project", + projectDisplayName: "Project", + projectRootPath: "/repo", + workspaceDirectory: `/repo/${id}`, + projectKind: "git", + workspaceKind: "worktree", + name: id, + title, + status: "done", + activityAt: null, + statusEnteredAt: null, + archivingAt: null, + diffStat: null, + scripts: [], + }; +} + +it("keeps workspace upserts and removals received during later pages", () => { + const result = reconcileWorkspaceDirectory({ + serverId: SERVER_ID, + snapshot: new Map([ + ["updated", normalizeWorkspaceDescriptor(workspace("updated", "snapshot"))], + ["removed", normalizeWorkspaceDescriptor(workspace("removed", "snapshot"))], + ]), + deltas: [ + { kind: "upsert", workspace: workspace("updated", "live") }, + { kind: "remove", id: "removed" }, + ], + }); + + expect(Array.from(result.values()).map(({ id, title }) => [id, title])).toEqual([ + ["updated", "live"], + ]); +}); + +it("does not restore a locally archiving workspace from a buffered upsert", () => { + markWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "archiving" }); + try { + const result = reconcileWorkspaceDirectory({ + serverId: SERVER_ID, + snapshot: new Map(), + deltas: [{ kind: "upsert", workspace: workspace("archiving", "live") }], + }); + + expect(result.has("archiving")).toBe(false); + } finally { + clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "archiving" }); + } +}); diff --git a/packages/app/src/contexts/workspace-directory-reconciliation.ts b/packages/app/src/contexts/workspace-directory-reconciliation.ts new file mode 100644 index 000000000..4be9cdd9f --- /dev/null +++ b/packages/app/src/contexts/workspace-directory-reconciliation.ts @@ -0,0 +1,26 @@ +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store"; +import { shouldSuppressWorkspaceForLocalArchive } from "./session-workspace-upserts"; + +type WorkspaceDelta = Extract["payload"]; + +export function reconcileWorkspaceDirectory(input: { + serverId: string; + snapshot: ReadonlyMap; + deltas: readonly WorkspaceDelta[]; +}): Map { + const workspaces = new Map(input.snapshot); + for (const delta of input.deltas) { + if (delta.kind === "remove") { + workspaces.delete(delta.id); + } else { + const workspace = normalizeWorkspaceDescriptor(delta.workspace); + if (shouldSuppressWorkspaceForLocalArchive({ serverId: input.serverId, workspace })) { + workspaces.delete(workspace.id); + } else { + workspaces.set(workspace.id, workspace); + } + } + } + return workspaces; +} diff --git a/packages/app/src/data/acp-provider-catalog.ts b/packages/app/src/data/acp-provider-catalog.ts index b4bec91d2..a49a63589 100644 --- a/packages/app/src/data/acp-provider-catalog.ts +++ b/packages/app/src/data/acp-provider-catalog.ts @@ -59,10 +59,10 @@ const CATALOG_DATA = [ title: "Cline", description: "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more", - version: "3.0.39", + version: "3.0.44", iconId: "cline", installLink: "https://cline.bot/cli", - command: ["npx", "-y", "cline@3.0.39", "--acp"], + command: ["npx", "-y", "cline@3.0.44", "--acp"], }, { id: "codebuddy-code", @@ -122,10 +122,10 @@ const CATALOG_DATA = [ id: "deepagents", title: "DeepAgents", description: "Batteries-included AI coding and general purpose agent powered by LangChain.", - version: "0.1.17", + version: "0.1.19", iconId: "deepagents", installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview", - command: ["npx", "-y", "deepagents-acp@0.1.17"], + command: ["npx", "-y", "deepagents-acp@0.1.19"], }, { id: "devin", @@ -140,29 +140,29 @@ const CATALOG_DATA = [ id: "dimcode", title: "DimCode", description: "A coding agent that puts leading models at your command.", - version: "0.2.27", + version: "0.2.31", iconId: "dimcode", installLink: "https://dimcode.dev/docs/acp.html", - command: ["npx", "-y", "dimcode@0.2.27", "acp"], + command: ["npx", "-y", "dimcode@0.2.31", "acp"], }, { id: "dirac", title: "Dirac", description: "Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.", - version: "0.4.15", + version: "0.4.17", iconId: "dirac", installLink: "https://dirac.run", - command: ["npx", "-y", "dirac-cli@0.4.15", "--acp"], + command: ["npx", "-y", "dirac-cli@0.4.17", "--acp"], }, { id: "factory-droid", title: "Factory Droid", description: "Factory Droid - AI coding agent powered by Factory AI", - version: "0.170.0", + version: "0.174.0", iconId: "factory-droid", installLink: "https://factory.ai/product/cli", - command: ["npx", "-y", "droid@0.170.0", "exec", "--output-format", "acp-daemon"], + command: ["npx", "-y", "droid@0.174.0", "exec", "--output-format", "acp-daemon"], env: { DROID_DISABLE_AUTO_UPDATE: "true", FACTORY_DROID_AUTO_UPDATE_ENABLED: "false", @@ -173,19 +173,19 @@ const CATALOG_DATA = [ id: "fast-agent", title: "fast-agent", description: "Code and build agents with comprehensive multi-provider support", - version: "0.9.7", + version: "0.9.14", iconId: "fast-agent", installLink: "https://fast-agent.ai/acp/", - command: ["uvx", "--from", "fast-agent-acp==0.9.7", "fast-agent-acp", "-x"], + command: ["uvx", "--from", "fast-agent-acp==0.9.14", "fast-agent-acp", "-x"], }, { id: "gemini", title: "Gemini CLI", description: "Google's official CLI for Gemini", - version: "0.50.0", + version: "0.51.0", iconId: "gemini", installLink: "https://geminicli.com", - command: ["npx", "-y", "@google/gemini-cli@0.50.0", "--acp"], + command: ["npx", "-y", "@google/gemini-cli@0.51.0", "--acp"], }, { id: "glm-acp-agent", @@ -284,10 +284,10 @@ const CATALOG_DATA = [ id: "nova", title: "Nova", description: "Nova by Compass AI - a fully-fledged software engineer at your command", - version: "1.1.25", + version: "1.1.27", iconId: "nova", installLink: "https://www.compassap.ai/portfolio/nova.html", - command: ["npx", "-y", "@compass-ai/nova@1.1.25", "acp"], + command: ["npx", "-y", "@compass-ai/nova@1.1.27", "acp"], }, { id: "poolside", @@ -302,19 +302,19 @@ const CATALOG_DATA = [ id: "qoder", title: "Qoder CLI", description: "AI coding assistant with agentic capabilities", - version: "1.0.43", + version: "1.0.48", iconId: "qoder", installLink: "https://qoder.com", - command: ["npx", "-y", "@qoder-ai/qodercli@1.0.43", "--acp"], + command: ["npx", "-y", "@qoder-ai/qodercli@1.0.48", "--acp"], }, { id: "qwen-code", title: "Qwen Code", description: "Alibaba's Qwen coding assistant", - version: "0.19.9", + version: "0.19.11", iconId: "qwen-code", installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview", - command: ["npx", "-y", "@qwen-code/qwen-code@0.19.9", "--acp", "--experimental-skills"], + command: ["npx", "-y", "@qwen-code/qwen-code@0.19.11", "--acp", "--experimental-skills"], }, { id: "sigit", diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index 9725a1848..5fa4685c9 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -246,7 +246,7 @@ function DaemonInfoCard(props: DaemonInfoCardProps) { {daemonStatusDetailText} - + {t("desktop.daemon.management.title")} {t("desktop.daemon.management.hint")} @@ -258,7 +258,7 @@ function DaemonInfoCard(props: DaemonInfoCardProps) { accessibilityLabel={t("desktop.daemon.management.title")} /> - + {t("desktop.daemon.keepRunning.title")} {t("desktop.daemon.keepRunning.hint")} @@ -270,7 +270,7 @@ function DaemonInfoCard(props: DaemonInfoCardProps) { accessibilityLabel={t("desktop.daemon.keepRunning.title")} /> - + {t("desktop.daemon.logs.title")} @@ -294,7 +294,7 @@ function DaemonInfoCard(props: DaemonInfoCardProps) { - + {t("desktop.daemon.fullStatus.title")} {t("desktop.daemon.fullStatus.hint")} @@ -420,7 +420,7 @@ export function LocalDaemonSection() { testID="host-page-daemon-lifecycle-card" > {isLoading || isLoadingSettings ? ( - + ) : ( @@ -524,7 +524,5 @@ const styles = StyleSheet.create((theme) => ({ }, })); -const LOADING_CARD_STYLE = [settingsStyles.card, styles.loadingCard]; -const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder]; const LOGS_MODAL_SNAP_POINTS = ["70%", "92%"]; const CLI_STATUS_MODAL_SNAP_POINTS = ["60%", "85%"]; diff --git a/packages/app/src/desktop/components/integrations-section.tsx b/packages/app/src/desktop/components/integrations-section.tsx index bedcb669c..abff95ecb 100644 --- a/packages/app/src/desktop/components/integrations-section.tsx +++ b/packages/app/src/desktop/components/integrations-section.tsx @@ -20,8 +20,6 @@ import { ConductorMigration } from "@/desktop/migrations/conductor"; const CLI_DOCS_URL = "https://paseo.sh/docs/cli"; const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills"; -const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder]; - const OP_KIND_ORDER: Record = { add: 0, update: 1, delete: 2 }; const OP_KIND_LABEL_KEY: Record = { add: "settings.integrations.operations.add", @@ -184,7 +182,7 @@ export function IntegrationsSection() { )} - + diff --git a/packages/app/src/diagnostics/app-diagnostic-report.test.ts b/packages/app/src/diagnostics/app-diagnostic-report.test.ts index c01244d96..199ca22a6 100644 --- a/packages/app/src/diagnostics/app-diagnostic-report.test.ts +++ b/packages/app/src/diagnostics/app-diagnostic-report.test.ts @@ -79,6 +79,7 @@ describe("app diagnostics report", () => { ["relay:relay.secret.test:443", { status: "available", latencyMs: 8 }], ]), clientGeneration: 1, + connectionEpoch: 1, }; const report = formatHostRuntimeSection({ host, snapshot }); diff --git a/packages/app/src/e2e-metro-readiness.test.ts b/packages/app/src/e2e-metro-readiness.test.ts new file mode 100644 index 000000000..0ba5df9e3 --- /dev/null +++ b/packages/app/src/e2e-metro-readiness.test.ts @@ -0,0 +1,65 @@ +import { createServer, type Server } from "node:http"; +import { afterEach, expect, test } from "vitest"; + +import { waitForMetro } from "../e2e/global-setup"; + +class MetroPort { + private response = { status: 500, body: "fallback" }; + + private constructor( + readonly port: number, + private readonly server: Server, + ) {} + + static async listen(): Promise { + let endpoint!: MetroPort; + const server = createServer((_request, response) => { + response.writeHead(endpoint.response.status, { "content-type": "text/plain" }); + response.end(endpoint.response.body); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + throw new Error("Failed to listen for Metro readiness test"); + } + endpoint = new MetroPort(address.port, server); + return endpoint; + } + + serveMetro(): void { + this.response = { status: 200, body: "packager-status:running" }; + } + + async close(): Promise { + await new Promise((resolve, reject) => { + this.server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } +} + +let endpoint: MetroPort | null = null; + +afterEach(async () => { + await endpoint?.close(); + endpoint = null; +}); + +test("Metro readiness rejects another HTTP listener on the selected port", async () => { + endpoint = await MetroPort.listen(); + + await expect(waitForMetro(endpoint.port, { label: "Metro", timeoutMs: 150 })).rejects.toThrow( + "Expected Metro status", + ); + + endpoint.serveMetro(); + await expect(waitForMetro(endpoint.port, { label: "Metro", timeoutMs: 150 })).resolves.toBe( + undefined, + ); +}); diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 27db92cd4..5a1d2e007 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -1091,7 +1091,11 @@ export function DiffFileBody({ ); return ( - + {(() => { if (file.status === "too_large" || file.status === "binary") { return ( @@ -1118,7 +1122,7 @@ export function DiffFileBody({ if (layout === "split") { const rows = buildSplitDiffRows(file); return ( - + 0 ? scrollViewWidth : Math.max(0, bodyWidth - gutterWidth); return ( - + {computedLines.map(({ line, lineNumber, key, reviewTarget }, index) => ( @@ -3062,7 +3066,4 @@ const styles = StyleSheet.create((theme) => ({ }, })); -const FILE_SECTION_BODY_STYLE = [styles.fileSectionBodyContainer, styles.fileSectionBorder]; -const DIFF_CONTENT_SPLIT_ROW_STYLE = [styles.diffContent, styles.splitRow]; -const DIFF_CONTENT_ROW_STYLE = [styles.diffContent, styles.diffContentRow]; const DIFF_HEIGHT_CHANGE_EPSILON = 0.5; diff --git a/packages/app/src/git/pull-request-panel/pane.tsx b/packages/app/src/git/pull-request-panel/pane.tsx index 3aba5bc89..582d9543a 100644 --- a/packages/app/src/git/pull-request-panel/pane.tsx +++ b/packages/app/src/git/pull-request-panel/pane.tsx @@ -411,6 +411,7 @@ export function PullRequestPane({ 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.*. diff --git a/packages/app/src/hooks/use-agent-initialization.test.ts b/packages/app/src/hooks/use-agent-initialization.test.ts index 2ef638caf..52fb58e1e 100644 --- a/packages/app/src/hooks/use-agent-initialization.test.ts +++ b/packages/app/src/hooks/use-agent-initialization.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { useSessionStore } from "@/stores/session-store"; import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy"; +import type { HostRuntimeStore } from "@/runtime/host-runtime"; import { getInitDeferred, getInitKey, resolveInitDeferred } from "@/utils/agent-initialization"; import { createSetAgentInitializing, @@ -12,15 +13,28 @@ import { const serverId = "server-1"; const agentId = "agent-1"; -interface FakeDaemonClient { - fetchAgentTimeline: ReturnType; - refreshAgent: ReturnType; +class FakeDaemonClient { + readonly refreshedAgentIds: string[] = []; + + async refreshAgent(requestedAgentId: string): Promise { + this.refreshedAgentIds.push(requestedAgentId); + } } -function makeClient(): FakeDaemonClient { - return { - fetchAgentTimeline: vi.fn().mockResolvedValue(undefined), - refreshAgent: vi.fn().mockResolvedValue(undefined), +class FakeTimelineRuntime { + readonly requests: Array<{ + serverId: string; + agentId: string; + request: Parameters[2]; + }> = []; + + fetchAgentTimeline: HostRuntimeStore["fetchAgentTimeline"] = async ( + requestedServerId, + requestedAgentId, + request, + ) => { + this.requests.push({ serverId: requestedServerId, agentId: requestedAgentId, request }); + return undefined as never; }; } @@ -36,7 +50,8 @@ afterEach(() => { describe("ensureAgentIsInitialized", () => { it("requests bounded projected catch-up after the current cursor when authoritative history is loaded", () => { - const client = makeClient(); + const client = new FakeDaemonClient(); + const runtime = new FakeTimelineRuntime(); useSessionStore.getState().initializeSession(serverId, client as never); useSessionStore .getState() @@ -50,46 +65,63 @@ describe("ensureAgentIsInitialized", () => { serverId, agentId, client: client as never, + runtime, setAgentInitializing: bindSetAgentInitializing(), }); - expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, { - direction: "after", - cursor: { epoch: "epoch-1", seq: 42 }, - limit: TIMELINE_FETCH_PAGE_SIZE, - projection: "projected", - }); + expect(runtime.requests).toEqual([ + { + serverId, + agentId, + request: { + direction: "after", + cursor: { epoch: "epoch-1", seq: 42 }, + limit: TIMELINE_FETCH_PAGE_SIZE, + projection: "projected", + }, + }, + ]); expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("after"); }); it("requests a bounded projected tail when no authoritative cursor is available", () => { - const client = makeClient(); + const client = new FakeDaemonClient(); + const runtime = new FakeTimelineRuntime(); useSessionStore.getState().initializeSession(serverId, client as never); void ensureAgentIsInitialized({ serverId, agentId, client: client as never, + runtime, setAgentInitializing: bindSetAgentInitializing(), }); - expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, { - direction: "tail", - limit: TIMELINE_FETCH_PAGE_SIZE, - projection: "projected", - }); + expect(runtime.requests).toEqual([ + { + serverId, + agentId, + request: { + direction: "tail", + limit: TIMELINE_FETCH_PAGE_SIZE, + projection: "projected", + }, + }, + ]); expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("tail"); }); it("times out initialization after 65 seconds", async () => { vi.useFakeTimers(); - const client = makeClient(); + const client = new FakeDaemonClient(); + const runtime = new FakeTimelineRuntime(); useSessionStore.getState().initializeSession(serverId, client as never); const promise = ensureAgentIsInitialized({ serverId, agentId, client: client as never, + runtime, setAgentInitializing: bindSetAgentInitializing(), }); @@ -108,7 +140,8 @@ describe("ensureAgentIsInitialized", () => { it("refreshes the initialization timeout after paged catch-up progress", async () => { vi.useFakeTimers(); - const client = makeClient(); + const client = new FakeDaemonClient(); + const runtime = new FakeTimelineRuntime(); useSessionStore.getState().initializeSession(serverId, client as never); const setAgentInitializing = bindSetAgentInitializing(); const key = getInitKey(serverId, agentId); @@ -117,6 +150,7 @@ describe("ensureAgentIsInitialized", () => { serverId, agentId, client: client as never, + runtime, setAgentInitializing, }); @@ -141,20 +175,29 @@ describe("ensureAgentIsInitialized", () => { describe("refreshAgent", () => { it("fetches a bounded projected tail after refreshing the agent", async () => { - const client = makeClient(); + const client = new FakeDaemonClient(); + const runtime = new FakeTimelineRuntime(); useSessionStore.getState().initializeSession(serverId, client as never); await refreshAgent({ + serverId, agentId, client: client as never, + runtime, setAgentInitializing: bindSetAgentInitializing(), }); - expect(client.refreshAgent).toHaveBeenCalledWith(agentId); - expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, { - direction: "tail", - limit: TIMELINE_FETCH_PAGE_SIZE, - projection: "projected", - }); + expect(client.refreshedAgentIds).toEqual([agentId]); + expect(runtime.requests).toEqual([ + { + serverId, + agentId, + request: { + direction: "tail", + limit: TIMELINE_FETCH_PAGE_SIZE, + projection: "projected", + }, + }, + ]); }); }); diff --git a/packages/app/src/hooks/use-agent-initialization.ts b/packages/app/src/hooks/use-agent-initialization.ts index b9a76bc99..ae80d352e 100644 --- a/packages/app/src/hooks/use-agent-initialization.ts +++ b/packages/app/src/hooks/use-agent-initialization.ts @@ -10,6 +10,7 @@ import { rejectInitDeferred, refreshInitTimeout, } from "@/utils/agent-initialization"; +import { getHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime"; import { planInitialAgentTimelineSync, planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { i18n } from "@/i18n/i18next"; @@ -37,6 +38,7 @@ export interface EnsureAgentIsInitializedInput { serverId: string; agentId: string; client: Pick | null; + runtime: Pick; setAgentInitializing: SetAgentInitializing; hostDisconnectedMessage?: string; } @@ -68,7 +70,7 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput): return deferred.promise; } - client.fetchAgentTimeline(agentId, timelineRequest).catch((error) => { + input.runtime.fetchAgentTimeline(serverId, agentId, timelineRequest).catch((error) => { setAgentInitializing(agentId, false); rejectInitDeferred(key, error instanceof Error ? error : new Error(String(error))); }); @@ -77,14 +79,16 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput): } export interface RefreshAgentInput { + serverId: string; agentId: string; - client: Pick | null; + client: Pick | null; + runtime: Pick; setAgentInitializing: SetAgentInitializing; hostDisconnectedMessage?: string; } export async function refreshAgent(input: RefreshAgentInput): Promise { - const { agentId, client, setAgentInitializing } = input; + const { serverId, agentId, client, runtime, setAgentInitializing } = input; if (!client) { throw new Error(input.hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected")); } @@ -92,7 +96,7 @@ export async function refreshAgent(input: RefreshAgentInput): Promise { try { await client.refreshAgent(agentId); - await client.fetchAgentTimeline(agentId, planTimelineTailFetch()); + await runtime.fetchAgentTimeline(serverId, agentId, planTimelineTailFetch()); } catch (error) { setAgentInitializing(agentId, false); throw error; @@ -135,6 +139,7 @@ export function useAgentInitialization({ serverId, agentId, client, + runtime: getHostRuntimeStore(), setAgentInitializing, hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), }), @@ -144,12 +149,14 @@ export function useAgentInitialization({ const refreshAgentCallback = useCallback( (agentId: string): Promise => refreshAgent({ + serverId, agentId, client, + runtime: getHostRuntimeStore(), setAgentInitializing, hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), }), - [client, setAgentInitializing, t], + [client, serverId, setAgentInitializing, t], ); return { diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts index f2da3318b..9b9b3e4f2 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts @@ -63,6 +63,7 @@ function createBaseInput(): AgentScreenMachineInput { isArchivingCurrentAgent: false, isHistorySyncing: false, needsAuthoritativeSync: false, + visibilityCatchUpStatus: "ready", hasHydratedHistoryBefore: false, }; } @@ -189,6 +190,62 @@ describe("deriveAgentScreenViewState", () => { expect(sync.ui).toBe("silent"); }); + it("keeps hydrated history visible while reconnect revalidation and visibility catch-up overlap", () => { + const memory = createBaseMemory({ + hasRenderedReady: true, + lastReadyAgent: createAgent("agent-1"), + }); + const input: AgentScreenMachineInput = { + ...createBaseInput(), + hasHydratedHistoryBefore: true, + needsAuthoritativeSync: true, + visibilityCatchUpStatus: "pending", + }; + + const result = deriveAgentScreenViewState({ input, memory }); + const ready = expectReadyState(result.state); + const sync = expectCatchingUpSync(ready); + + expect(sync.ui).toBe("silent"); + }); + + it("covers already-hydrated history while a newly visible agent catches up", () => { + const memory = createBaseMemory({ + hasRenderedReady: true, + lastReadyAgent: createAgent("agent-1"), + }); + const input: AgentScreenMachineInput = { + ...createBaseInput(), + agent: createAgent("agent-1"), + hasHydratedHistoryBefore: true, + visibilityCatchUpStatus: "pending", + }; + + const result = deriveAgentScreenViewState({ input, memory }); + const ready = expectReadyState(result.state); + const sync = expectCatchingUpSync(ready); + + expect(sync.ui).toBe("overlay"); + }); + + it("keeps hydrated history readable after a visibility catch-up error", () => { + const memory = createBaseMemory({ + hasRenderedReady: true, + lastReadyAgent: createAgent("agent-1"), + }); + const input: AgentScreenMachineInput = { + ...createBaseInput(), + agent: createAgent("agent-1"), + hasHydratedHistoryBefore: true, + visibilityCatchUpStatus: "error", + }; + + const result = deriveAgentScreenViewState({ input, memory }); + const ready = expectReadyState(result.state); + + expectSyncErrorSync(ready); + }); + it("keeps sync errors non-blocking once the screen was ready", () => { const memory = createBaseMemory({ hasRenderedReady: true, @@ -328,7 +385,7 @@ describe("deriveAgentScreenViewState", () => { expect(result.memory.lastReadyAgent).toBeNull(); }); - it("still allows optimistic create flow to render before authoritative history arrives", () => { + it("keeps optimistic create non-blocking while timeline and authoritative history catch up", () => { const memory = createBaseMemory(); const input: AgentScreenMachineInput = { ...createBaseInput(), @@ -336,6 +393,7 @@ describe("deriveAgentScreenViewState", () => { continuity: { kind: "optimistic-create", agent: createAgent("agent-1") }, needsAuthoritativeSync: true, isHistorySyncing: true, + visibilityCatchUpStatus: "pending", hasHydratedHistoryBefore: false, }; diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.ts b/packages/app/src/hooks/use-agent-screen-state-machine.ts index 9882ff5cf..daf8562f0 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.ts @@ -46,6 +46,7 @@ export interface AgentScreenMachineInput { isArchivingCurrentAgent: boolean; isHistorySyncing: boolean; needsAuthoritativeSync: boolean; + visibilityCatchUpStatus: "ready" | "pending" | "error"; continuity: AgentScreenContinuity; hasHydratedHistoryBefore: boolean; } @@ -147,10 +148,14 @@ function resolveAgentScreenSource(args: { function resolveCatchingUpUi(args: { hasOptimisticCreateContinuity: boolean; + isVisibilityCatchUpPending: boolean; hasHydratedHistoryBefore: boolean; + needsAuthoritativeSync: boolean; hadInitialSyncFailure: boolean; }): "overlay" | "silent" { if (args.hasOptimisticCreateContinuity) return "silent"; + if (args.hasHydratedHistoryBefore && args.needsAuthoritativeSync) return "silent"; + if (args.isVisibilityCatchUpPending) return "overlay"; if (args.hasHydratedHistoryBefore) return "silent"; if (args.hadInitialSyncFailure) return "silent"; return "overlay"; @@ -167,12 +172,21 @@ function resolveAgentScreenSync(args: { if (input.missingAgentState.kind === "error") { return { status: "sync_error" }; } - if (input.needsAuthoritativeSync || input.isHistorySyncing) { + if (input.visibilityCatchUpStatus === "error") { + return { status: "sync_error" }; + } + if ( + input.visibilityCatchUpStatus === "pending" || + input.needsAuthoritativeSync || + input.isHistorySyncing + ) { return { status: "catching_up", ui: resolveCatchingUpUi({ hasOptimisticCreateContinuity: hasOptimisticCreateContinuity(input), + isVisibilityCatchUpPending: input.visibilityCatchUpStatus === "pending", hasHydratedHistoryBefore: input.hasHydratedHistoryBefore, + needsAuthoritativeSync: input.needsAuthoritativeSync, hadInitialSyncFailure, }), }; diff --git a/packages/app/src/hooks/use-app-visible.ts b/packages/app/src/hooks/use-app-visible.ts index 89a046b3c..2777e18af 100644 --- a/packages/app/src/hooks/use-app-visible.ts +++ b/packages/app/src/hooks/use-app-visible.ts @@ -1,19 +1,24 @@ import { useSyncExternalStore } from "react"; import { AppState } from "react-native"; -import { getIsAppActivelyVisible } from "@/utils/app-visibility"; +import { getIsAppActivelyVisible, getIsAppVisible } from "@/utils/app-visibility"; import { isWeb } from "@/constants/platform"; -let current = getIsAppActivelyVisible(); -const listeners = new Set<() => void>(); +let visible = getIsAppVisible(); +let activelyVisible = getIsAppActivelyVisible(); +const visibilityListeners = new Set<() => void>(); +const activeVisibilityListeners = new Set<() => void>(); function notify(): void { - const next = getIsAppActivelyVisible(); - if (next === current) { - return; + const nextVisible = getIsAppVisible(); + if (nextVisible !== visible) { + visible = nextVisible; + for (const listener of visibilityListeners) listener(); } - current = next; - for (const listener of listeners) { - listener(); + + const nextActivelyVisible = getIsAppActivelyVisible(); + if (nextActivelyVisible !== activelyVisible) { + activelyVisible = nextActivelyVisible; + for (const listener of activeVisibilityListeners) listener(); } } @@ -29,15 +34,32 @@ if (isWeb && typeof document !== "undefined") { window.addEventListener("blur", notify); } -function subscribe(listener: () => void): () => void { - listeners.add(listener); - return () => listeners.delete(listener); +function subscribeToVisibility(listener: () => void): () => void { + visibilityListeners.add(listener); + return () => visibilityListeners.delete(listener); } -function getSnapshot(): boolean { - return current; +function subscribeToActiveVisibility(listener: () => void): () => void { + activeVisibilityListeners.add(listener); + return () => activeVisibilityListeners.delete(listener); +} + +function getVisibilitySnapshot(): boolean { + return visible; +} + +function getActiveVisibilitySnapshot(): boolean { + return activelyVisible; } export function useAppVisible(): boolean { - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + return useSyncExternalStore(subscribeToVisibility, getVisibilitySnapshot, getVisibilitySnapshot); +} + +export function useAppActivelyVisible(): boolean { + return useSyncExternalStore( + subscribeToActiveVisibility, + getActiveVisibilitySnapshot, + getActiveVisibilitySnapshot, + ); } diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts deleted file mode 100644 index 682c254ba..000000000 --- a/packages/app/src/hooks/use-command-center.ts +++ /dev/null @@ -1,482 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { TextInput } from "react-native"; -import { router, type Href } from "expo-router"; -import { useTranslation } from "react-i18next"; -import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; -import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; -import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents"; -import { useOpenAddProject } from "@/hooks/use-open-add-project"; -import { - clearCommandCenterFocusRestoreElement, - takeCommandCenterFocusRestoreElement, -} from "@/utils/command-center-focus-restore"; -import { buildOpenProjectRoute, buildSettingsRoute } from "@/utils/host-routes"; -import type { ShortcutKey } from "@/utils/format-shortcut"; -import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string"; -import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts"; -import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; -import { getShortcutOs } from "@/utils/shortcut-platform"; -import { getIsElectronRuntime } from "@/constants/layout"; -import { navigateToAgent } from "@/utils/navigate-to-agent"; -import { focusWithRetries } from "@/utils/web-focus"; -import { isWeb } from "@/constants/platform"; -import { useProjects } from "@/hooks/use-projects"; -import { useHosts } from "@/runtime/host-runtime"; -import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; -import { formatTimeAgo } from "@/utils/time"; -import { shortenPath } from "@/utils/shorten-path"; - -const EMPTY_ACTION_ITEMS: CommandCenterActionItem[] = []; -const EMPTY_WORKSPACE_ITEMS: CommandCenterWorkspaceItem[] = []; -const EMPTY_AGENT_ITEMS: CommandCenterAgentItem[] = []; -const EMPTY_COMMAND_CENTER_ITEMS: CommandCenterItem[] = []; - -function buildSearchText(...fields: string[]): string { - return fields.join(" ").toLowerCase(); -} - -function matchesQuery(searchText: string, query: string): boolean { - const normalized = query.trim().toLowerCase(); - return !normalized || searchText.includes(normalized); -} - -function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number { - const leftNeedsInput = (left.pendingPermissionCount ?? 0) > 0 ? 1 : 0; - const rightNeedsInput = (right.pendingPermissionCount ?? 0) > 0 ? 1 : 0; - if (leftNeedsInput !== rightNeedsInput) return rightNeedsInput - leftNeedsInput; - - const leftAttention = left.requiresAttention ? 1 : 0; - const rightAttention = right.requiresAttention ? 1 : 0; - if (leftAttention !== rightAttention) return rightAttention - leftAttention; - - const leftRunning = left.status === "running" ? 1 : 0; - const rightRunning = right.status === "running" ? 1 : 0; - if (leftRunning !== rightRunning) return rightRunning - leftRunning; - - return right.lastActivityAt.getTime() - left.lastActivityAt.getTime(); -} - -interface CommandCenterActionDefinition { - id: string; - titleKey: - | "shell.commandCenter.addProject" - | "shell.commandCenter.home" - | "sidebar.actions.settings"; - icon?: "plus" | "settings" | "home"; - actionId?: string; - keywords: string[]; - routeKind: "settings" | "home" | "none"; -} - -const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [ - { - id: "new-agent", - titleKey: "shell.commandCenter.addProject", - icon: "plus", - actionId: "new-agent", - keywords: ["open", "project", "folder", "workspace", "repo"], - routeKind: "none", - }, - { - id: "home", - titleKey: "shell.commandCenter.home", - icon: "home", - keywords: ["home", "start", "import", "session", "pair", "device", "providers"], - routeKind: "home", - }, - { - id: "settings", - titleKey: "sidebar.actions.settings", - icon: "settings", - keywords: ["settings", "preferences", "config", "configuration"], - routeKind: "settings", - }, -]; - -export interface CommandCenterActionItem { - kind: "action"; - id: string; - title: string; - icon?: "plus" | "settings" | "home"; - route?: Href; - shortcutKeys?: ShortcutKey[][]; - searchText: string; -} - -export interface CommandCenterWorkspaceItem { - kind: "workspace"; - serverId: string; - workspaceId: string; - title: string; - subtitle: string; - searchText: string; -} - -export interface CommandCenterAgentItem { - kind: "agent"; - agent: AggregatedAgent; - title: string; - subtitle: string; - searchText: string; -} - -export type CommandCenterItem = - | CommandCenterActionItem - | CommandCenterWorkspaceItem - | CommandCenterAgentItem; - -function resolveActionShortcutKeys( - actionId: string | undefined, - overrides: Record, -): ShortcutKey[][] | undefined { - if (!actionId) return undefined; - const isMac = getShortcutOs() === "mac"; - const isDesktopApp = getIsElectronRuntime(); - const platform = { isMac, isDesktop: isDesktopApp }; - const bindingId = getBindingIdForAction(actionId, platform); - if (!bindingId) return undefined; - const override = overrides[bindingId]; - if (override) return chordStringToShortcutKeys(override); - const defaultKeys = getDefaultKeysForAction(actionId, platform); - return defaultKeys ? [defaultKeys] : undefined; -} - -export function useCommandCenter() { - const { t } = useTranslation(); - const { overrides } = useKeyboardShortcutOverrides(); - const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen); - const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen); - const openAddProject = useOpenAddProject(); - const inputRef = useRef(null); - const didNavigateRef = useRef(false); - const prevOpenRef = useRef(open); - const activeIndexRef = useRef(0); - const itemsRef = useRef([]); - const handleCloseRef = useRef<() => void>(() => undefined); - const handleSelectItemRef = useRef<(item: CommandCenterItem) => void>(() => undefined); - const [query, setQuery] = useState(""); - const [activeIndex, setActiveIndex] = useState(0); - - const { agents } = useAggregatedAgents(); - const { projects } = useProjects({ enabled: open }); - const hosts = useHosts(); - const showAgentHost = hosts.length > 1; - - const allWorkspaceItems = useMemo(() => { - const results: CommandCenterWorkspaceItem[] = []; - for (const project of projects) { - for (const host of project.hosts) { - for (const workspace of host.workspaces) { - if (workspace.archivingAt) continue; - const title = workspace.title ?? workspace.name; - const subtitle = workspace.currentBranch - ? `${host.serverName} · ${workspace.currentBranch}` - : host.serverName; - results.push({ - kind: "workspace", - serverId: host.serverId, - workspaceId: workspace.id, - title, - subtitle, - searchText: buildSearchText(title, subtitle), - }); - } - } - } - results.sort((left, right) => { - const titleDelta = left.title.localeCompare(right.title, undefined, { - numeric: true, - sensitivity: "base", - }); - if (titleDelta !== 0) return titleDelta; - const hostDelta = left.subtitle.localeCompare(right.subtitle, undefined, { - numeric: true, - sensitivity: "base", - }); - if (hostDelta !== 0) return hostDelta; - return `${left.serverId}:${left.workspaceId}`.localeCompare( - `${right.serverId}:${right.workspaceId}`, - ); - }); - return results; - }, [projects]); - - const workspaceTitleByKey = useMemo( - () => - new Map( - allWorkspaceItems.map((workspace) => [ - `${workspace.serverId}:${workspace.workspaceId}`, - workspace.title, - ]), - ), - [allWorkspaceItems], - ); - - const workspaceResults = useMemo(() => { - if (!open || allWorkspaceItems.length === 0) { - return EMPTY_WORKSPACE_ITEMS; - } - return allWorkspaceItems.filter((workspace) => matchesQuery(workspace.searchText, query)); - }, [allWorkspaceItems, open, query]); - - const agentResults = useMemo(() => { - if (!open || agents.length === 0) { - return EMPTY_AGENT_ITEMS; - } - const items = agents.map((agent) => { - const title = agent.title || t("shell.commandCenter.newAgent"); - const workspaceTitle = agent.workspaceId - ? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`) - : undefined; - const location = workspaceTitle ?? shortenPath(agent.cwd); - const subtitle = [ - showAgentHost ? agent.serverLabel : null, - location, - formatTimeAgo(agent.lastActivityAt), - ] - .filter((part): part is string => Boolean(part)) - .join(" · "); - return { - kind: "agent", - agent, - title, - subtitle, - searchText: buildSearchText(title, subtitle, agent.cwd), - }; - }); - const filtered = items.filter((item) => matchesQuery(item.searchText, query)); - filtered.sort((left, right) => sortAgents(left.agent, right.agent)); - return filtered; - }, [agents, open, query, showAgentHost, t, workspaceTitleByKey]); - - const settingsRoute = useMemo(() => { - return buildSettingsRoute(); - }, []); - - const homeRoute = useMemo(() => buildOpenProjectRoute() as Href, []); - - const actionItems = useMemo(() => { - if (!open) { - return EMPTY_ACTION_ITEMS; - } - return COMMAND_CENTER_ACTIONS.filter( - (action) => action.routeKind !== "home" || Boolean(homeRoute), - ) - .map((action) => { - let route: Href | undefined; - if (action.routeKind === "settings") route = settingsRoute; - else if (action.routeKind === "home") route = homeRoute; - const title = t(action.titleKey); - return { - kind: "action", - id: action.id, - title, - icon: action.icon, - route, - shortcutKeys: resolveActionShortcutKeys(action.actionId, overrides), - searchText: buildSearchText(title, ...action.keywords), - }; - }) - .filter((action) => matchesQuery(action.searchText, query)); - }, [open, query, settingsRoute, homeRoute, overrides, t]); - - const items = useMemo(() => { - if (!open) { - return EMPTY_COMMAND_CENTER_ITEMS; - } - return [...actionItems, ...workspaceResults, ...agentResults]; - }, [actionItems, workspaceResults, agentResults, open]); - - const handleClose = useCallback(() => { - setOpen(false); - }, [setOpen]); - - const handleSelectAgent = useCallback( - (agent: AggregatedAgent) => { - didNavigateRef.current = true; - - // Don't restore focus back to the prior element after we navigate. - clearCommandCenterFocusRestoreElement(); - setOpen(false); - navigateToAgent({ - serverId: agent.serverId, - agentId: agent.id, - }); - }, - [setOpen], - ); - - const handleSelectWorkspace = useCallback( - (workspace: CommandCenterWorkspaceItem) => { - didNavigateRef.current = true; - clearCommandCenterFocusRestoreElement(); - setOpen(false); - navigateToWorkspace({ - serverId: workspace.serverId, - workspaceId: workspace.workspaceId, - }); - }, - [setOpen], - ); - - const handleSelectAction = useCallback( - (action: CommandCenterActionItem) => { - clearCommandCenterFocusRestoreElement(); - setOpen(false); - if (action.id === "new-agent") { - openAddProject(); - return; - } - if (!action.route) { - return; - } - didNavigateRef.current = true; - router.push(action.route); - }, - [openAddProject, setOpen], - ); - - const handleSelectItem = useCallback( - (item: CommandCenterItem) => { - if (item.kind === "action") { - handleSelectAction(item); - return; - } - if (item.kind === "workspace") { - handleSelectWorkspace(item); - return; - } - handleSelectAgent(item.agent); - }, - [handleSelectAction, handleSelectAgent, handleSelectWorkspace], - ); - - useEffect(() => { - activeIndexRef.current = activeIndex; - }, [activeIndex]); - - useEffect(() => { - itemsRef.current = items; - }, [items]); - - useEffect(() => { - handleCloseRef.current = handleClose; - }, [handleClose]); - - useEffect(() => { - handleSelectItemRef.current = handleSelectItem; - }, [handleSelectItem]); - - useEffect(() => { - const prevOpen = prevOpenRef.current; - prevOpenRef.current = open; - - if (!open) { - setQuery(""); - setActiveIndex(0); - - if (prevOpen && !didNavigateRef.current) { - const el = takeCommandCenterFocusRestoreElement(); - const isFocused = () => - Boolean(el) && typeof document !== "undefined" && document.activeElement === el; - - const cancel = focusWithRetries({ - focus: () => el?.focus(), - isFocused, - onTimeout: () => { - keyboardActionDispatcher.dispatch({ - id: "message-input.focus", - scope: "message-input", - }); - }, - }); - return cancel; - } - - return; - } - - didNavigateRef.current = false; - - const id = setTimeout(() => { - inputRef.current?.focus(); - }, 0); - return () => clearTimeout(id); - }, [open]); - - useEffect(() => { - if (!open) return; - if (activeIndex >= items.length) { - setActiveIndex(items.length > 0 ? items.length - 1 : 0); - } - }, [activeIndex, items.length, open]); - - const handleKeyEvent = useCallback( - (key: string): boolean => { - if (!open) return false; - const currentItems = itemsRef.current; - - if (key === "Escape") { - handleCloseRef.current(); - return true; - } - - if (key === "Enter") { - if (currentItems.length === 0) return false; - const index = Math.max(0, Math.min(activeIndexRef.current, currentItems.length - 1)); - handleSelectItemRef.current(currentItems[index]); - return true; - } - - if (key === "ArrowDown" || key === "ArrowUp") { - if (currentItems.length === 0) return false; - setActiveIndex((current) => { - const delta = key === "ArrowDown" ? 1 : -1; - const next = current + delta; - if (next < 0) return currentItems.length - 1; - if (next >= currentItems.length) return 0; - return next; - }); - return true; - } - - return false; - }, - [open], - ); - - useEffect(() => { - if (!open || !isWeb) return; - - const handler = (event: KeyboardEvent) => { - if ( - event.key !== "ArrowDown" && - event.key !== "ArrowUp" && - event.key !== "Enter" && - event.key !== "Escape" - ) { - return; - } - if (handleKeyEvent(event.key)) { - event.preventDefault(); - } - }; - - // react-native-web can stop propagation on key events, so listen in capture phase. - window.addEventListener("keydown", handler, true); - return () => window.removeEventListener("keydown", handler, true); - }, [open, handleKeyEvent]); - - return { - open, - inputRef, - query, - setQuery, - activeIndex, - setActiveIndex, - items, - handleClose, - handleSelectItem, - handleKeyEvent, - }; -} diff --git a/packages/app/src/hooks/use-load-older-agent-history.ts b/packages/app/src/hooks/use-load-older-agent-history.ts index 775f5f23a..c2cf7c02c 100644 --- a/packages/app/src/hooks/use-load-older-agent-history.ts +++ b/packages/app/src/hooks/use-load-older-agent-history.ts @@ -4,6 +4,7 @@ import type { ToastApi } from "@/components/toast-host"; import { i18n } from "@/i18n/i18next"; import { useSessionStore, type AgentTimelineCursorState } from "@/stores/session-store"; import { planTimelineOlderFetch } from "@/timeline/timeline-sync-plan"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; export interface LoadOlderAgentHistoryClient { fetchAgentTimeline: ( @@ -97,7 +98,12 @@ export function useLoadOlderAgentHistory({ const loadOlder = useCallback(() => { const session = useSessionStore.getState().sessions[serverId]; void loadOlderAgentHistory(agentId, { - client: (session?.client ?? null) as LoadOlderAgentHistoryClient | null, + client: session?.client + ? { + fetchAgentTimeline: (timelineAgentId, request) => + getHostRuntimeStore().fetchAgentTimeline(serverId, timelineAgentId, request), + } + : null, cursor: session?.agentTimelineCursor.get(agentId), hasOlder: session?.agentTimelineHasOlder.get(agentId) === true, isLoadingOlder: session?.agentTimelineOlderFetchInFlight.get(agentId) === true, diff --git a/packages/app/src/hooks/use-open-project.ts b/packages/app/src/hooks/use-open-project.ts index 29f571413..98bd07295 100644 --- a/packages/app/src/hooks/use-open-project.ts +++ b/packages/app/src/hooks/use-open-project.ts @@ -16,7 +16,8 @@ export function useOpenProject( const isConnected = useHostRuntimeIsConnected(normalizedServerId); const canAddProject = useSessionStore((state) => normalizedServerId - ? state.sessions[normalizedServerId]?.serverInfo?.features?.projectAdd === true + ? state.sessions[normalizedServerId]?.serverInfo?.features?.projectAdd === true && + state.sessions[normalizedServerId]?.serverInfo?.features?.stableProjectIdentity === true : false, ); const addEmptyProject = useSessionStore((state) => state.addEmptyProject); diff --git a/packages/app/src/hooks/use-sidebar-workspace-entries.ts b/packages/app/src/hooks/use-sidebar-workspace-entries.ts index 5f0ccc407..c2eb65ac7 100644 --- a/packages/app/src/hooks/use-sidebar-workspace-entries.ts +++ b/packages/app/src/hooks/use-sidebar-workspace-entries.ts @@ -38,7 +38,10 @@ export function useSidebarWorkspaceEntries( // subscription to structurally shared indexes, never one session-store // subscription per mounted row. return useMemo(() => { - if (!enabled || placements.length === 0 || sessions.length === 0) { + if (!enabled) { + return previousEntriesRef.current; + } + if (placements.length === 0 || sessions.length === 0) { previousEntriesRef.current = EMPTY_ENTRIES; return EMPTY_ENTRIES; } diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index d412bd8d1..124c6643e 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -184,6 +184,10 @@ describe("translation resources", () => { expect(en.shell.commandCenter.newAgent).toBe("New agent"); expect(en.shell.commandCenter.addProject).toBe("Add project"); expect(en.shell.commandCenter.home).toBe("Home"); + expect(en.shell.commandCenter.modelGroupLabel).toBe("Model"); + expect(en.shell.commandCenter.modelSearchKeywords).toBe( + "switch model change model set model select model", + ); }); it("includes composer and agent workflow keys for the Batch 2 migration", () => { diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index dd3ad073b..461e38e38 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -62,6 +62,8 @@ export const ar: TranslationResources = { newAgent: "وكيل جديد", addProject: "إضافة مشروع", home: "بيت", + modelGroupLabel: "النموذج", + modelSearchKeywords: "تبديل النموذج تغيير النموذج تعيين النموذج اختيار النموذج", }, }, composer: { @@ -190,6 +192,7 @@ export const ar: TranslationResources = { notFound: "لم يتم العثور على Agent", failedToLoad: "فشل تحميل الوكيل", reconnecting: "جارٍ إعادة الاتصال...", + timelineSyncFailed: "تعذر تحديث سجل الوكيل. جارٍ إعادة المحاولة…", archivingTitle: "وكيل الارشيف...", archivingSubtitle: "الرجاء الانتظار بينما نقوم بأرشفة هذا الوكيل.", }, @@ -1736,6 +1739,7 @@ export const ar: TranslationResources = { }, shortcuts: { dialogTitle: "الاختصارات", + searchPlaceholder: "البحث في الاختصارات", unavailableOnMobile: "اختصارات لوحة المفاتيح متاحة فقط على سطح المكتب", capturePrompt: "اضغط على الاختصار...", actions: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index e388b2cdf..ad2d0bfb5 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -60,6 +60,8 @@ export const en = { newAgent: "New agent", addProject: "Add project", home: "Home", + modelGroupLabel: "Model", + modelSearchKeywords: "switch model change model set model select model", }, }, composer: { @@ -188,6 +190,7 @@ export const en = { notFound: "Agent not found", failedToLoad: "Failed to load agent", reconnecting: "Reconnecting...", + timelineSyncFailed: "Couldn't refresh agent history. Retrying…", archivingTitle: "Archiving agent...", archivingSubtitle: "Please wait while we archive this agent.", }, @@ -1747,6 +1750,7 @@ export const en = { }, shortcuts: { dialogTitle: "Shortcuts", + searchPlaceholder: "Search shortcuts", unavailableOnMobile: "Keyboard shortcuts are only available on desktop", capturePrompt: "Press shortcut...", actions: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 558ea8a31..8dc588254 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -62,6 +62,8 @@ export const es: TranslationResources = { newAgent: "Nuevo agente", addProject: "Agregar proyecto", home: "Hogar", + modelGroupLabel: "Modelo", + modelSearchKeywords: "cambiar modelo modificar modelo establecer modelo seleccionar modelo", }, }, composer: { @@ -190,6 +192,7 @@ export const es: TranslationResources = { notFound: "Agentno encontrado", failedToLoad: "No se pudo cargar el agente", reconnecting: "Reconectando...", + timelineSyncFailed: "No se pudo actualizar el historial del agente. Reintentando…", archivingTitle: "Agente de archivo...", archivingSubtitle: "Espere mientras archivamos este agente.", }, @@ -1786,6 +1789,7 @@ export const es: TranslationResources = { }, shortcuts: { dialogTitle: "Atajos", + searchPlaceholder: "Buscar atajos", unavailableOnMobile: "Los atajos de teclado solo están disponibles en el escritorio", capturePrompt: "Presione el acceso directo...", actions: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index de7105c6b..48204cebd 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -63,6 +63,9 @@ export const fr: TranslationResources = { newAgent: "Nouvel agent", addProject: "Ajouter un projet", home: "Maison", + modelGroupLabel: "Modèle", + modelSearchKeywords: + "changer de modèle modifier le modèle définir le modèle sélectionner le modèle", }, }, composer: { @@ -191,6 +194,7 @@ export const fr: TranslationResources = { notFound: "Agentintrouvable", failedToLoad: "Échec du chargement de l'agent", reconnecting: "Reconnexion...", + timelineSyncFailed: "Impossible d’actualiser l’historique de l’agent. Nouvelle tentative…", archivingTitle: "Agent d'archivage...", archivingSubtitle: "Veuillez patienter pendant que nous archivons cet agent.", }, @@ -1787,6 +1791,7 @@ export const fr: TranslationResources = { }, shortcuts: { dialogTitle: "Raccourcis", + searchPlaceholder: "Rechercher des raccourcis", unavailableOnMobile: "Les raccourcis clavier ne sont disponibles que sur le bureau", capturePrompt: "Appuyez sur le raccourci...", actions: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 6acaf9f11..e8368f0b5 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -62,6 +62,8 @@ export const ja: TranslationResources = { newAgent: "新しいエージェント", addProject: "プロジェクトを追加", home: "ホーム", + modelGroupLabel: "モデル", + modelSearchKeywords: "モデルを切り替え モデルを変更 モデルを設定 モデルを選択", }, }, composer: { @@ -190,6 +192,7 @@ export const ja: TranslationResources = { notFound: "エージェントが見つかりません", failedToLoad: "エージェントの読み込みに失敗しました", reconnecting: "再接続中...", + timelineSyncFailed: "エージェントの履歴を更新できませんでした。再試行しています…", archivingTitle: "エージェントをアーカイブ中...", archivingSubtitle: "このエージェントをアーカイブするまでお待ちください。", }, @@ -1755,6 +1758,7 @@ export const ja: TranslationResources = { }, shortcuts: { dialogTitle: "ショートカット", + searchPlaceholder: "ショートカットを検索", unavailableOnMobile: "キーボードショートカットはデスクトップでのみ利用できます", capturePrompt: "ショートカットを押してください...", actions: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 5d2af125e..b1ac1268d 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -62,6 +62,8 @@ export const ptBR: TranslationResources = { newAgent: "Novo agente", addProject: "Adicionar projeto", home: "Início", + modelGroupLabel: "Modelo", + modelSearchKeywords: "trocar modelo mudar modelo definir modelo selecionar modelo", }, }, composer: { @@ -190,6 +192,7 @@ export const ptBR: TranslationResources = { notFound: "Agente não encontrado", failedToLoad: "Falha ao carregar agente", reconnecting: "Reconectando...", + timelineSyncFailed: "Não foi possível atualizar o histórico do agente. Tentando novamente…", archivingTitle: "Arquivando agente...", archivingSubtitle: "Aguarde enquanto arquivamos este agente.", }, @@ -1770,6 +1773,7 @@ export const ptBR: TranslationResources = { }, shortcuts: { dialogTitle: "Atalhos", + searchPlaceholder: "Pesquisar atalhos", unavailableOnMobile: "Atalhos de teclado estão disponíveis apenas no desktop", capturePrompt: "Pressione o atalho...", actions: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 7206c5add..d5d038b8c 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -62,6 +62,8 @@ export const ru: TranslationResources = { newAgent: "Новый агент", addProject: "Добавить проект", home: "Дом", + modelGroupLabel: "Модель", + modelSearchKeywords: "сменить модель изменить модель выбрать модель установить модель", }, }, composer: { @@ -190,6 +192,7 @@ export const ru: TranslationResources = { notFound: "Agent не найден", failedToLoad: "Не удалось загрузить агент", reconnecting: "Повторное подключение...", + timelineSyncFailed: "Не удалось обновить историю агента. Повторная попытка…", archivingTitle: "Архивный агент...", archivingSubtitle: "Пожалуйста, подождите, пока мы архивируем этого агента.", }, @@ -1775,6 +1778,7 @@ export const ru: TranslationResources = { }, shortcuts: { dialogTitle: "Ярлыки", + searchPlaceholder: "Поиск сочетаний клавиш", unavailableOnMobile: "Сочетания клавиш доступны только на рабочем столе.", capturePrompt: "Нажмите ярлык...", actions: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index d6cfacd21..a0ee0e224 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -62,6 +62,8 @@ export const zhCN: TranslationResources = { newAgent: "新建 Agent", addProject: "添加 project", home: "首页", + modelGroupLabel: "模型", + modelSearchKeywords: "切换模型 更改模型 设置模型 选择模型", }, }, composer: { @@ -190,6 +192,7 @@ export const zhCN: TranslationResources = { notFound: "未找到 Agent", failedToLoad: "加载 Agent 失败", reconnecting: "正在重连...", + timelineSyncFailed: "无法刷新代理历史记录。正在重试…", archivingTitle: "正在归档 Agent...", archivingSubtitle: "请稍候,我们正在归档这个 Agent。", }, @@ -1717,6 +1720,7 @@ export const zhCN: TranslationResources = { }, shortcuts: { dialogTitle: "快捷键", + searchPlaceholder: "搜索快捷键", unavailableOnMobile: "键盘快捷键仅在桌面端可用", capturePrompt: "按下快捷键...", actions: { diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index ba7b899a7..990527a7b 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1,7 +1,15 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { TFunction } from "i18next"; import { SquarePen } from "lucide-react-native"; -import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; import { useTranslation } from "react-i18next"; import { ActivityIndicator, Text, View } from "react-native"; import ReanimatedAnimated from "react-native-reanimated"; @@ -13,6 +21,8 @@ import { useStoreWithEqualityFn } from "zustand/traditional"; import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view"; import { ArchivedAgentCallout } from "@/components/archived-agent-callout"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; +import { useRetainedPanelActive } from "@/components/retained-panel"; +import { SidebarCallout } from "@/components/sidebar-callout"; import { Composer } from "@/composer"; import { AgentModeControl } from "@/composer/agent-controls/mode-control"; import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore"; @@ -26,7 +36,7 @@ import { import type { WorkspaceComposerAttachment } from "@/attachments/types"; import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; -import { isNative, isWeb } from "@/constants/platform"; +import { isWeb } from "@/constants/platform"; import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear"; import { useAgentInitialization } from "@/hooks/use-agent-initialization"; import { useAgentInputDraft, type AgentInputDraft } from "@/composer/draft/input-draft"; @@ -40,10 +50,7 @@ import { import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useContainerWidthBelow } from "@/hooks/use-container-width"; -import { - clearHistorySyncErrorAfterSuccessfulSync, - reconcileMissingAgentStateWithPresentAgent, -} from "@/panels/agent-panel-load-state"; +import { reconcileMissingAgentStateWithPresentAgent } from "@/panels/agent-panel-load-state"; import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; import { RenderProfile } from "@/utils/render-profiler"; @@ -491,6 +498,13 @@ function AgentPanelContent({ const runtimeIsConnected = useHostRuntimeIsConnected(runtimeServerId); const runtimeConnectionStatus = useHostRuntimeConnectionStatus(runtimeServerId); const runtimeLastError = useHostRuntimeLastError(runtimeServerId); + const hasCachedAgent = useSessionStore((state) => { + if (!resolvedServerId || !resolvedAgentId) return false; + const session = state.sessions[resolvedServerId]; + return Boolean( + session?.agents.has(resolvedAgentId) || session?.agentDetails.has(resolvedAgentId), + ); + }); const connectionServerId = resolvedServerId ?? null; const daemon = connectionServerId @@ -505,7 +519,7 @@ function AgentPanelContent({ : runtimeConnectionStatus; const lastConnectionError = runtimeLastError; - if (!resolvedServerId || !runtimeClient) { + if (!resolvedServerId || (!runtimeClient && !hasCachedAgent)) { return ( >; + client: ReturnType; isConnected: boolean; connectionStatus: HostRuntimeConnectionStatus; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; @@ -586,7 +600,7 @@ function AgentPanelBody({ } return; } - if (!isConnected || !hasSession) { + if (!client || !isConnected || !hasSession) { return; } if (lookupState.tag === "loading" || lookupState.tag === "not_found") { @@ -703,12 +717,13 @@ function ChatAgentContent({ serverId: string; agentId?: string; isPaneFocused: boolean; - client: NonNullable>; + client: ReturnType; isConnected: boolean; connectionStatus: HostRuntimeConnectionStatus; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { const { t } = useTranslation(); + const isPaneVisible = useRetainedPanelActive(); const { api: toastApi, toast: toastState, dismiss: dismissToast } = useToastHost(); const { isArchivingAgent } = useArchiveAgent(); const streamViewRef = useRef(null); @@ -752,6 +767,26 @@ function ChatAgentContent({ const agentHistorySyncGeneration = useSessionStore((state) => agentId ? (state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1) : -1, ); + const viewedTimelineSync = useSessionStore( + (state) => state.sessions[serverId]?.viewedTimelineSync ?? null, + ); + const subscribeToVisibilityCatchUp = useCallback( + (listener: () => void) => viewedTimelineSync?.subscribe(listener) ?? (() => {}), + [viewedTimelineSync], + ); + const readTimelineStatus = useCallback( + () => + !agentId || !viewedTimelineSync + ? ("ready" as const) + : viewedTimelineSync.getAgentTimelineStatus(agentId), + [agentId, viewedTimelineSync], + ); + const timelineStatus = useSyncExternalStore( + subscribeToVisibilityCatchUp, + readTimelineStatus, + readTimelineStatus, + ); + const visibilityCatchUpStatus = isPaneVisible ? timelineStatus : "ready"; const hasActiveCreateHandoff = useCreateFlowStore((state) => findActiveCreateHandoff({ pendingByDraftId: state.pendingByDraftId, serverId, agentId }), ); @@ -782,44 +817,6 @@ function ChatAgentContent({ mode: "translate", }); - const handleHistorySyncFailure = useCallback( - ({ origin, error }: { origin: "focus" | "entry"; error: unknown }) => { - if (agentId) { - console.warn("[AgentPanel] history sync failed", { - origin, - agentId, - error, - }); - } - const message = toErrorMessage(error); - setMissingAgentState((previous) => { - if (previous.kind === "error" && previous.message === message) { - return previous; - } - return { kind: "error", message }; - }); - }, - [agentId], - ); - - const ensureInitializedWithSyncErrorHandling = useCallback( - (origin: "focus" | "entry") => { - if (!agentId) { - return; - } - ensureAgentIsInitialized(agentId) - .then(() => { - setMissingAgentState(clearHistorySyncErrorAfterSuccessfulSync); - return undefined; - }) - .catch((error) => { - handleHistorySyncFailure({ origin, error }); - return undefined; - }); - }, - [agentId, ensureAgentIsInitialized, handleHistorySyncFailure], - ); - useEffect(() => { if (connectionStatus === "online") { if (reconnectToastArmedRef.current) { @@ -840,13 +837,6 @@ function ChatAgentContent({ } }, [connectionStatus, dismissToast, toastApi, t]); - useEffect(() => { - if (!isPaneFocused || !agentId || !isConnected || !hasSession) { - return; - } - ensureInitializedWithSyncErrorHandling("focus"); - }, [agentId, ensureInitializedWithSyncErrorHandling, hasSession, isConnected, isPaneFocused]); - const isArchivingCurrentAgent = Boolean(agentId && isArchivingAgent({ serverId, agentId })); useEffect(() => { @@ -908,6 +898,7 @@ function ChatAgentContent({ isArchivingCurrentAgent, isHistorySyncing, needsAuthoritativeSync, + visibilityCatchUpStatus, continuity, hasHydratedHistoryBefore, }, @@ -946,27 +937,6 @@ function ChatAgentContent({ streamViewRef.current?.scrollToBottom("message-sent"); }, [agentId]); - useEffect(() => { - if (!agentId) { - return; - } - if (!isConnected || !hasSession) { - return; - } - const shouldSyncOnEntry = needsAuthoritativeSync || isNative; - if (!shouldSyncOnEntry) { - return; - } - - ensureInitializedWithSyncErrorHandling("entry"); - }, [ - agentId, - ensureInitializedWithSyncErrorHandling, - hasSession, - isConnected, - needsAuthoritativeSync, - ]); - useEffect(() => { initAttemptTokenRef.current += 1; setMissingAgentState({ kind: "idle" }); @@ -976,16 +946,24 @@ function ChatAgentContent({ if (!agentId) { return; } - if (agentState.id) { - if (missingAgentState.kind === "resolving" || missingAgentState.kind === "not_found") { + if (agentState.id && hasAppliedAuthoritativeHistory) { + if ( + missingAgentState.kind === "resolving" || + missingAgentState.kind === "not_found" || + missingAgentState.kind === "error" + ) { setMissingAgentState(reconcileMissingAgentStateWithPresentAgent); } return; } - if (!isConnected || !hasSession) { + if (!client || !isPaneVisible || !isConnected || !hasSession) { return; } - if (missingAgentState.kind === "resolving" || missingAgentState.kind === "not_found") { + if ( + missingAgentState.kind === "resolving" || + missingAgentState.kind === "not_found" || + missingAgentState.kind === "error" + ) { return; } @@ -1033,11 +1011,13 @@ function ChatAgentContent({ }); }, [ agentState.id, + hasAppliedAuthoritativeHistory, agentId, client, ensureAgentIsInitialized, hasSession, isConnected, + isPaneVisible, missingAgentState.kind, serverId, ]); @@ -1061,6 +1041,7 @@ function ChatAgentContent({ viewState.tag === "ready" && viewState.sync.status === "catching_up" && viewState.sync.ui === "overlay"; + const showHistorySyncError = viewState.tag === "ready" && viewState.sync.status === "sync_error"; return ( void; handleMessageSent: () => void; showHistorySyncOverlay: boolean; + showHistorySyncError: boolean; cwd: string; onAttentionInputFocus: () => void; onAttentionPromptSend: () => void; @@ -1197,6 +1181,14 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ {contentContainer} + {showHistorySyncError ? ( + + ) : null} + {composerSection} {showHistorySyncOverlay ? ( diff --git a/packages/app/src/provider-selection/resolve-agent-form.test.ts b/packages/app/src/provider-selection/resolve-agent-form.test.ts index 992c3a8f0..a10de7b12 100644 --- a/packages/app/src/provider-selection/resolve-agent-form.test.ts +++ b/packages/app/src/provider-selection/resolve-agent-form.test.ts @@ -575,6 +575,25 @@ describe("resolveFormState", () => { expect(resolved.modeId).toBe("workspace-write"); }); + it("falls back when the provider cannot advertise its preferred default mode", () => { + const providerMap = makeProviderMap({ + ...TEST_CODEX_DEFINITION, + defaultModeId: "auto-review", + modes: TEST_CODEX_DEFINITION.modes, + }); + + const resolved = resolveFormState( + undefined, + { provider: "codex" }, + CODEX_MODELS, + INITIAL_USER_MODIFIED, + makeState({ provider: "codex" }).form, + providerMap, + ); + + expect(resolved.modeId).toBe("auto"); + }); + it("ignores disabled ready providers when resolving selectable defaults", () => { const entries: ProviderSnapshotEntry[] = [ { diff --git a/packages/app/src/provider-selection/resolve-agent-form.ts b/packages/app/src/provider-selection/resolve-agent-form.ts index 196b16bc2..56e84f5b1 100644 --- a/packages/app/src/provider-selection/resolve-agent-form.ts +++ b/packages/app/src/provider-selection/resolve-agent-form.ts @@ -171,7 +171,12 @@ function resolvePreferredModeId(input: { const preferredModeId = normalizeSelectedModeId(input.preferredModeId); if (preferredModeId) return preferredModeId; - return input.providerDef?.defaultModeId ?? input.providerDef?.modes[0]?.id ?? ""; + const defaultModeId = input.providerDef?.defaultModeId; + const modes = input.providerDef?.modes ?? []; + if (defaultModeId && (modes.length === 0 || modes.some((mode) => mode.id === defaultModeId))) { + return defaultModeId; + } + return modes[0]?.id ?? ""; } export function mergeSelectedComposerPreferences(args: { diff --git a/packages/app/src/provider-usage/settings-section.tsx b/packages/app/src/provider-usage/settings-section.tsx index 58da33fff..51da2d710 100644 --- a/packages/app/src/provider-usage/settings-section.tsx +++ b/packages/app/src/provider-usage/settings-section.tsx @@ -55,7 +55,7 @@ function ProviderUsageBody({ }) { if (view.kind === "loading") { return ( - + {providerUsageCopy.loading} ); @@ -73,7 +73,7 @@ function ProviderUsageBody({ if (view.payload.providers.length === 0) { return ( - + {providerUsageCopy.empty} ); @@ -92,5 +92,3 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, }, })); - -const EMPTY_CARD_STYLE = [settingsStyles.card, styles.emptyCard]; diff --git a/packages/app/src/runtime/directory-sync/agent-replica.test.ts b/packages/app/src/runtime/directory-sync/agent-replica.test.ts new file mode 100644 index 000000000..4456ecc0a --- /dev/null +++ b/packages/app/src/runtime/directory-sync/agent-replica.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; +import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; +import { useSessionStore } from "@/stores/session-store"; +import { AgentDirectoryReplica } from "./agent-replica"; + +function payload(title: string): AgentSnapshotPayload { + return { + id: "agent", + provider: "codex", + cwd: "/repo", + model: null, + createdAt: "2026-07-17T00:00:00.000Z", + updatedAt: "2026-07-17T00:01:00.000Z", + lastUserMessageAt: null, + status: "idle", + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title, + labels: {}, + }; +} + +function entry(agent: AgentSnapshotPayload): FetchAgentsEntry { + return { + agent, + project: { + projectKey: "/repo", + projectName: "repo", + checkout: { + cwd: "/repo", + isGit: false, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }, + }; +} + +describe("AgentDirectoryReplica", () => { + it("keeps membership authoritative across remove, stale timeline, and re-add", () => { + const serverId = "agent-replica"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new AgentDirectoryReplica(serverId, () => undefined); + replica.commitSnapshot([entry(payload("directory"))], []); + const directoryPlacement = useSessionStore + .getState() + .sessions[serverId]?.agents.get("agent")?.projectPlacement; + expect(directoryPlacement).toBeDefined(); + const staleToken = replica.captureTimeline("agent"); + + replica.remove("agent"); + expect(replica.submitTimelineAgent(staleToken, payload("stale"))).toBe(false); + expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(false); + + replica.applyDelta({ + kind: "upsert", + agent: payload("re-added"), + project: entry(payload("x")).project, + }); + expect(replica.submitTimelineAgent(staleToken, payload("still stale"))).toBe(false); + const currentToken = replica.captureTimeline("agent"); + expect(replica.submitTimelineAgent(currentToken, payload("current"))).toBe(true); + expect(useSessionStore.getState().sessions[serverId]?.agents.get("agent")?.title).toBe( + "current", + ); + expect( + useSessionStore.getState().sessions[serverId]?.agents.get("agent")?.projectPlacement, + ).toEqual(directoryPlacement); + store.clearSession(serverId); + }); +}); diff --git a/packages/app/src/runtime/directory-sync/agent-replica.ts b/packages/app/src/runtime/directory-sync/agent-replica.ts new file mode 100644 index 000000000..62de843cd --- /dev/null +++ b/packages/app/src/runtime/directory-sync/agent-replica.ts @@ -0,0 +1,121 @@ +import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; +import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; +import { clearArchiveAgentPending } from "@/hooks/use-archive-agent"; +import { queryClient } from "@/data/query-client"; +import { useSessionStore, type Agent } from "@/stores/session-store"; +import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import { + applyAgentDirectoryDelta, + type AgentDirectoryDelta, + removeAgentDirectoryReplica, + replaceAgentPendingPermissions, + replaceFetchedAgentDirectory, + upsertAgentReplica, +} from "@/utils/agent-directory-sync"; +import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation"; +import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces"; + +export interface AgentLifecycleToken { + readonly agentId: string; + readonly version: number; +} + +export class AgentDirectoryReplica { + private readonly lifecycleVersions = new Map(); + private readonly members = new Set(); + + constructor( + private readonly serverId: string, + private readonly onStoppedRunning: (agentId: string) => void, + ) {} + + captureTimeline(agentId: string): AgentLifecycleToken { + return { agentId, version: this.lifecycleVersions.get(agentId) ?? 0 }; + } + + submitTimelineAgent(token: AgentLifecycleToken, payload: AgentSnapshotPayload): boolean { + if ( + !this.members.has(token.agentId) || + token.version !== (this.lifecycleVersions.get(token.agentId) ?? 0) + ) { + return false; + } + const existing = useSessionStore.getState().sessions[this.serverId]?.agents.get(token.agentId); + const timelineAgent = applyLegacyDaemonWorkspaceOwnership({ + serverId: this.serverId, + agent: normalizeAgentSnapshot(payload, this.serverId), + }); + const normalized: Agent = { + ...timelineAgent, + projectPlacement: timelineAgent.projectPlacement ?? existing?.projectPlacement, + }; + const accepted = upsertAgentReplica(this.serverId, normalized); + replaceAgentPendingPermissions(this.serverId, accepted); + useSessionStore.getState().setAgentLastActivity(accepted.id, accepted.lastActivityAt); + if (accepted.archivedAt) { + clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId: accepted.id }); + } + return true; + } + + applyDelta(delta: AgentDirectoryDelta): void { + const before = this.members.has(delta.kind === "remove" ? delta.agentId : delta.agent.id); + const result = applyAgentDirectoryDelta({ serverId: this.serverId, delta }); + if (delta.kind === "remove") { + this.members.delete(delta.agentId); + this.advance(delta.agentId); + } else { + this.members.add(delta.agent.id); + if (!before) this.advance(delta.agent.id); + } + if (result.stoppedRunning) this.onStoppedRunning(result.agentId); + } + + commitSnapshot( + entries: FetchAgentsEntry[], + deltas: readonly AgentDirectoryDelta[], + ): Map { + const previous = useSessionStore.getState().sessions[this.serverId]?.agents ?? new Map(); + const reconciled = reconcileAgentDirectory({ previous, snapshot: entries, deltas }); + const nextIds = new Set(reconciled.entries.map((entry) => entry.agent.id)); + for (const agentId of this.members) { + if (!nextIds.has(agentId)) this.advance(agentId); + } + for (const agentId of nextIds) { + if (!this.members.has(agentId)) this.advance(agentId); + } + for (const agentId of previous.keys()) { + if (!nextIds.has(agentId)) removeAgentDirectoryReplica(this.serverId, agentId); + } + this.members.clear(); + for (const agentId of nextIds) this.members.add(agentId); + const { agents } = replaceFetchedAgentDirectory({ + serverId: this.serverId, + entries: reconciled.entries, + }); + for (const agentId of reconciled.stoppedRunningAgentIds) this.onStoppedRunning(agentId); + return agents; + } + + archive(agentId: string, archivedAt: string): void { + this.advance(agentId); + useSessionStore.getState().setAgents(this.serverId, (current) => { + const agent = current.get(agentId); + if (!agent) return current; + const next = new Map(current); + next.set(agentId, { ...agent, archivedAt: new Date(archivedAt) }); + return next; + }); + clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId }); + } + + remove(agentId: string): void { + this.members.delete(agentId); + this.advance(agentId); + removeAgentDirectoryReplica(this.serverId, agentId); + } + + private advance(agentId: string): void { + this.lifecycleVersions.set(agentId, (this.lifecycleVersions.get(agentId) ?? 0) + 1); + } +} diff --git a/packages/app/src/runtime/directory-sync/index.test.ts b/packages/app/src/runtime/directory-sync/index.test.ts new file mode 100644 index 000000000..727967713 --- /dev/null +++ b/packages/app/src/runtime/directory-sync/index.test.ts @@ -0,0 +1,257 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { useSessionStore } from "@/stores/session-store"; +import { DirectoryRefreshSupersededError, DirectorySync } from "./index"; + +type WorkspaceFetchResult = Awaited>; + +class FakeDirectoryClient { + fetchAgentsCalls = 0; + fetchWorkspacesCalls = 0; + private pendingWorkspaceFetch: Promise | null = null; + private readonly handlers = new Map< + SessionOutboundMessage["type"], + Set<(message: SessionOutboundMessage) => void> + >(); + + on( + type: TType, + handler: (message: Extract) => void, + ): () => void { + const handlers = this.handlers.get(type) ?? new Set(); + const registered = handler as unknown as (message: SessionOutboundMessage) => void; + handlers.add(registered); + this.handlers.set(type, handlers); + return () => handlers.delete(registered); + } + + emit( + message: Extract, + ): void { + for (const handler of this.handlers.get(message.type) ?? []) handler(message); + } + + holdWorkspaceFetch(): (result: WorkspaceFetchResult) => void { + let complete!: (result: WorkspaceFetchResult) => void; + this.pendingWorkspaceFetch = new Promise((resolve) => { + complete = resolve; + }); + return complete; + } + + async fetchAgents(): Promise>> { + this.fetchAgentsCalls += 1; + return { + requestId: "agents", + entries: [], + pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, + }; + } + + async fetchWorkspaces(): Promise { + this.fetchWorkspacesCalls += 1; + if (this.pendingWorkspaceFetch) { + const pending = this.pendingWorkspaceFetch; + this.pendingWorkspaceFetch = null; + return pending; + } + return { + requestId: "workspaces", + entries: [], + emptyProjects: [], + pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, + }; + } +} + +const serverIds = new Set(); + +function createDirectory(serverId: string): { + client: FakeDirectoryClient; + directory: DirectorySync; +} { + serverIds.add(serverId); + const client = new FakeDirectoryClient(); + const directory = new DirectorySync(serverId, { + drainQueuedAgentMessage: () => undefined, + markAgentLoading: () => undefined, + markAgentReady: () => undefined, + markAgentError: () => undefined, + }); + directory.connectionChanged({ + client: client as unknown as DaemonClient, + status: "online", + source: { clientGeneration: 1, connectionEpoch: 1 }, + }); + return { client, directory }; +} + +afterEach(() => { + for (const serverId of serverIds) useSessionStore.getState().clearSession(serverId); + serverIds.clear(); +}); + +describe("DirectorySync session readiness", () => { + it("waits for workspace capability metadata before choosing the workspace protocol", async () => { + const serverId = "workspace-metadata"; + const { client, directory } = createDirectory(serverId); + + const refresh = directory.refreshWorkspaces({ subscribe: true }); + await Promise.resolve(); + expect(client.fetchWorkspacesCalls).toBe(0); + + const store = useSessionStore.getState(); + store.initializeSession(serverId, client as unknown as DaemonClient, 1); + await Promise.resolve(); + expect(client.fetchWorkspacesCalls).toBe(0); + + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { workspaceMultiplicity: true }, + }); + await refresh; + + expect(client.fetchWorkspacesCalls).toBe(1); + expect(useSessionStore.getState().sessions[serverId]?.hasHydratedWorkspaces).toBe(true); + 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); + const staleRefresh = directory.refreshAgents(); + await Promise.resolve(); + + directory.connectionChanged({ + client: null, + status: "offline", + source: { clientGeneration: 1, connectionEpoch: 1 }, + }); + await expect(staleRefresh).rejects.toBeInstanceOf(DirectoryRefreshSupersededError); + + directory.connectionChanged({ + client: client as unknown as DaemonClient, + status: "online", + source: { clientGeneration: 1, connectionEpoch: 2 }, + }); + const currentRefresh = directory.refreshAgents(); + useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); + await currentRefresh; + + expect(client.fetchAgentsCalls).toBe(1); + directory.dispose(); + }); + + it("buffers workspace and project updates in the same hydration transaction", async () => { + const serverId = "workspace-project-transaction"; + const { client, directory } = createDirectory(serverId); + const store = useSessionStore.getState(); + store.initializeSession(serverId, client as unknown as DaemonClient, 1); + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { workspaceMultiplicity: true }, + }); + const completeFetch = client.holdWorkspaceFetch(); + + const refresh = directory.refreshWorkspaces({ subscribe: true }); + await Promise.resolve(); + client.emit({ + type: "workspace_update", + payload: { + kind: "remove", + id: "removed-workspace", + emptyProject: { + projectId: "workspace-project", + projectDisplayName: "Project from workspace update", + projectRootPath: "/repo/workspace-project", + projectKind: "git", + }, + }, + }); + client.emit({ + type: "project.update", + payload: { + kind: "upsert", + project: { + projectId: "snapshot-project", + projectDisplayName: "Renamed during hydration", + projectRootPath: "/moved/snapshot-project", + projectKind: "directory", + }, + }, + }); + completeFetch({ + requestId: "workspaces", + entries: [], + emptyProjects: [ + { + projectId: "snapshot-project", + projectDisplayName: "Stale snapshot project", + projectRootPath: "/repo/snapshot-project", + projectKind: "git", + }, + ], + pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, + }); + await refresh; + + const emptyProjects = useSessionStore.getState().sessions[serverId]?.emptyProjects; + expect(Array.from(emptyProjects?.keys() ?? [])).toEqual([ + "snapshot-project", + "workspace-project", + ]); + expect(emptyProjects?.get("snapshot-project")).toMatchObject({ + projectDisplayName: "Renamed during hydration", + projectRootPath: "/moved/snapshot-project", + projectKind: "directory", + }); + expect(emptyProjects?.get("workspace-project")).toMatchObject({ + projectDisplayName: "Project from workspace update", + }); + directory.dispose(); + }); + + it("buffers project updates from the online epoch before workspace hydration starts", async () => { + const serverId = "project-before-workspace-hydration"; + const { client, directory } = createDirectory(serverId); + const store = useSessionStore.getState(); + store.initializeSession(serverId, client as unknown as DaemonClient, 1); + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { workspaceMultiplicity: true }, + }); + + client.emit({ + type: "project.update", + payload: { + kind: "upsert", + project: { + projectId: "early-project", + projectDisplayName: "Early project", + projectRootPath: "/repo/early-project", + projectKind: "git", + }, + }, + }); + + expect(useSessionStore.getState().sessions[serverId]?.hasHydratedWorkspaces).toBe(false); + + await directory.refreshWorkspaces({ subscribe: true }); + + expect(useSessionStore.getState().sessions[serverId]?.hasHydratedWorkspaces).toBe(true); + expect( + useSessionStore.getState().sessions[serverId]?.emptyProjects.get("early-project"), + ).toMatchObject({ + projectDisplayName: "Early project", + projectRootPath: "/repo/early-project", + }); + directory.dispose(); + }); +}); diff --git a/packages/app/src/runtime/directory-sync/index.ts b/packages/app/src/runtime/directory-sync/index.ts new file mode 100644 index 000000000..317eabf6a --- /dev/null +++ b/packages/app/src/runtime/directory-sync/index.ts @@ -0,0 +1,426 @@ +import type { + DaemonClient, + FetchAgentsEntry, + FetchAgentsOptions, +} from "@getpaseo/client/internal/daemon-client"; +import { fetchAgentTimelineOnce } from "@/timeline/fetch-agent-timeline-once"; +import { + normalizeEmptyProjectDescriptor, + normalizeWorkspaceDescriptor, + useSessionStore, + type Agent, +} from "@/stores/session-store"; +import { + readLegacyDaemonWorkspaceDirectory, + buildLegacyWorkspaces, + shouldUseLegacyDaemonWorkspaceDirectory, + stampLegacyWorkspaceIds, +} from "@/workspace/legacy-daemon-workspaces"; +import type { AgentDirectoryDelta } from "@/utils/agent-directory-sync"; +import { AgentDirectoryReplica } from "./agent-replica"; +import { + WorkspaceDirectoryReplica, + type WorkspaceDirectoryDelta, + type WorkspaceDirectorySnapshot, +} from "./workspace-replica"; +import { + DirectoryTransactionOwner, + type DirectorySourceToken, + type DirectoryTransaction, +} from "./transaction"; + +const PAGE_LIMIT = 200; +const AGENT_SORT: NonNullable = [ + { key: "updated_at", direction: "desc" }, +]; + +interface AgentSnapshot { + entries: FetchAgentsEntry[]; + subscriptionId: string | null; + legacy: boolean; +} + +export interface DirectoryConnection { + client: DaemonClient | null; + status: "online" | "offline"; + source: DirectorySourceToken; +} + +export interface RefreshAgentDirectoryInput { + filter?: FetchAgentsOptions["filter"]; + subscribe?: FetchAgentsOptions["subscribe"]; + page?: FetchAgentsOptions["page"]; +} + +export interface RefreshAgentDirectoryResult { + agents: Map; + subscriptionId: string | null; +} + +export class DirectorySync { + private readonly agentTransactions = new DirectoryTransactionOwner< + AgentSnapshot, + AgentDirectoryDelta + >(); + private readonly workspaceTransactions = new DirectoryTransactionOwner< + WorkspaceDirectorySnapshot, + WorkspaceDirectoryDelta + >(); + private readonly agents: AgentDirectoryReplica; + private readonly workspaces: WorkspaceDirectoryReplica; + private connection: DirectoryConnection = { + client: null, + status: "offline", + source: { clientGeneration: 0, connectionEpoch: 0 }, + }; + private unsubscribe: (() => void) | null = null; + private readonly abortSessionWaits = new Set<() => void>(); + + constructor( + private readonly serverId: string, + private readonly callbacks: { + drainQueuedAgentMessage: (agentId: string) => void; + markAgentLoading: () => void; + markAgentReady: () => void; + markAgentError: (error: string) => void; + }, + ) { + this.agents = new AgentDirectoryReplica(serverId, callbacks.drainQueuedAgentMessage); + this.workspaces = new WorkspaceDirectoryReplica(serverId); + } + + connectionChanged(connection: DirectoryConnection): boolean { + const changed = + this.connection.client !== connection.client || + this.connection.source.clientGeneration !== connection.source.clientGeneration || + this.connection.source.connectionEpoch !== connection.source.connectionEpoch; + const wentOffline = this.connection.status === "online" && connection.status === "offline"; + if (!changed && !wentOffline) { + this.connection = connection; + return false; + } + this.flushAbortedTransactions(); + this.unsubscribe?.(); + this.unsubscribe = null; + this.connection = connection; + this.abortPendingSessionWaits(); + if (!connection.client || connection.status !== "online") return true; + const client = connection.client; + const source = connection.source; + this.workspaceTransactions.begin(source, () => ({ + workspaces: new Map(), + emptyProjects: new Map(), + })); + const subscriptions = [ + client.on("agent_update", (message) => { + if (message.type !== "agent_update" || !this.isCurrent(client, source)) return; + if (!this.agentTransactions.record(source, message.payload)) + this.agents.applyDelta(message.payload); + }), + client.on("workspace_update", (message) => { + if (message.type !== "workspace_update" || !this.isCurrent(client, source)) return; + if (!this.workspaceTransactions.record(source, message.payload)) { + this.workspaces.applyDelta(message.payload); + } + }), + client.on("project.update", (message) => { + if (message.type !== "project.update" || !this.isCurrent(client, source)) return; + if (!this.workspaceTransactions.record(source, message.payload)) { + this.workspaces.applyDelta(message.payload); + } + }), + client.on("agent_deleted", (message) => { + if (message.type === "agent_deleted" && this.isCurrent(client, source)) { + this.agents.remove(message.payload.agentId); + } + }), + client.on("agent_archived", (message) => { + if (message.type === "agent_archived" && this.isCurrent(client, source)) { + this.agents.archive(message.payload.agentId, message.payload.archivedAt); + } + }), + ]; + this.unsubscribe = () => { + for (const unsubscribe of subscriptions) unsubscribe(); + }; + return true; + } + + dispose(): void { + this.flushAbortedTransactions(); + this.abortPendingSessionWaits(); + this.unsubscribe?.(); + this.unsubscribe = null; + } + + async fetchTimeline( + agentId: string, + request: Parameters[1], + ): Promise>> { + const { client } = this.requireOnline(); + const token = this.agents.captureTimeline(agentId); + const page = await fetchAgentTimelineOnce(client, agentId, request); + if (page.agent) this.agents.submitTimelineAgent(token, page.agent); + return page; + } + + async refreshAgents( + input: RefreshAgentDirectoryInput = {}, + ): Promise { + const { client, source } = this.requireOnline(); + const transaction = this.agentTransactions.begin(source, () => ({ + entries: [], + subscriptionId: null, + legacy: false, + })); + this.callbacks.markAgentLoading(); + try { + await this.waitForSession(client, source); + const session = useSessionStore.getState().sessions[this.serverId]; + if (!input.filter && shouldUseLegacyDaemonWorkspaceDirectory(session?.serverInfo)) { + const directory = await readLegacyDaemonWorkspaceDirectory({ + client, + subscribe: input.subscribe, + page: input.page, + }); + if ( + !directory || + !this.agentTransactions.isCurrent(transaction) || + !this.isCurrent(client, source) + ) { + throw new DirectoryRefreshSupersededError("legacy fetch no longer current"); + } + transaction.snapshot.entries.push(...stampLegacyWorkspaceIds(directory.entries)); + transaction.snapshot.subscriptionId = directory.subscriptionId; + transaction.snapshot.legacy = true; + } else { + await this.fetchAgents(client, source, transaction, input); + } + if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) { + throw new DirectoryRefreshSupersededError("agent completion no longer current"); + } + const completion = this.agentTransactions.complete(transaction); + if (completion.kind === "stale") { + throw new DirectoryRefreshSupersededError("agent completion was superseded"); + } + if (completion.snapshot.legacy) { + const store = useSessionStore.getState(); + store.setWorkspaces(this.serverId, buildLegacyWorkspaces(completion.snapshot.entries)); + store.setEmptyProjects(this.serverId, []); + store.setHasHydratedWorkspaces(this.serverId, true); + } + const deltas = completion.snapshot.legacy + ? completion.deltas.map((delta) => + delta.kind === "upsert" + ? { ...delta, agent: { ...delta.agent, workspaceId: delta.agent.cwd } } + : delta, + ) + : completion.deltas; + const agents = this.agents.commitSnapshot(completion.snapshot.entries, deltas); + this.callbacks.markAgentReady(); + return { agents, subscriptionId: completion.snapshot.subscriptionId }; + } catch (error) { + const deltas = this.agentTransactions.fail(transaction); + if (deltas) for (const delta of deltas) this.agents.applyDelta(delta); + if (!(error instanceof DirectoryRefreshSupersededError)) { + this.callbacks.markAgentError(error instanceof Error ? error.message : String(error)); + } + throw error; + } + } + + async refreshWorkspaces(input?: { subscribe?: boolean }): Promise { + const { client, source } = this.requireOnline(); + const transaction = this.workspaceTransactions.begin(source, () => ({ + workspaces: new Map(), + emptyProjects: new Map(), + })); + try { + await this.waitForSessionMetadata(client, source); + const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo; + if (serverInfo?.features?.workspaceMultiplicity !== true) { + const deltas = this.workspaceTransactions.fail(transaction); + if (deltas) for (const delta of deltas) this.workspaces.applyDelta(delta); + return; + } + await this.fetchWorkspaceSnapshot(client, source, transaction, input?.subscribe === true); + if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) { + throw new DirectoryRefreshSupersededError("workspace completion no longer current"); + } + const completion = this.workspaceTransactions.complete(transaction); + if (completion.kind === "stale") { + throw new DirectoryRefreshSupersededError("workspace completion was superseded"); + } + this.workspaces.commitSnapshot(completion.snapshot, completion.deltas); + } catch (error) { + const deltas = this.workspaceTransactions.fail(transaction); + if (deltas) for (const delta of deltas) this.workspaces.applyDelta(delta); + throw error; + } + } + + private async fetchWorkspaceSnapshot( + client: DaemonClient, + source: DirectorySourceToken, + transaction: DirectoryTransaction, + initialSubscribe: boolean, + ): Promise { + let cursor: string | null = null; + let subscribe = initialSubscribe; + while (true) { + const payload = await client.fetchWorkspaces({ + sort: [{ key: "activity_at", direction: "desc" }], + ...(subscribe ? { subscribe: {} } : {}), + page: cursor ? { limit: PAGE_LIMIT, cursor } : { limit: PAGE_LIMIT }, + }); + this.assertWorkspaceTransactionCurrent(client, source, transaction); + for (const entry of payload.entries) { + const workspace = normalizeWorkspaceDescriptor(entry); + transaction.snapshot.workspaces.set(workspace.id, workspace); + } + for (const entry of payload.emptyProjects ?? []) { + const project = normalizeEmptyProjectDescriptor(entry); + transaction.snapshot.emptyProjects.set(project.projectId, project); + } + if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) return; + cursor = payload.pageInfo.nextCursor; + subscribe = false; + } + } + + async refreshAll(): Promise { + await Promise.all([this.refreshAgents(), this.refreshWorkspaces({ subscribe: true })]); + } + + private async fetchAgents( + client: DaemonClient, + source: DirectorySourceToken, + transaction: DirectoryTransaction, + input: RefreshAgentDirectoryInput, + ): Promise { + let cursor = input.page?.cursor ?? null; + let subscribe = input.subscribe; + while (true) { + const limit = input.page?.limit ?? PAGE_LIMIT; + const payload = await client.fetchAgents({ + ...(input.filter ? { filter: input.filter } : { scope: "active" as const }), + sort: AGENT_SORT, + ...(subscribe ? { subscribe } : {}), + page: cursor ? { limit, cursor } : { limit }, + }); + this.assertAgentTransactionCurrent(client, source, transaction); + transaction.snapshot.entries.push(...payload.entries); + transaction.snapshot.subscriptionId ??= payload.subscriptionId ?? null; + const pageInfo = payload.pageInfo as { + hasMore?: boolean; + hasMoreAfter?: boolean; + nextCursor?: string | null; + afterCursor?: string | null; + }; + const hasMore = pageInfo.hasMore ?? pageInfo.hasMoreAfter ?? false; + const nextCursor = pageInfo.nextCursor ?? pageInfo.afterCursor ?? null; + if (!hasMore || !nextCursor) break; + cursor = nextCursor; + subscribe = undefined; + } + } + + private assertAgentTransactionCurrent( + client: DaemonClient, + source: DirectorySourceToken, + transaction: DirectoryTransaction, + ): void { + if (!this.agentTransactions.isCurrent(transaction) || !this.isCurrent(client, source)) { + throw new DirectoryRefreshSupersededError("agent page no longer current"); + } + } + + private assertWorkspaceTransactionCurrent( + client: DaemonClient, + source: DirectorySourceToken, + transaction: DirectoryTransaction, + ): void { + if (!this.workspaceTransactions.isCurrent(transaction) || !this.isCurrent(client, source)) { + throw new DirectoryRefreshSupersededError("workspace fetch no longer current"); + } + } + + private requireOnline(): { client: DaemonClient; source: DirectorySourceToken } { + if (!this.connection.client || this.connection.status !== "online") { + throw new Error(`Host ${this.serverId} is not connected`); + } + return { client: this.connection.client, source: this.connection.source }; + } + + private isCurrent(client: DaemonClient, source: DirectorySourceToken): boolean { + return ( + this.connection.client === client && + this.connection.status === "online" && + this.connection.source.clientGeneration === source.clientGeneration && + this.connection.source.connectionEpoch === source.connectionEpoch + ); + } + + private async waitForSession(client: DaemonClient, source: DirectorySourceToken): Promise { + await this.waitForSessionState(client, source, () => this.hasMatchingSession(client, source)); + } + + private async waitForSessionMetadata( + client: DaemonClient, + source: DirectorySourceToken, + ): Promise { + await this.waitForSessionState(client, source, () => { + const session = useSessionStore.getState().sessions[this.serverId]; + return this.hasMatchingSession(client, source) && session?.serverInfo !== null; + }); + } + + private async waitForSessionState( + client: DaemonClient, + source: DirectorySourceToken, + matches: () => boolean, + ): Promise { + if (matches()) return; + await new Promise((resolve, reject) => { + let settled = false; + let unsubscribe: () => void = () => undefined; + const finish = (result: "ready" | "aborted") => { + if (settled) return; + settled = true; + unsubscribe(); + this.abortSessionWaits.delete(abort); + if (result === "ready") resolve(); + else reject(new DirectoryRefreshSupersededError("session wait no longer current")); + }; + const abort = () => finish("aborted"); + const check = () => { + if (matches()) { + finish("ready"); + } else if (!this.isCurrent(client, source)) { + finish("aborted"); + } + }; + this.abortSessionWaits.add(abort); + unsubscribe = useSessionStore.subscribe(check); + check(); + }); + } + + private hasMatchingSession(client: DaemonClient, source: DirectorySourceToken): boolean { + const session = useSessionStore.getState().sessions[this.serverId]; + return session?.client === client && session.clientGeneration === source.clientGeneration; + } + + private flushAbortedTransactions(): void { + for (const delta of this.agentTransactions.abort()) this.agents.applyDelta(delta); + for (const delta of this.workspaceTransactions.abort()) this.workspaces.applyDelta(delta); + } + + private abortPendingSessionWaits(): void { + for (const abort of this.abortSessionWaits) abort(); + } +} + +export class DirectoryRefreshSupersededError extends Error {} + +export type { DirectorySourceToken } from "./transaction"; diff --git a/packages/app/src/runtime/directory-sync/transaction.test.ts b/packages/app/src/runtime/directory-sync/transaction.test.ts new file mode 100644 index 000000000..bc8002704 --- /dev/null +++ b/packages/app/src/runtime/directory-sync/transaction.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { DirectoryTransactionOwner, type DirectorySourceToken } from "./transaction"; + +const epoch = (connectionEpoch: number): DirectorySourceToken => ({ + clientGeneration: 1, + connectionEpoch, +}); + +type Event = "begin-new" | "first" | "second" | "finish-old" | "finish-new"; + +function permutations(events: Event[]): Event[][] { + if (events.length === 0) return [[]]; + return events.flatMap((event, index) => + permutations(events.filter((_, candidate) => candidate !== index)).map((tail) => + [event].concat(tail), + ), + ); +} + +describe("DirectoryTransactionOwner", () => { + it("applies every bounded two-refresh interleaving exactly once", () => { + const schedules = permutations([ + "begin-new", + "first", + "second", + "finish-old", + "finish-new", + ]).filter((events) => events.indexOf("begin-new") < events.indexOf("finish-new")); + expect(schedules).toHaveLength(60); + for (const events of schedules) { + const owner = new DirectoryTransactionOwner(); + const old = owner.begin(epoch(1), () => null); + let current = old; + const applied: string[] = []; + for (const event of events) { + if (event === "begin-new") current = owner.begin(epoch(1), () => null); + if (event === "first" || event === "second") { + if (!owner.record(epoch(1), event)) applied.push(event); + } + if (event === "finish-old") { + const result = owner.complete(old); + if (result.kind === "current") applied.push(...result.deltas); + } + if (event === "finish-new") { + const result = owner.complete(current); + if (result.kind === "current") applied.push(...result.deltas); + } + } + expect(applied.sort(), JSON.stringify(events)).toEqual(["first", "second"]); + } + }); + + it.each([ + ["success", false, false, ["delta"]], + ["failure", true, false, ["delta"]], + ["superseded success", false, true, ["delta"]], + ["superseded failure", true, true, ["delta"]], + ] as const)("preserves buffered deltas through %s", (_name, fail, supersede, expected) => { + const owner = new DirectoryTransactionOwner(); + const first = owner.begin(epoch(1), () => []); + owner.record(epoch(1), "delta"); + const current = supersede ? owner.begin(epoch(1), () => []) : first; + + let deltas: readonly string[] | null; + if (fail) { + deltas = owner.fail(current); + } else { + const completion = owner.complete(current); + deltas = completion.kind === "current" ? completion.deltas : null; + } + + expect(deltas).toEqual(expected); + if (supersede) expect(owner.complete(first)).toEqual({ kind: "stale" }); + }); + + it("makes a stale completion inert", () => { + const owner = new DirectoryTransactionOwner(); + const first = owner.begin(epoch(1), () => null); + const second = owner.begin(epoch(1), () => null); + expect(owner.complete(first)).toEqual({ kind: "stale" }); + expect(owner.isCurrent(second)).toBe(true); + }); + + it("isolates reconnect epochs", () => { + const owner = new DirectoryTransactionOwner(); + const disconnected = owner.begin(epoch(1), () => null); + owner.record(epoch(1), "old"); + + expect(owner.abort()).toEqual(["old"]); + const reconnected = owner.begin(epoch(2), () => null); + owner.record(epoch(2), "new"); + + expect(owner.complete(disconnected)).toEqual({ kind: "stale" }); + expect(owner.complete(reconnected)).toEqual({ + kind: "current", + snapshot: null, + deltas: ["new"], + }); + }); +}); diff --git a/packages/app/src/runtime/directory-sync/transaction.ts b/packages/app/src/runtime/directory-sync/transaction.ts new file mode 100644 index 000000000..15851491c --- /dev/null +++ b/packages/app/src/runtime/directory-sync/transaction.ts @@ -0,0 +1,70 @@ +export interface DirectorySourceToken { + clientGeneration: number; + connectionEpoch: number; +} + +export interface DirectoryTransaction { + readonly source: DirectorySourceToken; + readonly snapshot: TSnapshot; + readonly deltas: TDelta[]; +} + +export type DirectoryTransactionCompletion = + | { kind: "stale" } + | { kind: "current"; snapshot: TSnapshot; deltas: readonly TDelta[] }; + +function isSameSource(left: DirectorySourceToken, right: DirectorySourceToken): boolean { + return ( + left.clientGeneration === right.clientGeneration && + left.connectionEpoch === right.connectionEpoch + ); +} + +/** Owns the shared begin/record/supersede/complete lifecycle for a directory replica. */ +export class DirectoryTransactionOwner { + private current: DirectoryTransaction | null = null; + + begin( + source: DirectorySourceToken, + createSnapshot: () => TSnapshot, + ): DirectoryTransaction { + const transaction: DirectoryTransaction = { + source, + snapshot: createSnapshot(), + deltas: + this.current && isSameSource(this.current.source, source) ? [...this.current.deltas] : [], + }; + this.current = transaction; + return transaction; + } + + record(source: DirectorySourceToken, delta: TDelta): boolean { + if (!this.current || !isSameSource(this.current.source, source)) return false; + this.current.deltas.push(delta); + return true; + } + + isCurrent(transaction: DirectoryTransaction): boolean { + return this.current === transaction; + } + + complete( + transaction: DirectoryTransaction, + ): DirectoryTransactionCompletion { + if (this.current !== transaction) return { kind: "stale" }; + this.current = null; + return { kind: "current", snapshot: transaction.snapshot, deltas: transaction.deltas }; + } + + fail(transaction: DirectoryTransaction): readonly TDelta[] | null { + const completion = this.complete(transaction); + return completion.kind === "current" ? completion.deltas : null; + } + + abort(): readonly TDelta[] { + if (!this.current) return []; + const deltas = [...this.current.deltas]; + this.current = null; + return deltas; + } +} diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.test.ts b/packages/app/src/runtime/directory-sync/workspace-replica.test.ts new file mode 100644 index 000000000..f72b78102 --- /dev/null +++ b/packages/app/src/runtime/directory-sync/workspace-replica.test.ts @@ -0,0 +1,145 @@ +import { expect, it } from "vitest"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages"; +import { + normalizeEmptyProjectDescriptor, + normalizeWorkspaceDescriptor, + useSessionStore, +} from "@/stores/session-store"; +import { WorkspaceDirectoryReplica } from "./workspace-replica"; + +function workspace(id: string, projectId = "project"): WorkspaceDescriptorPayload { + return { + id, + projectId, + projectDisplayName: projectId, + projectRootPath: `/repo/${projectId}`, + workspaceDirectory: `/repo/${projectId}/${id}`, + projectKind: "git", + workspaceKind: "worktree", + name: id, + title: id, + status: "done", + activityAt: null, + statusEnteredAt: null, + archivingAt: null, + diffStat: null, + scripts: [], + }; +} + +it("commits workspace and project-parent state with filtered removals", () => { + const serverId = "workspace-replica"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new WorkspaceDirectoryReplica(serverId); + const empty = normalizeEmptyProjectDescriptor({ + projectId: "empty", + projectDisplayName: "Empty", + projectRootPath: "/repo/empty", + projectKind: "git", + }); + replica.commitSnapshot( + { + workspaces: new Map([ + ["kept", normalizeWorkspaceDescriptor(workspace("kept"))], + ["filtered", normalizeWorkspaceDescriptor(workspace("filtered", "filtered-project"))], + ]), + emptyProjects: new Map([[empty.projectId, empty]]), + }, + [{ kind: "remove", id: "filtered", removedProjectId: "filtered-project" }], + ); + + const session = useSessionStore.getState().sessions[serverId]; + expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["kept"]); + expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual(["empty"]); + store.clearSession(serverId); +}); + +it("commits the authoritative snapshot before buffered project updates", () => { + const serverId = "project-update-replica"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new WorkspaceDirectoryReplica(serverId); + const attachedMain = normalizeWorkspaceDescriptor(workspace("attached-main", "attached")); + const attachedFeature = normalizeWorkspaceDescriptor(workspace("attached-feature", "attached")); + const removed = normalizeWorkspaceDescriptor(workspace("removed", "removed")); + const unrelated = normalizeWorkspaceDescriptor(workspace("unrelated", "unrelated")); + const staleAttachedProject = normalizeEmptyProjectDescriptor({ + projectId: "attached", + projectDisplayName: "Stale attached project", + projectRootPath: "/repo/attached", + projectKind: "git", + }); + const removedProject = normalizeEmptyProjectDescriptor({ + projectId: "removed", + projectDisplayName: "Removed project", + projectRootPath: "/repo/removed", + projectKind: "git", + }); + const unchangedEmptyProject = normalizeEmptyProjectDescriptor({ + projectId: "unchanged-empty", + projectDisplayName: "Unchanged empty project", + projectRootPath: "/repo/unchanged-empty", + projectKind: "git", + }); + + replica.commitSnapshot( + { + workspaces: new Map([ + [attachedMain.id, attachedMain], + [attachedFeature.id, attachedFeature], + [removed.id, removed], + [unrelated.id, unrelated], + ]), + emptyProjects: new Map([ + [staleAttachedProject.projectId, staleAttachedProject], + [removedProject.projectId, removedProject], + [unchangedEmptyProject.projectId, unchangedEmptyProject], + ]), + }, + [ + { + kind: "upsert", + project: { + projectId: "attached", + projectDisplayName: "Renamed attached project", + projectCustomName: "Personal name", + projectRootPath: "/moved/attached", + projectKind: "directory", + }, + }, + { + kind: "upsert", + project: { + projectId: "new-empty", + projectDisplayName: "New empty project", + projectRootPath: "/repo/new-empty", + projectKind: "directory", + }, + }, + { kind: "remove", projectId: "removed" }, + ], + ); + + const session = useSessionStore.getState().sessions[serverId]; + expect(session?.workspaces.get(attachedMain.id)).toMatchObject({ + projectDisplayName: "Renamed attached project", + projectCustomName: "Personal name", + projectRootPath: "/moved/attached", + projectKind: "directory", + }); + expect(session?.workspaces.get(attachedFeature.id)).toMatchObject({ + projectDisplayName: "Renamed attached project", + projectRootPath: "/moved/attached", + }); + expect(session?.workspaces.has(removed.id)).toBe(false); + expect(session?.workspaces.get(unrelated.id)).toBe(unrelated); + expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual(["unchanged-empty", "new-empty"]); + expect(session?.emptyProjects.get("unchanged-empty")).toBe(unchangedEmptyProject); + expect(session?.emptyProjects.get("new-empty")).toMatchObject({ + projectDisplayName: "New empty project", + projectRootPath: "/repo/new-empty", + }); + store.clearSession(serverId); +}); diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.ts b/packages/app/src/runtime/directory-sync/workspace-replica.ts new file mode 100644 index 000000000..e78a2b557 --- /dev/null +++ b/packages/app/src/runtime/directory-sync/workspace-replica.ts @@ -0,0 +1,127 @@ +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { + normalizeEmptyProjectDescriptor, + normalizeWorkspaceDescriptor, + useSessionStore, + type EmptyProjectDescriptor, + type WorkspaceDescriptor, +} from "@/stores/session-store"; +import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; +import { + clearWorkspaceArchivePending, + shouldSuppressWorkspaceForLocalArchive, +} from "@/contexts/session-workspace-upserts"; + +export type WorkspaceDirectoryDelta = Extract< + SessionOutboundMessage, + { type: "workspace_update" | "project.update" } +>["payload"]; +type ProjectDirectoryDelta = Extract["payload"]; + +export interface WorkspaceDirectorySnapshot { + workspaces: Map; + emptyProjects: Map; +} + +function applyProjectDelta( + snapshot: WorkspaceDirectorySnapshot, + delta: ProjectDirectoryDelta, +): void { + if (delta.kind === "remove") { + snapshot.emptyProjects.delete(delta.projectId); + for (const [workspaceId, workspace] of snapshot.workspaces) { + if (workspace.projectId === delta.projectId) snapshot.workspaces.delete(workspaceId); + } + return; + } + + const project = normalizeEmptyProjectDescriptor(delta.project); + let hasAttachedWorkspace = false; + for (const [workspaceId, workspace] of snapshot.workspaces) { + if (workspace.projectId !== project.projectId) continue; + hasAttachedWorkspace = true; + snapshot.workspaces.set(workspaceId, { + ...workspace, + projectDisplayName: project.projectDisplayName, + projectCustomName: project.projectCustomName, + projectRootPath: project.projectRootPath, + projectKind: project.projectKind, + }); + } + if (hasAttachedWorkspace) snapshot.emptyProjects.delete(project.projectId); + else snapshot.emptyProjects.set(project.projectId, project); +} + +export class WorkspaceDirectoryReplica { + constructor(private readonly serverId: string) {} + + applyDelta(delta: WorkspaceDirectoryDelta): void { + const state = this.reconcile(this.read(), [delta]); + this.commit(state, delta.kind === "remove" && "id" in delta ? [delta.id] : []); + } + + commitSnapshot( + snapshot: WorkspaceDirectorySnapshot, + deltas: readonly WorkspaceDirectoryDelta[], + ): void { + const removedWorkspaceIds = deltas.flatMap((delta) => + delta.kind === "remove" && "id" in delta ? [delta.id] : [], + ); + this.commit(this.reconcile(snapshot, deltas), removedWorkspaceIds); + useSessionStore.getState().setHasHydratedWorkspaces(this.serverId, true); + } + + private read(): WorkspaceDirectorySnapshot { + const session = useSessionStore.getState().sessions[this.serverId]; + return { + workspaces: new Map(session?.workspaces), + emptyProjects: new Map(session?.emptyProjects), + }; + } + + private reconcile( + snapshot: WorkspaceDirectorySnapshot, + deltas: readonly WorkspaceDirectoryDelta[], + ): WorkspaceDirectorySnapshot { + const workspaces = new Map(snapshot.workspaces); + const emptyProjects = new Map(snapshot.emptyProjects); + for (const [workspaceId, workspace] of workspaces) { + if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) { + workspaces.delete(workspaceId); + } + } + for (const delta of deltas) { + if ("projectId" in delta || "project" in delta) { + applyProjectDelta({ workspaces, emptyProjects }, delta); + continue; + } + if (delta.kind === "remove") { + workspaces.delete(delta.id); + if (delta.emptyProject) { + const project = normalizeEmptyProjectDescriptor(delta.emptyProject); + emptyProjects.set(project.projectId, project); + } + if (delta.removedProjectId) emptyProjects.delete(delta.removedProjectId); + continue; + } + const workspace = normalizeWorkspaceDescriptor(delta.workspace); + if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) { + workspaces.delete(workspace.id); + } else { + workspaces.set(workspace.id, workspace); + emptyProjects.delete(workspace.projectId); + } + } + return { workspaces, emptyProjects }; + } + + private commit(snapshot: WorkspaceDirectorySnapshot, removedWorkspaceIds: string[]): void { + const store = useSessionStore.getState(); + store.setWorkspaces(this.serverId, snapshot.workspaces); + store.setEmptyProjects(this.serverId, snapshot.emptyProjects.values()); + for (const workspaceId of removedWorkspaceIds) { + clearWorkspaceArchivePending({ serverId: this.serverId, workspaceId }); + useWorkspaceSetupStore.getState().removeWorkspace({ serverId: this.serverId, workspaceId }); + } + } +} diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index f9424e195..156f554dc 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -1,8 +1,4 @@ import { afterEach, describe, expect, it, vi } from "vitest"; - -vi.hoisted(() => { - Object.defineProperty(globalThis, "__DEV__", { value: false, configurable: true }); -}); import type { DaemonClient, ConnectionState, @@ -10,8 +6,13 @@ import type { FetchAgentsOptions, } from "@getpaseo/client/internal/daemon-client"; import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; import type { HostConnection, HostProfile } from "@/types/host-connection"; import { useSessionStore, type Agent } from "@/stores/session-store"; +import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent"; +import { queryClient } from "@/data/query-client"; import { HostRuntimeController, HostRuntimeStore, @@ -19,10 +20,7 @@ import { type HostRuntimeControllerDeps, type HostRuntimeStorage, } from "./host-runtime"; - -vi.mock("@/browser-automation/handler", () => ({ - mountBrowserAutomationDaemonClientHandler: vi.fn(() => () => undefined), -})); +import { ReplicaCache } from "./replica-cache"; class FakeDaemonClient { private state: ConnectionState = { status: "idle" }; @@ -33,7 +31,45 @@ class FakeDaemonClient { private latencyMeasurementsRequested: Array<{ timeoutMs?: number }> = []; public connectCalls = 0; public fetchAgentsCalls: FetchAgentsOptions[] = []; - public fetchAgentsResponses: Awaited>[] = []; + public fetchAgentsResponses: Array< + Awaited> | ReturnType + > = []; + public sentAgentMessages: Array> = []; + public sendAgentMessageFailures: Error[] = []; + public sendAgentMessageResponses: Promise[] = []; + private agentUpdateListeners = new Set< + (message: Extract) => void + >(); + private fetchWaiters = new Set<() => void>(); + private agentListenerWaiters = new Set<() => void>(); + private sentMessageWaiters = new Set<() => void>(); + + on( + type: "agent_update", + listener: (message: Extract) => void, + ): () => void { + if (type === "agent_update") this.agentUpdateListeners.add(listener); + for (const waiter of this.agentListenerWaiters) waiter(); + return () => this.agentUpdateListeners.delete(listener); + } + + async waitForAgentUpdates(): Promise { + if (this.agentUpdateListeners.size > 0) return; + await new Promise((resolve) => { + const waiter = () => { + if (this.agentUpdateListeners.size === 0) return; + this.agentListenerWaiters.delete(waiter); + resolve(); + }; + this.agentListenerWaiters.add(waiter); + }); + } + + agentUpdate(payload: Extract["payload"]): void { + for (const listener of this.agentUpdateListeners) { + listener({ type: "agent_update", payload }); + } + } async connect(): Promise { this.connectCalls += 1; @@ -44,6 +80,27 @@ class FakeDaemonClient { this.setConnectionState({ status: "disconnected", reason: "client_closed" }); } + async sendAgentMessage(...args: Parameters): Promise { + this.sentAgentMessages.push(args); + for (const waiter of this.sentMessageWaiters) waiter(); + const response = this.sendAgentMessageResponses.shift(); + if (response) await response; + const failure = this.sendAgentMessageFailures.shift(); + if (failure) throw failure; + } + + async waitForSentMessages(count: number): Promise { + if (this.sentAgentMessages.length >= count) return; + await new Promise((resolve) => { + const waiter = () => { + if (this.sentAgentMessages.length < count) return; + this.sentMessageWaiters.delete(waiter); + resolve(); + }; + this.sentMessageWaiters.add(waiter); + }); + } + ensureConnected(): void { if (this.state.status !== "connected") { this.setConnectionState({ status: "connected" }); @@ -70,9 +127,10 @@ class FakeDaemonClient { options?: FetchAgentsOptions, ): Promise>> { this.fetchAgentsCalls.push(options ?? {}); + for (const waiter of this.fetchWaiters) waiter(); const queued = this.fetchAgentsResponses.shift(); if (queued) { - return queued; + return await queued; } return makeFetchAgentsPayload({ entries: [], @@ -80,6 +138,18 @@ class FakeDaemonClient { }); } + async waitForFetches(count: number): Promise { + if (this.fetchAgentsCalls.length >= count) return; + await new Promise((resolve) => { + const waiter = () => { + if (this.fetchAgentsCalls.length < count) return; + this.fetchWaiters.delete(waiter); + resolve(); + }; + this.fetchWaiters.add(waiter); + }); + } + async ping(): Promise<{ rttMs: number }> { return { rttMs: 0 }; } @@ -160,6 +230,38 @@ function makeFetchAgentsPayload(input: { }; } +class Deferred { + readonly promise: Promise; + private resolvePromise!: (value: T) => void; + private rejectPromise!: (error: Error) => void; + + constructor() { + this.promise = new Promise((resolve, reject) => { + this.resolvePromise = resolve; + this.rejectPromise = reject; + }); + } + + resolve(value: T): void { + this.resolvePromise(value); + } + + reject(error: Error): void { + this.rejectPromise(error); + } +} + +async function waitForDirectoryReady(store: HostRuntimeStore, serverId: string): Promise { + if (store.getSnapshot(serverId)?.agentDirectoryStatus === "ready") return; + await new Promise((resolve) => { + const unsubscribe = store.subscribe(serverId, () => { + if (store.getSnapshot(serverId)?.agentDirectoryStatus !== "ready") return; + unsubscribe(); + resolve(); + }); + }); +} + function makeFetchAgentsEntry(input: { id: string; cwd: string; @@ -220,6 +322,14 @@ function makeFetchAgentsEntry(input: { }; } +function replicaAgent(snapshot: FetchAgentsEntry["agent"], serverId: string): Agent { + return { ...normalizeAgentSnapshot(snapshot, serverId), projectPlacement: null }; +} + +function agentPermission(id: string): AgentPermissionRequest { + return { id, provider: "codex", name: id, kind: "tool", title: id }; +} + function makeHost(input?: Partial): HostProfile { const direct: HostConnection = { id: "direct:lan:6767", @@ -524,7 +634,6 @@ describe("HostRuntimeController", () => { }); await controller.start({ autoProbe: false }); - const snapshot = controller.getSnapshot(); expect(snapshot.activeConnectionId).toBe("direct:lan:6767"); expect(snapshot.connectionStatus).toBe("online"); @@ -1005,6 +1114,7 @@ describe("HostRuntimeController", () => { }); await controller.start({ autoProbe: false }); + const firstEpoch = controller.getSnapshot().connectionEpoch; controller.markAgentDirectorySyncReady(); expect(controller.getSnapshot().agentDirectoryStatus).toBe("ready"); expect(controller.getSnapshot().hasEverLoadedAgentDirectory).toBe(true); @@ -1019,6 +1129,7 @@ describe("HostRuntimeController", () => { clients[0]?.setConnectionState({ status: "connected" }); expect(controller.getSnapshot().connectionStatus).toBe("online"); expect(controller.getSnapshot().agentDirectoryStatus).toBe("ready"); + expect(controller.getSnapshot().connectionEpoch).toBe(firstEpoch + 1); }); it("stores directory sync errors as non-blocking after a successful directory load", async () => { @@ -1254,6 +1365,74 @@ describe("HostRuntimeController", () => { }); describe("HostRuntimeStore", () => { + it("restores the display replica before declaring the host registry loaded", async () => { + const host = makeHost(); + const storage = createMemoryHostRuntimeStorage(); + await storage.setItem("@paseo:daemon-registry", JSON.stringify([host])); + await storage.setItem("@paseo:e2e", "1"); + + const cachedAgent = replicaAgent( + makeFetchAgentsEntry({ + id: "cached-agent", + cwd: "/repo/paseo", + updatedAt: "2026-07-18T08:00:00.000Z", + title: "Cached agent", + }).agent, + host.serverId, + ); + const session = useSessionStore.getState(); + session.initializeSession(host.serverId, null); + session.setAgents(host.serverId, new Map([[cachedAgent.id, cachedAgent]])); + session.setFocusedAgentId(host.serverId, cachedAgent.id); + session.setAgentStreamTail( + host.serverId, + new Map([ + [ + cachedAgent.id, + [ + { + kind: "assistant_message", + id: "cached-message", + text: "Already painted", + timestamp: new Date("2026-07-18T08:01:00.000Z"), + }, + ], + ], + ]), + ); + const cache = new ReplicaCache(storage); + cache.setHosts([host.serverId]); + await cache.flush(); + session.clearSession(host.serverId); + + const store = new HostRuntimeStore({ + storage, + deps: { + createClient: () => { + throw new Error("createClient should not be called"); + }, + connectToDaemon: async () => { + throw new Error("connectToDaemon should not be called"); + }, + getClientId: async () => "cid_test_runtime", + }, + }); + const registryLoaded = onceHostListMatches(store, () => store.isHostRegistryLoaded()); + store.boot(); + await registryLoaded; + + const restored = useSessionStore.getState().sessions[host.serverId]; + expect(restored?.agents.get(cachedAgent.id)?.title).toBe("Cached agent"); + expect(restored?.agentStreamTail.get(cachedAgent.id)?.[0]).toMatchObject({ + text: "Already painted", + timestamp: new Date("2026-07-18T08:01:00.000Z"), + }); + expect(restored?.hasHydratedAgents).toBe(false); + + store.syncHosts([]); + session.clearSession(host.serverId); + }); + it("marks the host registry loaded after boot reads storage", async () => { const previousOverride = process.env.EXPO_PUBLIC_LOCAL_DAEMON; process.env.EXPO_PUBLIC_LOCAL_DAEMON = "not-an-endpoint"; @@ -1322,13 +1501,11 @@ describe("HostRuntimeStore", () => { useSessionStore .getState() - .initializeSession(host.serverId, fakeClient as unknown as DaemonClient); + .initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); store.syncHosts([host]); - const timeoutAt = Date.now() + 200; - while (fakeClient.fetchAgentsCalls.length === 0 && Date.now() < timeoutAt) { - await new Promise((resolve) => setTimeout(resolve, 0)); - } + await fakeClient.waitForFetches(1); + await waitForDirectoryReady(store, host.serverId); expect(fakeClient.fetchAgentsCalls).toHaveLength(1); expect(fakeClient.fetchAgentsCalls[0]).toEqual({ @@ -1342,11 +1519,18 @@ describe("HostRuntimeStore", () => { expect(snapshot?.agentDirectoryStatus).toBe("ready"); expect(snapshot?.hasEverLoadedAgentDirectory).toBe(true); + await store.refreshAgentDirectory({ serverId: host.serverId }); + expect(fakeClient.fetchAgentsCalls[1]).toEqual({ + scope: "active", + sort: [{ key: "updated_at", direction: "desc" }], + page: { limit: 200 }, + }); + store.syncHosts([]); useSessionStore.getState().clearSession(host.serverId); }); - it("bootstraps agent directory immediately when connection goes online (no session required)", async () => { + it("waits for the matching session replica before committing the connected client bootstrap", async () => { const host = makeHost({ serverId: "srv_no_session", connections: [ @@ -1373,10 +1557,14 @@ describe("HostRuntimeStore", () => { store.syncHosts([host]); - const timeoutAt = Date.now() + 200; - while (fakeClient.fetchAgentsCalls.length === 0 && Date.now() < timeoutAt) { - await new Promise((resolve) => setTimeout(resolve, 0)); - } + await Promise.resolve(); + expect(fakeClient.fetchAgentsCalls).toEqual([]); + useSessionStore + .getState() + .initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + + await fakeClient.waitForFetches(1); + await waitForDirectoryReady(store, host.serverId); expect(fakeClient.fetchAgentsCalls).toHaveLength(1); expect(fakeClient.fetchAgentsCalls[0]).toEqual({ @@ -1387,6 +1575,7 @@ describe("HostRuntimeStore", () => { }); store.syncHosts([]); + useSessionStore.getState().clearSession(host.serverId); }); it("bootstraps legacy daemons from unscoped agents and creates path-backed workspaces", async () => { @@ -1428,7 +1617,7 @@ describe("HostRuntimeStore", () => { }); const sessionStore = useSessionStore.getState(); - sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient); + sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); sessionStore.updateSessionServerInfo(host.serverId, { serverId: host.serverId, hostname: null, @@ -1436,14 +1625,8 @@ describe("HostRuntimeStore", () => { }); store.syncHosts([host]); - const timeoutAt = Date.now() + 300; - while ( - (fakeClient.fetchAgentsCalls.length === 0 || - !useSessionStore.getState().sessions[host.serverId]?.workspaces.has("/repo/legacy-app")) && - Date.now() < timeoutAt - ) { - await new Promise((resolve) => setTimeout(resolve, 0)); - } + await fakeClient.waitForFetches(1); + await waitForDirectoryReady(store, host.serverId); expect(fakeClient.fetchAgentsCalls).toEqual([ { @@ -1466,6 +1649,101 @@ describe("HostRuntimeStore", () => { useSessionStore.getState().clearSession(host.serverId); }); + it("drains legacy snapshot and buffered running transitions exactly once", async () => { + const host = makeHost({ + serverId: "srv_legacy_transitions", + connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }], + }); + const fakeClient = new FakeDaemonClient(); + fakeClient.setConnectionState({ status: "connected" }); + const pageTwo = new Deferred>>(); + const snapshotAgent = makeFetchAgentsEntry({ + id: "legacy-snapshot", + cwd: "/legacy/repo", + updatedAt: "2026-07-12T10:00:00.000Z", + }); + const bufferedAgent = makeFetchAgentsEntry({ + id: "legacy-buffered", + cwd: "/legacy/repo", + updatedAt: "2026-07-12T10:00:00.000Z", + }); + fakeClient.fetchAgentsResponses.push( + makeFetchAgentsPayload({ + entries: [ + { ...snapshotAgent, agent: { ...snapshotAgent.agent, status: "idle" } }, + { ...bufferedAgent, agent: { ...bufferedAgent.agent, status: "running" } }, + ], + hasMore: true, + nextCursor: "legacy-page-two", + }), + pageTwo.promise, + ); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async () => ({ + client: fakeClient as unknown as DaemonClient, + serverId: host.serverId, + hostname: null, + }), + getClientId: async () => "cid_legacy_transitions", + }, + }); + const sessionStore = useSessionStore.getState(); + sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + sessionStore.updateSessionServerInfo(host.serverId, { + serverId: host.serverId, + hostname: null, + version: "0.1.96", + }); + sessionStore.setAgents( + host.serverId, + new Map([ + [ + "legacy-snapshot", + { ...replicaAgent(snapshotAgent.agent, host.serverId), status: "running" }, + ], + [ + "legacy-buffered", + { ...replicaAgent(bufferedAgent.agent, host.serverId), status: "running" }, + ], + ]), + ); + sessionStore.setQueuedMessages( + host.serverId, + new Map([ + ["legacy-snapshot", [{ id: "legacy-snapshot-message", text: "snapshot", attachments: [] }]], + ["legacy-buffered", [{ id: "legacy-buffered-message", text: "buffered", attachments: [] }]], + ]), + ); + store.syncHosts([host]); + await fakeClient.waitForFetches(2); + fakeClient.agentUpdate({ + kind: "upsert", + agent: { ...bufferedAgent.agent, status: "idle" }, + project: bufferedAgent.project, + }); + pageTwo.resolve(makeFetchAgentsPayload({ entries: [] })); + await waitForDirectoryReady(store, host.serverId); + await fakeClient.waitForSentMessages(2); + + expect(fakeClient.sentAgentMessages.map(([agentId, text]) => [agentId, text])).toEqual([ + ["legacy-snapshot", "snapshot"], + ["legacy-buffered", "buffered"], + ]); + expect( + Array.from(useSessionStore.getState().sessions[host.serverId]?.agents.values() ?? []).map( + ({ id, status, workspaceId }) => [id, status, workspaceId], + ), + ).toEqual([ + ["legacy-snapshot", "idle", "/legacy/repo"], + ["legacy-buffered", "idle", "/legacy/repo"], + ]); + + store.syncHosts([]); + useSessionStore.getState().clearSession(host.serverId); + }); + it("fetches all pages during bootstrap within the active agent scope", async () => { const host = makeHost({ serverId: "srv_paged", @@ -1521,13 +1799,11 @@ describe("HostRuntimeStore", () => { useSessionStore .getState() - .initializeSession(host.serverId, fakeClient as unknown as DaemonClient); + .initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); store.syncHosts([host]); - const timeoutAt = Date.now() + 300; - while (fakeClient.fetchAgentsCalls.length < 2 && Date.now() < timeoutAt) { - await new Promise((resolve) => setTimeout(resolve, 0)); - } + await fakeClient.waitForFetches(2); + await waitForDirectoryReady(store, host.serverId); expect(fakeClient.fetchAgentsCalls).toHaveLength(2); expect(fakeClient.fetchAgentsCalls[0]).toEqual({ @@ -1542,16 +1818,9 @@ describe("HostRuntimeStore", () => { page: { limit: 200, cursor: "cursor-page-2" }, }); - let staleAgent = + const staleAgent = useSessionStore.getState().sessions[host.serverId]?.agents?.get("agent-stale-attention") ?? null; - const staleTimeoutAt = Date.now() + 300; - while (!staleAgent && Date.now() < staleTimeoutAt) { - await new Promise((resolve) => setTimeout(resolve, 0)); - staleAgent = - useSessionStore.getState().sessions[host.serverId]?.agents?.get("agent-stale-attention") ?? - null; - } expect(staleAgent?.requiresAttention).toBe(true); expect(staleAgent?.attentionReason).toBe("error"); @@ -1563,6 +1832,744 @@ describe("HostRuntimeStore", () => { useSessionStore.getState().clearSession(host.serverId); }); + it("replays agent updates received while a later bootstrap page is loading", async () => { + const host = makeHost({ + serverId: "srv_paged_delta", + connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }], + }); + const fakeClient = new FakeDaemonClient(); + fakeClient.setConnectionState({ status: "connected" }); + let finishPageTwo!: (payload: Awaited>) => void; + const pageTwo = new Promise>>((resolve) => { + finishPageTwo = resolve; + }); + const pageOneAgent = makeFetchAgentsEntry({ + id: "agent-a", + cwd: "/repo", + updatedAt: "2026-07-12T10:00:00.000Z", + title: "snapshot", + }); + const recoveredAgent = makeFetchAgentsEntry({ + id: "agent-recovered", + cwd: "/repo", + updatedAt: "2026-07-12T10:00:00.000Z", + title: "before refresh", + }); + fakeClient.fetchAgentsResponses.push( + makeFetchAgentsPayload({ + entries: [pageOneAgent], + hasMore: true, + nextCursor: "page-two", + subscriptionId: "app:srv_paged_delta", + }), + pageTwo, + ); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async () => ({ + client: fakeClient as unknown as DaemonClient, + serverId: host.serverId, + hostname: null, + }), + getClientId: async () => "cid_paged_delta", + }, + }); + const sessionStore = useSessionStore.getState(); + sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + sessionStore.setAgents( + host.serverId, + new Map([[recoveredAgent.agent.id, replicaAgent(recoveredAgent.agent, host.serverId)]]), + ); + sessionStore.setAgentTimelineCursor( + host.serverId, + new Map([[recoveredAgent.agent.id, { epoch: "epoch", startSeq: 10, endSeq: 20 }]]), + ); + store.syncHosts([host]); + await fakeClient.waitForFetches(2); + + fakeClient.agentUpdate({ + kind: "upsert", + agent: { ...pageOneAgent.agent, title: "live" }, + project: pageOneAgent.project, + }); + fakeClient.agentUpdate({ + kind: "upsert", + agent: { ...recoveredAgent.agent, title: "recovered by delta" }, + project: recoveredAgent.project, + }); + finishPageTwo( + makeFetchAgentsPayload({ + entries: [ + makeFetchAgentsEntry({ + id: "agent-b", + cwd: "/repo", + updatedAt: "2026-07-12T09:00:00.000Z", + }), + ], + }), + ); + await waitForDirectoryReady(store, host.serverId); + + expect( + Array.from(useSessionStore.getState().sessions[host.serverId]?.agents.values() ?? []).map( + (agent) => [agent.id, agent.title], + ), + ).toEqual([ + ["agent-a", "live"], + ["agent-b", null], + ["agent-recovered", "recovered by delta"], + ]); + expect( + useSessionStore + .getState() + .sessions[host.serverId]?.agentTimelineCursor.get(recoveredAgent.agent.id), + ).toEqual({ epoch: "epoch", startSeq: 10, endSeq: 20 }); + + const agentB = makeFetchAgentsEntry({ + id: "agent-b", + cwd: "/repo", + updatedAt: "2026-07-12T11:00:00.000Z", + title: "immediate", + }); + fakeClient.agentUpdate({ kind: "upsert", agent: agentB.agent, project: agentB.project }); + expect(useSessionStore.getState().sessions[host.serverId]?.agents.get("agent-b")?.title).toBe( + "immediate", + ); + fakeClient.agentUpdate({ kind: "remove", agentId: "agent-b" }); + expect(useSessionStore.getState().sessions[host.serverId]?.agents.has("agent-b")).toBe(false); + + store.syncHosts([]); + useSessionStore.getState().clearSession(host.serverId); + }); + + it("buffers updates until the matching session generation exists", async () => { + const host = makeHost({ + serverId: "srv_pre_session", + connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }], + }); + const fakeClient = new FakeDaemonClient(); + fakeClient.setConnectionState({ status: "connected" }); + const snapshotEntry = makeFetchAgentsEntry({ + id: "agent-pre-session", + cwd: "/repo", + updatedAt: "2026-07-12T10:00:00.000Z", + title: "snapshot", + }); + fakeClient.fetchAgentsResponses.push(makeFetchAgentsPayload({ entries: [snapshotEntry] })); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async () => ({ + client: fakeClient as unknown as DaemonClient, + serverId: host.serverId, + hostname: null, + }), + getClientId: async () => "cid_pre_session", + }, + }); + + store.syncHosts([host]); + await fakeClient.waitForAgentUpdates(); + fakeClient.agentUpdate({ + kind: "upsert", + agent: { ...snapshotEntry.agent, title: "before-session" }, + project: snapshotEntry.project, + }); + const generation = store.getSnapshot(host.serverId)?.clientGeneration ?? 0; + useSessionStore + .getState() + .initializeSession(host.serverId, fakeClient as unknown as DaemonClient, generation); + useSessionStore.getState().updateSessionServerInfo(host.serverId, { + serverId: host.serverId, + hostname: null, + version: "test", + }); + await fakeClient.waitForFetches(1); + await waitForDirectoryReady(store, host.serverId); + + expect( + useSessionStore.getState().sessions[host.serverId]?.agents.get("agent-pre-session")?.title, + ).toBe("before-session"); + + store.syncHosts([]); + useSessionStore.getState().clearSession(host.serverId); + }); + + it("restarts directory bootstrap when reconnect supersedes a pending session wait", async () => { + const host = makeHost({ + serverId: "srv_session_wait_reconnect", + connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }], + }); + const fakeClient = new FakeDaemonClient(); + fakeClient.setConnectionState({ status: "connected" }); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async () => ({ + client: fakeClient as unknown as DaemonClient, + serverId: host.serverId, + hostname: null, + }), + getClientId: async () => "cid_session_wait_reconnect", + }, + }); + + store.syncHosts([host]); + await fakeClient.waitForAgentUpdates(); + fakeClient.setConnectionState({ status: "disconnected", reason: "network" }); + fakeClient.setConnectionState({ status: "connected" }); + + const generation = store.getSnapshot(host.serverId)?.clientGeneration ?? 0; + const sessionStore = useSessionStore.getState(); + sessionStore.initializeSession( + host.serverId, + fakeClient as unknown as DaemonClient, + generation, + ); + sessionStore.updateSessionServerInfo(host.serverId, { + serverId: host.serverId, + hostname: null, + version: "test", + }); + await fakeClient.waitForFetches(1); + await waitForDirectoryReady(store, host.serverId); + + expect(fakeClient.fetchAgentsCalls).toHaveLength(1); + store.syncHosts([]); + sessionStore.clearSession(host.serverId); + }); + + it("rejects a superseded refresh without overwriting the newer replica", async () => { + const host = makeHost({ + serverId: "srv_overlap", + connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }], + }); + const fakeClient = new FakeDaemonClient(); + fakeClient.setConnectionState({ status: "connected" }); + fakeClient.fetchAgentsResponses.push(makeFetchAgentsPayload({ entries: [] })); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async () => ({ + client: fakeClient as unknown as DaemonClient, + serverId: host.serverId, + hostname: null, + }), + getClientId: async () => "cid_overlap", + }, + }); + useSessionStore + .getState() + .initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + store.syncHosts([host]); + await fakeClient.waitForFetches(1); + await waitForDirectoryReady(store, host.serverId); + const olderPage = new Deferred>>(); + fakeClient.fetchAgentsResponses.push(olderPage.promise); + const olderRefresh = store.refreshAgentDirectory({ serverId: host.serverId }); + await fakeClient.waitForFetches(2); + const newerEntry = makeFetchAgentsEntry({ + id: "newer", + cwd: "/repo", + updatedAt: "2026-07-12T11:00:00.000Z", + }); + fakeClient.fetchAgentsResponses.push(makeFetchAgentsPayload({ entries: [newerEntry] })); + + const newerRefresh = store.refreshAgentDirectory({ serverId: host.serverId }); + await fakeClient.waitForFetches(3); + await newerRefresh; + olderPage.resolve( + makeFetchAgentsPayload({ + entries: [ + makeFetchAgentsEntry({ + id: "older", + cwd: "/repo", + updatedAt: "2026-07-12T09:00:00.000Z", + }), + ], + }), + ); + await expect(olderRefresh).rejects.toThrow(); + + fakeClient.agentUpdate({ + kind: "upsert", + agent: { ...newerEntry.agent, title: "after cleanup" }, + project: newerEntry.project, + }); + + expect( + Array.from(useSessionStore.getState().sessions[host.serverId]?.agents.values() ?? []).map( + ({ id, title }) => [id, title], + ), + ).toEqual([["newer", "after cleanup"]]); + + store.syncHosts([]); + useSessionStore.getState().clearSession(host.serverId); + }); + + it("replays inherited deltas when a superseding refresh fails", async () => { + const host = makeHost({ + serverId: "srv_overlap_failure", + connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }], + }); + const fakeClient = new FakeDaemonClient(); + fakeClient.setConnectionState({ status: "connected" }); + fakeClient.fetchAgentsResponses.push(makeFetchAgentsPayload({ entries: [] })); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async () => ({ + client: fakeClient as unknown as DaemonClient, + serverId: host.serverId, + hostname: null, + }), + getClientId: async () => "cid_overlap_failure", + }, + }); + useSessionStore + .getState() + .initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + store.syncHosts([host]); + await fakeClient.waitForFetches(1); + await waitForDirectoryReady(store, host.serverId); + + const olderPage = new Deferred>>(); + fakeClient.fetchAgentsResponses.push(olderPage.promise); + const olderRefresh = store.refreshAgentDirectory({ serverId: host.serverId }); + await fakeClient.waitForFetches(2); + const liveEntry = makeFetchAgentsEntry({ + id: "live-delta", + cwd: "/repo", + updatedAt: "2026-07-17T10:00:00.000Z", + title: "preserved", + }); + fakeClient.agentUpdate({ kind: "upsert", agent: liveEntry.agent, project: liveEntry.project }); + + const newerPage = new Deferred>>(); + fakeClient.fetchAgentsResponses.push(newerPage.promise); + const newerRefresh = store.refreshAgentDirectory({ serverId: host.serverId }); + await fakeClient.waitForFetches(3); + newerPage.reject(new Error("newer refresh failed")); + await expect(newerRefresh).rejects.toThrow("newer refresh failed"); + + expect( + useSessionStore.getState().sessions[host.serverId]?.agents.get("live-delta")?.title, + ).toBe("preserved"); + + olderPage.resolve(makeFetchAgentsPayload({ entries: [] })); + await expect(olderRefresh).rejects.toThrow(); + store.syncHosts([]); + useSessionStore.getState().clearSession(host.serverId); + }); + + it("rejects a refresh when the session generation changes before commit", async () => { + const host = makeHost({ + serverId: "srv_stale_generation", + connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }], + }); + const fakeClient = new FakeDaemonClient(); + fakeClient.setConnectionState({ status: "connected" }); + const existingEntry = makeFetchAgentsEntry({ + id: "existing", + cwd: "/repo", + updatedAt: "2026-07-12T10:00:00.000Z", + }); + fakeClient.fetchAgentsResponses.push(makeFetchAgentsPayload({ entries: [existingEntry] })); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async () => ({ + client: fakeClient as unknown as DaemonClient, + serverId: host.serverId, + hostname: null, + }), + getClientId: async () => "cid_stale_generation", + }, + }); + const sessionStore = useSessionStore.getState(); + sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + store.syncHosts([host]); + await fakeClient.waitForFetches(1); + await waitForDirectoryReady(store, host.serverId); + const stalePage = new Deferred>>(); + fakeClient.fetchAgentsResponses.push(stalePage.promise); + + const refresh = store.refreshAgentDirectory({ serverId: host.serverId }); + await fakeClient.waitForFetches(2); + sessionStore.updateSessionClient(host.serverId, fakeClient as unknown as DaemonClient, 2); + stalePage.resolve( + makeFetchAgentsPayload({ + entries: [ + makeFetchAgentsEntry({ + id: "stale", + cwd: "/repo", + updatedAt: "2026-07-12T11:00:00.000Z", + }), + ], + }), + ); + + await expect(refresh).rejects.toThrow(); + expect( + Array.from(useSessionStore.getState().sessions[host.serverId]?.agents.keys() ?? []), + ).toEqual(["existing"]); + + store.syncHosts([]); + useSessionStore.getState().clearSession(host.serverId); + }); + + it("drains queued messages once for snapshot and buffered running transitions", async () => { + const host = makeHost({ + serverId: "srv_queued_transitions", + connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }], + }); + const fakeClient = new FakeDaemonClient(); + fakeClient.setConnectionState({ status: "connected" }); + const pageTwo = new Deferred>>(); + const snapshotAgent = makeFetchAgentsEntry({ + id: "snapshot-transition", + cwd: "/repo", + updatedAt: "2026-07-12T10:00:00.000Z", + }); + const bufferedAgent = makeFetchAgentsEntry({ + id: "buffered-transition", + cwd: "/repo", + updatedAt: "2026-07-12T10:00:00.000Z", + }); + fakeClient.fetchAgentsResponses.push( + makeFetchAgentsPayload({ + entries: [ + { ...snapshotAgent, agent: { ...snapshotAgent.agent, status: "idle" } }, + { ...bufferedAgent, agent: { ...bufferedAgent.agent, status: "running" } }, + ], + hasMore: true, + nextCursor: "page-two", + }), + pageTwo.promise, + ); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async () => ({ + client: fakeClient as unknown as DaemonClient, + serverId: host.serverId, + hostname: null, + }), + getClientId: async () => "cid_queued_transitions", + }, + }); + const sessionStore = useSessionStore.getState(); + sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + sessionStore.setAgents( + host.serverId, + new Map([ + [ + "snapshot-transition", + { ...replicaAgent(snapshotAgent.agent, host.serverId), status: "running" }, + ], + [ + "buffered-transition", + { ...replicaAgent(bufferedAgent.agent, host.serverId), status: "running" }, + ], + ]), + ); + sessionStore.setQueuedMessages( + host.serverId, + new Map([ + [ + "snapshot-transition", + [{ id: "message-snapshot", text: "snapshot queued", attachments: [] }], + ], + [ + "buffered-transition", + [{ id: "message-buffered", text: "buffered queued", attachments: [] }], + ], + ]), + ); + store.syncHosts([host]); + await fakeClient.waitForFetches(2); + fakeClient.agentUpdate({ + kind: "upsert", + agent: { ...bufferedAgent.agent, status: "idle" }, + project: bufferedAgent.project, + }); + pageTwo.resolve(makeFetchAgentsPayload({ entries: [] })); + await waitForDirectoryReady(store, host.serverId); + await fakeClient.waitForSentMessages(2); + + expect(fakeClient.sentAgentMessages.map(([agentId, text]) => [agentId, text])).toEqual([ + ["snapshot-transition", "snapshot queued"], + ["buffered-transition", "buffered queued"], + ]); + expect( + Array.from(useSessionStore.getState().sessions[host.serverId]?.queuedMessages.values() ?? []), + ).toEqual([[], []]); + + store.syncHosts([]); + useSessionStore.getState().clearSession(host.serverId); + }); + + it("restores an automatically drained message when sending fails", async () => { + const host = makeHost({ serverId: "srv_failed_queue_drain" }); + const fakeClient = new FakeDaemonClient(); + fakeClient.sendAgentMessageFailures.push(new Error("connection lost")); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async () => ({ + client: fakeClient as unknown as DaemonClient, + serverId: host.serverId, + hostname: null, + }), + getClientId: async () => "cid_failed_queue_drain", + }, + }); + const sessionStore = useSessionStore.getState(); + sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + sessionStore.setQueuedMessages( + host.serverId, + new Map([ + [ + "agent", + [ + { id: "first", text: "retry me", attachments: [] }, + { id: "second", text: "keep me behind", attachments: [] }, + ], + ], + ]), + ); + + store.drainQueuedAgentMessage(host.serverId, "agent"); + + await vi.waitFor(() => { + expect(fakeClient.sentAgentMessages).toHaveLength(1); + expect( + useSessionStore.getState().sessions[host.serverId]?.queuedMessages.get("agent"), + ).toEqual([ + { id: "first", text: "retry me", attachments: [] }, + { id: "second", text: "keep me behind", attachments: [] }, + ]); + }); + + useSessionStore.getState().clearSession(host.serverId); + }); + + it("serializes queued-message drains for the same agent", async () => { + const host = makeHost({ serverId: "srv_serialized_queue_drain" }); + const fakeClient = new FakeDaemonClient(); + const send = new Deferred(); + fakeClient.sendAgentMessageResponses.push(send.promise); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async () => ({ + client: fakeClient as unknown as DaemonClient, + serverId: host.serverId, + hostname: null, + }), + getClientId: async () => "cid_serialized_queue_drain", + }, + }); + const sessionStore = useSessionStore.getState(); + sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + sessionStore.setQueuedMessages( + host.serverId, + new Map([["agent", [{ id: "first", text: "send once", attachments: [] }]]]), + ); + + store.drainQueuedAgentMessage(host.serverId, "agent"); + store.drainQueuedAgentMessage(host.serverId, "agent"); + await fakeClient.waitForSentMessages(1); + expect(fakeClient.sentAgentMessages).toHaveLength(1); + + send.resolve(); + await vi.waitFor(() => { + expect( + useSessionStore.getState().sessions[host.serverId]?.queuedMessages.get("agent"), + ).toEqual([]); + }); + useSessionStore.getState().clearSession(host.serverId); + }); + + it("uses legacy GitHub attachments when draining a queue for an old daemon", async () => { + const host = makeHost({ serverId: "srv_legacy_queue_attachment" }); + const fakeClient = new FakeDaemonClient(); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async () => ({ + client: fakeClient as unknown as DaemonClient, + serverId: host.serverId, + hostname: null, + }), + getClientId: async () => "cid_legacy_queue_attachment", + }, + }); + const sessionStore = useSessionStore.getState(); + sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + sessionStore.updateSessionServerInfo(host.serverId, { + serverId: host.serverId, + hostname: null, + version: "0.1.105", + features: { forgeSearch: false }, + }); + sessionStore.setQueuedMessages( + host.serverId, + new Map([ + [ + "agent", + [ + { + id: "queued-legacy-attachment", + text: "review this", + attachments: [ + { + kind: "github_pr" as const, + item: { + kind: "change_request" as const, + number: 42, + title: "Compatibility fix", + url: "https://github.com/acme/repo/pull/42", + state: "open" as const, + body: "Details", + labels: [], + baseRefName: "main", + headRefName: "fix", + }, + }, + ], + }, + ], + ], + ]), + ); + + store.drainQueuedAgentMessage(host.serverId, "agent"); + await fakeClient.waitForSentMessages(1); + + expect(fakeClient.sentAgentMessages[0]?.[2]?.attachments).toEqual([ + { + type: "github_pr", + mimeType: "application/github-pr", + number: 42, + title: "Compatibility fix", + url: "https://github.com/acme/repo/pull/42", + body: "Details", + baseRefName: "main", + headRefName: "fix", + }, + ]); + sessionStore.clearSession(host.serverId); + }); + + it("applies buffered stale side effects from the accepted page agent", async () => { + const host = makeHost({ + serverId: "srv_buffered_stale_side_effects", + connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }], + }); + const fakeClient = new FakeDaemonClient(); + fakeClient.setConnectionState({ status: "connected" }); + const pageTwo = new Deferred>>(); + const base = makeFetchAgentsEntry({ + id: "stale-side-effects", + cwd: "/repo", + updatedAt: "2026-07-12T12:00:00.000Z", + title: "newer page", + }); + const pageAgent = { + ...base.agent, + status: "running" as const, + lastUsage: { inputTokens: 10, outputTokens: 5 }, + pendingPermissions: [agentPermission("current-permission")], + }; + fakeClient.fetchAgentsResponses.push( + makeFetchAgentsPayload({ + entries: [{ ...base, agent: pageAgent }], + hasMore: true, + nextCursor: "page-two", + }), + pageTwo.promise, + ); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async () => ({ + client: fakeClient as unknown as DaemonClient, + serverId: host.serverId, + hostname: null, + }), + getClientId: async () => "cid_buffered_stale_side_effects", + }, + }); + const sessionStore = useSessionStore.getState(); + sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + sessionStore.setAgents( + host.serverId, + new Map([ + [ + pageAgent.id, + replicaAgent({ ...pageAgent, updatedAt: "2026-07-12T10:00:00.000Z" }, host.serverId), + ], + ]), + ); + sessionStore.setAgentLastActivity(pageAgent.id, new Date("2026-07-12T12:00:00.000Z")); + sessionStore.flushAgentLastActivity(); + setAgentArchiving({ + queryClient, + serverId: host.serverId, + agentId: pageAgent.id, + isArchiving: true, + }); + store.syncHosts([host]); + await fakeClient.waitForFetches(2); + fakeClient.agentUpdate({ + kind: "upsert", + agent: { + ...pageAgent, + status: "idle", + title: "stale live", + updatedAt: "2026-07-12T11:00:00.000Z", + lastUsage: { inputTokens: 20, outputTokens: 8 }, + pendingPermissions: [agentPermission("stale-permission")], + archivedAt: "2026-07-12T11:00:00.000Z", + }, + project: base.project, + }); + pageTwo.resolve(makeFetchAgentsPayload({ entries: [] })); + await waitForDirectoryReady(store, host.serverId); + sessionStore.flushAgentLastActivity(); + + const state = useSessionStore.getState(); + const agent = state.sessions[host.serverId]?.agents.get(pageAgent.id); + expect({ + title: agent?.title, + status: agent?.status, + usage: agent?.lastUsage, + permissions: Array.from(state.sessions[host.serverId]?.pendingPermissions.values() ?? []).map( + ({ request }) => request.id, + ), + archivePending: isAgentArchiving({ + queryClient, + serverId: host.serverId, + agentId: pageAgent.id, + }), + activity: state.agentLastActivity.get(pageAgent.id)?.toISOString(), + sentMessages: fakeClient.sentAgentMessages.length, + }).toEqual({ + title: "newer page", + status: "running", + usage: { inputTokens: 20, outputTokens: 8 }, + permissions: ["current-permission"], + archivePending: true, + activity: "2026-07-12T12:00:00.000Z", + sentMessages: 0, + }); + + store.syncHosts([]); + useSessionStore.getState().clearSession(host.serverId); + }); + it("re-subscribes agent directory updates after reconnect", async () => { const host = makeHost({ serverId: "srv_resubscribe", @@ -1590,13 +2597,16 @@ describe("HostRuntimeStore", () => { useSessionStore .getState() - .initializeSession(host.serverId, fakeClient as unknown as DaemonClient); + .initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); store.syncHosts([host]); - const initialTimeoutAt = Date.now() + 200; - while (fakeClient.fetchAgentsCalls.length < 1 && Date.now() < initialTimeoutAt) { - await new Promise((resolve) => setTimeout(resolve, 0)); - } + await fakeClient.waitForFetches(1); + await waitForDirectoryReady(store, host.serverId); + + fakeClient.setConnectionState({ status: "connected" }); + await Promise.resolve(); + await Promise.resolve(); + expect(fakeClient.fetchAgentsCalls).toHaveLength(1); fakeClient.setConnectionState({ status: "disconnected", @@ -1604,10 +2614,8 @@ describe("HostRuntimeStore", () => { }); fakeClient.setConnectionState({ status: "connected" }); - const reconnectTimeoutAt = Date.now() + 200; - while (fakeClient.fetchAgentsCalls.length < 2 && Date.now() < reconnectTimeoutAt) { - await new Promise((resolve) => setTimeout(resolve, 0)); - } + await fakeClient.waitForFetches(2); + await waitForDirectoryReady(store, host.serverId); expect(fakeClient.fetchAgentsCalls).toEqual([ { @@ -1661,7 +2669,7 @@ describe("HostRuntimeStore", () => { useSessionStore .getState() - .initializeSession(host.serverId, fakeClient as unknown as DaemonClient); + .initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); useSessionStore.getState().setAgents(host.serverId, () => { const stale = makeFetchAgentsEntry({ id: "agent-archived", @@ -1686,13 +2694,8 @@ describe("HostRuntimeStore", () => { store.syncHosts([host]); - const timeoutAt = Date.now() + 300; - while ( - useSessionStore.getState().sessions[host.serverId]?.agents.has("agent-archived") && - Date.now() < timeoutAt - ) { - await new Promise((resolve) => setTimeout(resolve, 0)); - } + await fakeClient.waitForFetches(1); + await waitForDirectoryReady(store, host.serverId); expect(useSessionStore.getState().sessions[host.serverId]?.agents.has("agent-archived")).toBe( false, diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 283354231..59fdf9c09 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -4,7 +4,6 @@ import equal from "fast-deep-equal/es6"; import { DaemonClient, type ConnectionState, - type FetchAgentsEntry, type FetchAgentsOptions, } from "@getpaseo/client/internal/daemon-client"; import { @@ -40,12 +39,7 @@ import { import { getDesktopHost } from "@/desktop/host"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas"; -import { replaceFetchedAgentDirectory } from "@/utils/agent-directory-sync"; import { useSessionStore } from "@/stores/session-store"; -import { - fetchLegacyDaemonWorkspaceDirectory, - shouldUseLegacyDaemonWorkspaceDirectory, -} from "@/workspace/legacy-daemon-workspaces"; import { invalidateCheckoutGitQueriesForServer } from "@/git/query-keys"; import { queryClient } from "@/data/query-client"; import { @@ -54,6 +48,14 @@ import { } from "@/data/push-router"; import { mountBrowserAutomationDaemonClientHandler } from "@/browser-automation/handler"; import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules"; +import { sendQueuedComposerMessageNow } from "@/composer/actions"; +import { + resolveComposerAttachmentSubmitFormat, + splitComposerAttachmentsForSubmit, +} from "@/composer/attachments/submit"; +import { encodeImages } from "@/utils/encode-images"; +import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/directory-sync"; +import { ReplicaCache } from "@/runtime/replica-cache"; export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error"; export type HostRegistryStatus = "loading" | "ready"; @@ -85,6 +87,7 @@ export interface HostRuntimeSnapshot { hasEverLoadedAgentDirectory: boolean; probeByConnectionId: Map; clientGeneration: number; + connectionEpoch: number; } type HostRuntimeSnapshotPatch = Partial>; @@ -176,90 +179,6 @@ const ADAPTIVE_SWITCH_CONSECUTIVE_PROBES = 3; const DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT = 200; const CONFIGURED_OVERRIDE_BOOTSTRAP_RETRY_MS = 1_000; -const DEFAULT_AGENT_DIRECTORY_SORT: NonNullable = [ - { key: "updated_at", direction: "desc" }, -]; - -function readFetchAgentsHasMore( - pageInfo: Awaited>["pageInfo"], -): boolean { - const page = pageInfo as { - hasMore?: boolean; - hasMoreAfter?: boolean; - }; - if (typeof page.hasMore === "boolean") { - return page.hasMore; - } - if (typeof page.hasMoreAfter === "boolean") { - return page.hasMoreAfter; - } - return false; -} - -function readFetchAgentsNextCursor( - pageInfo: Awaited>["pageInfo"], -): string | null { - const page = pageInfo as { - nextCursor?: string | null; - afterCursor?: string | null; - }; - if (typeof page.nextCursor === "string" && page.nextCursor.length > 0) { - return page.nextCursor; - } - if (typeof page.afterCursor === "string" && page.afterCursor.length > 0) { - return page.afterCursor; - } - return null; -} - -interface AgentDirectoryFetchInput { - client: DaemonClient; - filter?: FetchAgentsOptions["filter"]; - subscribe?: FetchAgentsOptions["subscribe"]; - page?: FetchAgentsOptions["page"]; -} - -interface AgentDirectoryFetchResult { - entries: FetchAgentsEntry[]; - subscriptionId: string | null; -} - -async function fetchCurrentAgentDirectory( - input: AgentDirectoryFetchInput, -): Promise { - const pageLimit = input.page?.limit ?? DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT; - let cursor = input.page?.cursor ?? null; - let includeSubscribe = true; - let subscriptionId: string | null = null; - const entries: FetchAgentsEntry[] = []; - - while (true) { - const payload = await input.client.fetchAgents({ - scope: input.filter ? undefined : "active", - ...(input.filter ? { filter: input.filter } : {}), - sort: DEFAULT_AGENT_DIRECTORY_SORT, - ...(includeSubscribe && input.subscribe ? { subscribe: input.subscribe } : {}), - page: cursor ? { limit: pageLimit, cursor } : { limit: pageLimit }, - }); - - entries.push(...payload.entries); - subscriptionId = subscriptionId ?? payload.subscriptionId ?? null; - includeSubscribe = false; - - if (!readFetchAgentsHasMore(payload.pageInfo)) { - break; - } - - const nextCursor = readFetchAgentsNextCursor(payload.pageInfo); - if (!nextCursor) { - break; - } - cursor = nextCursor; - } - - return { entries, subscriptionId }; -} - function toActiveConnection(connection: HostConnection): ActiveConnection { if (connection.type === "directSocket") { return { @@ -445,9 +364,15 @@ function nextConnectionMachineState(input: { function toSnapshotConnectionPatch( state: HostRuntimeConnectionMachineState, + connectionEpoch: number, ): Pick< HostRuntimeSnapshot, - "activeConnectionId" | "activeConnection" | "connectionStatus" | "lastError" | "lastOnlineAt" + | "activeConnectionId" + | "activeConnection" + | "connectionStatus" + | "lastError" + | "lastOnlineAt" + | "connectionEpoch" > { if (state.tag === "booting") { return { @@ -456,6 +381,7 @@ function toSnapshotConnectionPatch( connectionStatus: "connecting", lastError: null, lastOnlineAt: null, + connectionEpoch, }; } if (state.tag === "connecting") { @@ -465,6 +391,7 @@ function toSnapshotConnectionPatch( connectionStatus: "connecting", lastError: null, lastOnlineAt: null, + connectionEpoch, }; } if (state.tag === "online") { @@ -474,6 +401,7 @@ function toSnapshotConnectionPatch( connectionStatus: "online", lastError: null, lastOnlineAt: state.lastOnlineAt, + connectionEpoch, }; } if (state.tag === "offline") { @@ -483,6 +411,7 @@ function toSnapshotConnectionPatch( connectionStatus: "offline", lastError: null, lastOnlineAt: null, + connectionEpoch, }; } return { @@ -491,6 +420,7 @@ function toSnapshotConnectionPatch( connectionStatus: "error", lastError: state.message, lastOnlineAt: null, + connectionEpoch, }; } @@ -538,6 +468,10 @@ function createDefaultDeps(): HostRuntimeControllerDeps { }, } : undefined; + const appCapabilities = { + [CLIENT_CAPS.selectiveAgentTimeline]: true, + ...browserAutomationCapabilities, + }; return { createClient: ({ host, connection, clientId, runtimeGeneration }) => { @@ -548,7 +482,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps { clientType: "mobile" as const, appVersion: resolveAppVersion() ?? undefined, runtimeGeneration, - ...(browserAutomationCapabilities ? { capabilities: browserAutomationCapabilities } : {}), + capabilities: appCapabilities, }; if (connection.type === "directSocket" || connection.type === "directPipe") { return new DaemonClient({ @@ -586,7 +520,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps { connectToDaemon(connection, { ...(host.serverId ? { serverId: host.serverId } : {}), ...(timeoutMs !== undefined ? { timeoutMs } : {}), - ...(browserAutomationCapabilities ? { capabilities: browserAutomationCapabilities } : {}), + capabilities: appCapabilities, }), getClientId: () => getOrCreateClientId(), mountClientHandlers: ({ client, host }) => { @@ -614,6 +548,7 @@ export class HostRuntimeController { private deps: HostRuntimeControllerDeps; private onReconcileServerId: ((oldId: string, newId: string) => void) | null; private connectionMachineState: HostRuntimeConnectionMachineState; + private connectionEpoch = 0; private snapshot: HostRuntimeSnapshot; private listeners = new Set<() => void>(); private activeClient: DaemonClient | null = null; @@ -644,7 +579,7 @@ export class HostRuntimeController { }; this.snapshot = { serverId: this.host.serverId, - ...toSnapshotConnectionPatch(this.connectionMachineState), + ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), client: null, agentDirectoryStatus: "idle", agentDirectoryError: null, @@ -712,7 +647,7 @@ export class HostRuntimeController { } this.applyConnectionEvent({ type: "stopped" }); this.updateSnapshot({ - ...toSnapshotConnectionPatch(this.connectionMachineState), + ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), client: null, }); } @@ -776,7 +711,7 @@ export class HostRuntimeController { markStartupError(message: string): void { this.applyConnectionEvent({ type: "connect_failed", message }); this.updateSnapshot({ - ...toSnapshotConnectionPatch(this.connectionMachineState), + ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), }); } @@ -809,7 +744,7 @@ export class HostRuntimeController { } this.applyConnectionEvent({ type: "no_connections" }); this.updateSnapshot({ - ...toSnapshotConnectionPatch(this.connectionMachineState), + ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), probeByConnectionId: new Map(), }); return; @@ -1095,6 +1030,9 @@ export class HostRuntimeController { state: previousState, event, }); + if (previousState.tag !== "online" && nextState.tag === "online") { + this.connectionEpoch += 1; + } this.connectionMachineState = nextState; this.logConnectionTransition({ from: previousState.tag, @@ -1171,7 +1109,7 @@ export class HostRuntimeController { message: `Failed to resolve client id: ${message}`, }); this.updateSnapshot({ - ...toSnapshotConnectionPatch(this.connectionMachineState), + ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), }); return null; } @@ -1269,7 +1207,7 @@ export class HostRuntimeController { this.snapshot = { ...this.snapshot, serverId: this.host.serverId, - ...toSnapshotConnectionPatch(this.connectionMachineState), + ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), client, clientGeneration: nextGeneration, }; @@ -1287,7 +1225,7 @@ export class HostRuntimeController { lastError: client.lastError, }); const patch: HostRuntimeSnapshotPatch = { - ...toSnapshotConnectionPatch(this.connectionMachineState), + ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), ...this.buildAgentDirectoryStatusPatch(), }; this.updateSnapshot(patch); @@ -1307,7 +1245,7 @@ export class HostRuntimeController { message, }); this.updateSnapshot({ - ...toSnapshotConnectionPatch(this.connectionMachineState), + ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), }); } } @@ -1393,6 +1331,13 @@ function rekeyMap(map: Map, oldKey: string, newKey: string): void map.set(newKey, value); } +interface AgentDirectoryRefreshInput { + serverId: string; + filter?: FetchAgentsOptions["filter"]; + subscribe?: FetchAgentsOptions["subscribe"]; + page?: FetchAgentsOptions["page"]; +} + export class HostRuntimeStore { private controllers = new Map(); private serverListeners = new Map void>>(); @@ -1406,13 +1351,17 @@ export class HostRuntimeStore { private deps: HostRuntimeControllerDeps; private lastConnectionStatusByServer = new Map(); private agentDirectoryBootstrapInFlight = new Map>(); + private queuedAgentDrainInFlight = new Set(); + private directorySyncByServer = new Map(); private configuredOverrideBootstrapInFlight: Promise | null = null; private bootStarted = false; private storage: HostRuntimeStorage; + private replicaCache: ReplicaCache; constructor(input?: { deps?: HostRuntimeControllerDeps; storage?: HostRuntimeStorage }) { this.deps = input?.deps ?? createDefaultDeps(); this.storage = input?.storage ?? AsyncStorage; + this.replicaCache = new ReplicaCache(this.storage); } // --- Host registry --- @@ -1486,27 +1435,29 @@ export class HostRuntimeStore { private async loadFromStorage(): Promise { let shouldPersistHosts = false; + let profiles: HostProfile[] = []; try { const stored = await this.storage.getItem(REGISTRY_STORAGE_KEY); - if (!stored) { - return; + if (stored) { + const parsed = JSON.parse(stored) as unknown; + if (Array.isArray(parsed)) { + const normalizedProfiles = parsed + .map((entry) => normalizeStoredHostProfile(entry)) + .filter((entry): entry is HostProfile => entry !== null); + profiles = normalizedProfiles.filter((entry) => !isPlaceholderServerId(entry.serverId)); + if (profiles.length !== normalizedProfiles.length) { + shouldPersistHosts = true; + } + } } - const parsed = JSON.parse(stored) as unknown; - if (!Array.isArray(parsed)) { - return; - } - const normalizedProfiles = parsed - .map((entry) => normalizeStoredHostProfile(entry)) - .filter((entry): entry is HostProfile => entry !== null); - const profiles = normalizedProfiles.filter((entry) => !isPlaceholderServerId(entry.serverId)); this.hosts = profiles; + this.replicaCache.setHosts(profiles.map((profile) => profile.serverId)); + await this.replicaCache.restore(); this.syncHosts(profiles); - if (profiles.length !== normalizedProfiles.length) { - shouldPersistHosts = true; - } } catch (error) { console.error("[HostRuntime] Failed to load host registry from storage", error); } finally { + this.replicaCache.start(); this.hostRegistryStatus = "ready"; this.emitHostList(); if (shouldPersistHosts) { @@ -1636,6 +1587,28 @@ export class HostRuntimeStore { rekeyMap(this.lastConnectionStatusByServer, oldServerId, newServerId); rekeyMap(this.agentDirectoryBootstrapInFlight, oldServerId, newServerId); + this.replicaCache.reconcileServerId(oldServerId, newServerId); + this.directorySyncByServer.get(oldServerId)?.dispose(); + this.directorySyncByServer.delete(oldServerId); + const directory = new DirectorySync(newServerId, { + drainQueuedAgentMessage: (agentId) => this.drainQueuedAgentMessage(newServerId, agentId), + markAgentLoading: () => controller.markAgentDirectorySyncLoading(), + markAgentReady: () => { + this.agentDirectoryBootstrapInFlight.delete(newServerId); + controller.markAgentDirectorySyncReady(); + }, + markAgentError: (error) => controller.markAgentDirectorySyncError(error), + }); + this.directorySyncByServer.set(newServerId, directory); + const snapshot = controller.getSnapshot(); + directory.connectionChanged({ + client: snapshot.client, + status: snapshot.connectionStatus === "online" ? "online" : "offline", + source: { + clientGeneration: snapshot.clientGeneration, + connectionEpoch: snapshot.connectionEpoch, + }, + }); const listeners = this.serverListeners.get(oldServerId); if (listeners) { @@ -1918,6 +1891,7 @@ export class HostRuntimeStore { >; }, ): void { + this.replicaCache.setHosts(hosts.map((host) => host.serverId)); const nextIds = new Set(hosts.map((host) => host.serverId)); for (const [serverId, controller] of this.controllers) { if (nextIds.has(serverId)) { @@ -1926,6 +1900,8 @@ export class HostRuntimeStore { this.controllers.delete(serverId); this.lastConnectionStatusByServer.delete(serverId); this.agentDirectoryBootstrapInFlight.delete(serverId); + this.directorySyncByServer.get(serverId)?.dispose(); + this.directorySyncByServer.delete(serverId); void controller.stop(); this.emit(serverId); } @@ -1948,6 +1924,19 @@ export class HostRuntimeStore { onReconcileServerId: (oldId, newId) => this.reconcileServerId(oldId, newId), }); this.controllers.set(host.serverId, controller); + this.directorySyncByServer.set( + host.serverId, + new DirectorySync(host.serverId, { + drainQueuedAgentMessage: (agentId) => + this.drainQueuedAgentMessage(host.serverId, agentId), + markAgentLoading: () => controller.markAgentDirectorySyncLoading(), + markAgentReady: () => { + this.agentDirectoryBootstrapInFlight.delete(host.serverId); + controller.markAgentDirectorySyncReady(); + }, + markAgentError: (error) => controller.markAgentDirectorySyncError(error), + }), + ); this.lastConnectionStatusByServer.set( host.serverId, controller.getSnapshot().connectionStatus, @@ -1980,6 +1969,15 @@ export class HostRuntimeStore { return; } const snapshot = controller.getSnapshot(); + const directorySourceChanged = + this.directorySyncByServer.get(serverId)?.connectionChanged({ + client: snapshot.client, + status: snapshot.connectionStatus === "online" ? "online" : "offline", + source: { + clientGeneration: snapshot.clientGeneration, + connectionEpoch: snapshot.connectionEpoch, + }, + }) ?? false; const previousStatus = this.lastConnectionStatusByServer.get(serverId); this.lastConnectionStatusByServer.set(serverId, snapshot.connectionStatus); const didTransitionOnline = @@ -2002,17 +2000,20 @@ export class HostRuntimeStore { if (!didTransitionOnline && snapshot.hasEverLoadedAgentDirectory) { return; } - if (this.agentDirectoryBootstrapInFlight.has(serverId)) { + if (this.agentDirectoryBootstrapInFlight.has(serverId) && !directorySourceChanged) { return; } const bootstrap = Promise.resolve() .then(() => - this.refreshAgentDirectory({ - serverId, - subscribe: { subscriptionId: `app:${serverId}` }, - page: { limit: DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT }, - }), + Promise.all([ + this.refreshAgentDirectory({ + serverId, + subscribe: { subscriptionId: `app:${serverId}` }, + page: { limit: DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT }, + }), + this.refreshWorkspaceDirectory({ serverId, subscribe: true }), + ]), ) .then(() => undefined) .catch((error) => { @@ -2031,6 +2032,55 @@ export class HostRuntimeStore { this.agentDirectoryBootstrapInFlight.set(serverId, bootstrap); } + drainQueuedAgentMessage(serverId: string, agentId: string): void { + const drainKey = `${serverId}:${agentId}`; + if (this.queuedAgentDrainInFlight.has(drainKey)) return; + const store = useSessionStore.getState(); + const session = store.sessions[serverId]; + const queue = session?.queuedMessages.get(agentId); + const client = session?.client; + if (!client || !queue?.length || session.initializingAgents.get(agentId) === true) { + return; + } + this.queuedAgentDrainInFlight.add(drainKey); + const next = queue[0]; + void sendQueuedComposerMessageNow({ + agentId, + messageId: next.id, + queue: { + read: (queuedAgentId) => + useSessionStore.getState().sessions[serverId]?.queuedMessages.get(queuedAgentId) ?? [], + write: (update) => useSessionStore.getState().setQueuedMessages(serverId, update), + }, + submitMessage: async ({ text, attachments }) => { + const supportsForgeAttachments = + useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch === true; + const wirePayload = splitComposerAttachmentsForSubmit(attachments, { + format: resolveComposerAttachmentSubmitFormat({ supportsForgeAttachments }), + }); + const images = await encodeImages(wirePayload.images); + await client.sendAgentMessage(agentId, text, { + messageId: next.id, + ...(images && images.length > 0 ? { images } : {}), + attachments: wirePayload.attachments, + }); + }, + }) + .then((result) => { + if (result.status === "failed") { + console.error("[HostRuntime] failed to drain queued agent message", { + serverId, + agentId, + error: result.errorMessage, + }); + } + return result; + }) + .finally(() => { + this.queuedAgentDrainInFlight.delete(drainKey); + }); + } + getSnapshot(serverId: string): HostRuntimeSnapshot | null { return this.controllers.get(serverId)?.getSnapshot() ?? null; } @@ -2095,63 +2145,34 @@ export class HostRuntimeStore { ).then(() => undefined); } - async refreshAgentDirectory(input: { - serverId: string; - filter?: FetchAgentsOptions["filter"]; - subscribe?: FetchAgentsOptions["subscribe"]; - page?: FetchAgentsOptions["page"]; - }): Promise<{ - agents: ReturnType["agents"]; - subscriptionId: string | null; - }> { - const controller = this.controllers.get(input.serverId); - if (!controller) { - throw new Error(`Unknown host runtime for serverId ${input.serverId}`); - } - const snapshot = controller.getSnapshot(); - const client = controller.getClient(); - if (!client || snapshot.connectionStatus !== "online") { - throw new Error(`Host ${input.serverId} is not connected`); - } + async refreshAgentDirectory( + input: AgentDirectoryRefreshInput, + ): Promise { + const directory = this.directorySyncByServer.get(input.serverId); + if (!directory) throw new Error(`Unknown host runtime for serverId ${input.serverId}`); + return directory.refreshAgents(input); + } - controller.markAgentDirectorySyncLoading(); - try { - const session = useSessionStore.getState().sessions[input.serverId]; - if (!input.filter && shouldUseLegacyDaemonWorkspaceDirectory(session?.serverInfo)) { - const result = await fetchLegacyDaemonWorkspaceDirectory({ - client, - serverId: input.serverId, - subscribe: input.subscribe, - page: input.page, - }); - controller.markAgentDirectorySyncReady(); - return { - agents: result.agents, - subscriptionId: result.subscriptionId, - }; - } + async refreshWorkspaceDirectory(input: { serverId: string; subscribe?: boolean }): Promise { + const directory = this.directorySyncByServer.get(input.serverId); + if (!directory) throw new Error(`Unknown host runtime for serverId ${input.serverId}`); + await directory.refreshWorkspaces({ subscribe: input.subscribe }); + } - const directory = await fetchCurrentAgentDirectory({ - client, - filter: input.filter, - subscribe: input.subscribe, - page: input.page, - }); + async refreshDirectories(serverId: string): Promise { + const directory = this.directorySyncByServer.get(serverId); + if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); + await directory.refreshAll(); + } - const { agents } = replaceFetchedAgentDirectory({ - serverId: input.serverId, - entries: directory.entries, - }); - - controller.markAgentDirectorySyncReady(); - return { - agents, - subscriptionId: directory.subscriptionId, - }; - } catch (error) { - controller.markAgentDirectorySyncError(toErrorMessage(error)); - throw error; - } + fetchAgentTimeline( + serverId: string, + agentId: string, + request: Parameters[1], + ): Promise>> { + const directory = this.directorySyncByServer.get(serverId); + if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); + return directory.fetchTimeline(agentId, request); } refreshAllAgentDirectories(input?: { serverIds?: string[] }): void { diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts new file mode 100644 index 000000000..69800f03a --- /dev/null +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -0,0 +1,256 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages"; +import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import { + normalizeEmptyProjectDescriptor, + normalizeWorkspaceDescriptor, + useSessionStore, +} from "@/stores/session-store"; +import type { StreamItem } from "@/types/stream"; +import { ReplicaCache, type ReplicaCacheStorage } from "."; + +const SERVER_ID = "cached-host"; +const LRU_SERVER_IDS = ["host-a", "host-b", "host-c"] as const; + +class MemoryStorage implements ReplicaCacheStorage { + readonly values = new Map(); + + async getItem(key: string): Promise { + return this.values.get(key) ?? null; + } + + async setItem(key: string, value: string): Promise { + this.values.set(key, value); + } +} + +function workspace( + id = "workspace-1", + projectId = "project-1", + workspaceDirectory = "/repo/paseo", +): WorkspaceDescriptorPayload { + return { + id, + projectId, + projectDisplayName: "Paseo", + projectRootPath: workspaceDirectory, + workspaceDirectory, + projectKind: "git", + workspaceKind: "local_checkout", + name: "main", + status: "running", + statusEnteredAt: "2026-07-18T08:00:00.000Z", + activityAt: null, + archivingAt: null, + diffStat: null, + scripts: [], + }; +} + +function agent(id: string, workspaceId = "workspace-1", cwd = "/repo/paseo") { + return normalizeAgentSnapshot( + { + id, + provider: "codex", + cwd, + workspaceId, + model: null, + createdAt: "2026-07-18T08:00:00.000Z", + updatedAt: "2026-07-18T08:01:00.000Z", + lastUserMessageAt: "2026-07-18T08:01:00.000Z", + status: "idle", + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: `Agent ${id}`, + labels: {}, + }, + SERVER_ID, + ); +} + +function message(id: string, text: string): StreamItem { + return { + kind: "assistant_message", + id, + text, + timestamp: new Date("2026-07-18T08:02:00.000Z"), + timelineCursor: { epoch: "epoch-1", seq: 12 }, + }; +} + +function seedSession(): void { + const store = useSessionStore.getState(); + store.initializeSession(SERVER_ID, null); + store.setAgents(SERVER_ID, new Map([["agent-1", agent("agent-1")]])); + store.setWorkspaces( + SERVER_ID, + new Map([["workspace-1", normalizeWorkspaceDescriptor(workspace())]]), + ); + store.setEmptyProjects(SERVER_ID, [ + normalizeEmptyProjectDescriptor({ + projectId: "empty-project", + projectDisplayName: "Empty project", + projectRootPath: "/repo/empty", + projectKind: "directory", + }), + ]); + store.setFocusedAgentId(SERVER_ID, "agent-1"); + store.setAgentStreamTail(SERVER_ID, new Map([["agent-1", [message("message-1", "Cached")]]])); + store.setAgentTimelineCursor( + SERVER_ID, + new Map([["agent-1", { epoch: "epoch-1", startSeq: 1, endSeq: 12 }]]), + ); + store.setAgentTimelineHasOlder(SERVER_ID, new Map([["agent-1", true]])); + store.setAgentAuthoritativeHistoryApplied(SERVER_ID, "agent-1", true); +} + +function seedTimeline(serverId: string, text: string): void { + const agentId = `agent-${serverId}`; + const workspaceId = `workspace-${serverId}`; + const workspaceDirectory = `/repo/${serverId}`; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null); + store.setAgents(serverId, new Map([[agentId, agent(agentId, workspaceId, workspaceDirectory)]])); + store.setWorkspaces( + serverId, + new Map([ + [ + workspaceId, + normalizeWorkspaceDescriptor( + workspace(workspaceId, `project-${serverId}`, workspaceDirectory), + ), + ], + ]), + ); + store.setFocusedAgentId(serverId, agentId); + store.setAgentStreamTail(serverId, new Map([[agentId, [message(`message-${serverId}`, text)]]])); +} + +afterEach(() => { + const store = useSessionStore.getState(); + store.clearSession(SERVER_ID); + for (const serverId of LRU_SERVER_IDS) store.clearSession(serverId); +}); + +describe("ReplicaCache", () => { + it("restores a displayable stale replica without claiming remote hydration", async () => { + const storage = new MemoryStorage(); + const writer = new ReplicaCache(storage); + writer.setHosts([SERVER_ID]); + seedSession(); + await writer.flush(); + + useSessionStore.getState().clearSession(SERVER_ID); + + const reader = new ReplicaCache(storage); + reader.setHosts([SERVER_ID]); + await reader.restore(); + + const session = useSessionStore.getState().sessions[SERVER_ID]; + expect(session?.client).toBeNull(); + expect(session?.hasHydratedAgents).toBe(false); + expect(session?.hasHydratedWorkspaces).toBe(false); + expect(Array.from(session?.agents.keys() ?? [])).toEqual(["agent-1"]); + expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["workspace-1"]); + expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual([]); + expect(session?.agents.get("agent-1")?.updatedAt).toBeInstanceOf(Date); + expect(session?.workspaces.get("workspace-1")?.statusEnteredAt).toBeInstanceOf(Date); + expect(session?.agentStreamTail.get("agent-1")).toEqual([message("message-1", "Cached")]); + expect(session?.agentAuthoritativeHistoryApplied.get("agent-1")).toBe(true); + expect(session?.agentTimelineCursor.get("agent-1")).toEqual({ + epoch: "epoch-1", + startSeq: 1, + endSeq: 12, + }); + expect(session?.agentTimelineHasOlder.get("agent-1")).toBe(true); + }); + + it("persists only the focused agent view with a short timeline tail", async () => { + const storage = new MemoryStorage(); + const cache = new ReplicaCache(storage); + cache.setHosts([SERVER_ID]); + seedSession(); + + const store = useSessionStore.getState(); + store.setAgents(SERVER_ID, (agents) => + new Map(agents).set("agent-2", agent("agent-2", "workspace-2", "/repo/other")), + ); + store.setWorkspaces(SERVER_ID, (workspaces) => + new Map(workspaces).set( + "workspace-2", + normalizeWorkspaceDescriptor(workspace("workspace-2", "project-2", "/repo/other")), + ), + ); + const secondTimeline = Array.from({ length: 60 }, (_, index) => + message(`message-${index}`, `Second ${index}`), + ); + store.setAgentStreamTail( + SERVER_ID, + new Map([ + ["agent-1", [message("message-1", "First")]], + ["agent-2", secondTimeline], + ]), + ); + store.setFocusedAgentId(SERVER_ID, "agent-2"); + await cache.flush(); + + store.clearSession(SERVER_ID); + const reader = new ReplicaCache(storage); + reader.setHosts([SERVER_ID]); + await reader.restore(); + + const session = useSessionStore.getState().sessions[SERVER_ID]; + const timelines = session?.agentStreamTail; + expect(Array.from(session?.agents.keys() ?? [])).toEqual(["agent-2"]); + expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["workspace-2"]); + expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual([]); + expect(Array.from(timelines?.keys() ?? [])).toEqual(["agent-2"]); + expect(timelines?.get("agent-2")).toEqual(secondTimeline.slice(-50)); + }); + + it("evicts the least recently written host when the cache exceeds its byte budget", async () => { + const storage = new MemoryStorage(); + const cache = new ReplicaCache(storage, { maxBytes: 7_000 }); + cache.setHosts(LRU_SERVER_IDS.slice(0, 2)); + seedTimeline("host-a", "A".repeat(1_200)); + seedTimeline("host-b", "B".repeat(1_200)); + await cache.flush(); + + seedTimeline("host-a", "A".repeat(1_201)); + await cache.flush(); + + cache.setHosts(LRU_SERVER_IDS); + seedTimeline("host-c", "C".repeat(1_200)); + await cache.flush(); + + for (const serverId of LRU_SERVER_IDS) { + useSessionStore.getState().clearSession(serverId); + } + const reader = new ReplicaCache(storage, { maxBytes: 7_000 }); + reader.setHosts(LRU_SERVER_IDS); + await reader.restore(); + + expect(Object.keys(useSessionStore.getState().sessions).sort()).toEqual(["host-a", "host-c"]); + }); + + it("drops malformed or unknown cache versions", async () => { + const storage = new MemoryStorage(); + storage.values.set("@paseo:replica-cache", JSON.stringify({ version: 999, hosts: [] })); + const cache = new ReplicaCache(storage); + cache.setHosts([SERVER_ID]); + + await cache.restore(); + + expect(useSessionStore.getState().sessions[SERVER_ID]).toBeUndefined(); + }); +}); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts new file mode 100644 index 000000000..7f8582884 --- /dev/null +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -0,0 +1,422 @@ +import { Buffer } from "buffer"; +import { z } from "zod"; +import { + AgentSnapshotPayloadSchema, + ProjectPlacementPayloadSchema, + WorkspaceDescriptorPayloadSchema, + WorkspaceProjectDescriptorPayloadSchema, + type AgentSnapshotPayload, + type WorkspaceDescriptorPayload, +} from "@getpaseo/protocol/messages"; +import { + normalizeEmptyProjectDescriptor, + normalizeWorkspaceDescriptor, + useSessionStore, + type Agent, + type SessionReplica, + type SessionState, + type WorkspaceDescriptor, +} from "@/stores/session-store"; +import type { StreamItem } from "@/types/stream"; +import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; + +const STORAGE_KEY = "@paseo:replica-cache"; +const CACHE_VERSION = 1; +const PERSIST_DELAY_MS = 750; +const MAX_TIMELINE_ITEMS = 50; +const MAX_CACHE_BYTES = 1024 * 1024; +const DATE_TAG = "__paseoDate"; + +const StoredAgentSchema = z.object({ + snapshot: AgentSnapshotPayloadSchema, + projectPlacement: ProjectPlacementPayloadSchema.nullable(), + lastActivityAt: z.string(), +}); + +const StoredTimelineSchema = z.object({ + agentId: z.string(), + items: z.unknown(), + cursor: z + .object({ + epoch: z.string(), + startSeq: z.number().int().nonnegative(), + endSeq: z.number().int().nonnegative(), + }) + .nullable(), + hasOlder: z.boolean(), +}); + +const StoredHostSchema = z.object({ + serverId: z.string(), + agents: z.array(StoredAgentSchema), + workspaces: z.array(WorkspaceDescriptorPayloadSchema), + emptyProjects: z.array(WorkspaceProjectDescriptorPayloadSchema), + timeline: StoredTimelineSchema.nullable(), +}); + +const StoredCacheSchema = z.object({ + version: z.literal(CACHE_VERSION), + hosts: z.array(StoredHostSchema), +}); + +type StoredAgent = z.infer; +type StoredHost = z.infer; + +export interface ReplicaCacheStorage { + getItem: (key: string) => Promise; + setItem: (key: string, value: string) => Promise; +} + +interface ReplicaCacheOptions { + maxBytes?: number; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasString(value: Record, key: string): boolean { + return typeof value[key] === "string"; +} + +function isStreamItem(value: unknown): value is StreamItem { + if (!isRecord(value) || !hasString(value, "id") || !(value.timestamp instanceof Date)) { + return false; + } + switch (value.kind) { + case "user_message": + case "assistant_message": + return hasString(value, "text"); + case "thought": + return hasString(value, "text") && (value.status === "loading" || value.status === "ready"); + case "tool_call": + return isRecord(value.payload) && isRecord(value.payload.data); + case "todo_list": + return hasString(value, "provider") && Array.isArray(value.items); + case "activity_log": + return hasString(value, "message") && hasString(value, "activityType"); + case "compaction": + return value.status === "loading" || value.status === "completed"; + default: + return false; + } +} + +function encodeDates(value: unknown): unknown { + if (value instanceof Date) { + return { [DATE_TAG]: value.toISOString() }; + } + if (Array.isArray(value)) { + return value.map(encodeDates); + } + if (!isRecord(value)) { + return value; + } + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, encodeDates(entry)])); +} + +function decodeDates(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(decodeDates); + } + if (!isRecord(value)) { + return value; + } + if (Object.keys(value).length === 1 && typeof value[DATE_TAG] === "string") { + const date = new Date(value[DATE_TAG]); + return Number.isNaN(date.getTime()) ? value : date; + } + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, decodeDates(entry)])); +} + +function deserializeTimeline(stored: StoredHost["timeline"]): SessionReplica["timeline"] { + if (!stored) { + return null; + } + const decoded = decodeDates(stored.items); + if (!Array.isArray(decoded) || !decoded.every(isStreamItem)) { + return null; + } + return { + agentId: stored.agentId, + items: decoded, + cursor: stored.cursor, + hasOlder: stored.hasOlder, + }; +} + +function serializeAgent(agent: Agent): StoredAgent { + const snapshot: AgentSnapshotPayload = { + id: agent.id, + provider: agent.provider, + cwd: agent.cwd, + ...(agent.workspaceId ? { workspaceId: agent.workspaceId } : {}), + model: agent.model, + ...(agent.features ? { features: agent.features } : {}), + thinkingOptionId: agent.thinkingOptionId ?? null, + createdAt: agent.createdAt.toISOString(), + updatedAt: agent.updatedAt.toISOString(), + lastUserMessageAt: agent.lastUserMessageAt?.toISOString() ?? null, + status: agent.status, + capabilities: agent.capabilities, + currentModeId: agent.currentModeId, + availableModes: agent.availableModes, + pendingPermissions: [], + persistence: agent.persistence, + ...(agent.runtimeInfo ? { runtimeInfo: agent.runtimeInfo } : {}), + ...(agent.lastUsage ? { lastUsage: agent.lastUsage } : {}), + ...(agent.lastError ? { lastError: agent.lastError } : {}), + title: agent.title, + labels: agent.labels, + requiresAttention: agent.requiresAttention ?? false, + attentionReason: agent.attentionReason ?? null, + attentionTimestamp: agent.attentionTimestamp?.toISOString() ?? null, + archivedAt: agent.archivedAt?.toISOString() ?? null, + }; + return { + snapshot, + projectPlacement: agent.projectPlacement ?? null, + lastActivityAt: agent.lastActivityAt.toISOString(), + }; +} + +function deserializeAgent(serverId: string, stored: StoredAgent): Agent { + return { + ...normalizeAgentSnapshot(stored.snapshot, serverId), + lastActivityAt: new Date(stored.lastActivityAt), + projectPlacement: stored.projectPlacement, + }; +} + +function serializeWorkspace(workspace: WorkspaceDescriptor): WorkspaceDescriptorPayload { + return { + id: workspace.id, + projectId: workspace.projectId, + projectDisplayName: workspace.projectDisplayName, + projectCustomName: workspace.projectCustomName ?? null, + projectRootPath: workspace.projectRootPath, + workspaceDirectory: workspace.workspaceDirectory, + projectKind: workspace.projectKind, + workspaceKind: workspace.workspaceKind, + name: workspace.name, + title: workspace.title ?? null, + pinnedAt: workspace.pinnedAt ?? null, + status: workspace.status, + statusEnteredAt: workspace.statusEnteredAt?.toISOString() ?? null, + activityAt: null, + archivingAt: workspace.archivingAt, + diffStat: workspace.diffStat, + scripts: workspace.scripts, + gitRuntime: workspace.gitRuntime, + githubRuntime: workspace.githubRuntime, + forge: workspace.forge, + project: workspace.project, + }; +} + +function deserializeHost(stored: StoredHost): SessionReplica { + const agents = stored.agents.map((entry) => deserializeAgent(stored.serverId, entry)); + const workspaces = stored.workspaces.map(normalizeWorkspaceDescriptor); + const emptyProjects = stored.emptyProjects.map(normalizeEmptyProjectDescriptor); + return { + agents: new Map(agents.map((agent) => [agent.id, agent])), + workspaces: new Map(workspaces.map((workspace) => [workspace.id, workspace])), + emptyProjects: new Map(emptyProjects.map((project) => [project.projectId, project])), + timeline: deserializeTimeline(stored.timeline), + }; +} + +export class ReplicaCache { + private readonly activeServerIds = new Set(); + private readonly storedHosts = new Map(); + private readonly lastFocusedAgentIds = new Map(); + private readonly capturedSessions = new Map(); + private readonly maxBytes: number; + private needsPersist = false; + private unsubscribe: (() => void) | null = null; + private persistTimer: ReturnType | null = null; + private writeQueue: Promise = Promise.resolve(); + + constructor( + private readonly storage: ReplicaCacheStorage, + options: ReplicaCacheOptions = {}, + ) { + const emptyPayloadBytes = Buffer.byteLength( + JSON.stringify({ version: CACHE_VERSION, hosts: [] }), + "utf8", + ); + this.maxBytes = Math.max(options.maxBytes ?? MAX_CACHE_BYTES, emptyPayloadBytes); + } + + setHosts(serverIds: Iterable): void { + const next = new Set(serverIds); + this.activeServerIds.clear(); + for (const serverId of next) this.activeServerIds.add(serverId); + let removedStoredHost = false; + for (const serverId of this.storedHosts.keys()) { + if (!next.has(serverId)) { + this.storedHosts.delete(serverId); + removedStoredHost = true; + } + } + for (const serverId of this.lastFocusedAgentIds.keys()) { + if (!next.has(serverId)) this.lastFocusedAgentIds.delete(serverId); + } + for (const serverId of this.capturedSessions.keys()) { + if (!next.has(serverId)) this.capturedSessions.delete(serverId); + } + if (removedStoredHost) this.needsPersist = true; + if (this.unsubscribe && this.needsPersist) this.schedulePersist(); + } + + async restore(): Promise { + let raw: string | null; + try { + raw = await this.storage.getItem(STORAGE_KEY); + } catch { + return; + } + if (!raw) return; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return; + } + const cache = StoredCacheSchema.safeParse(parsed); + if (!cache.success) return; + for (const host of cache.data.hosts) { + if (!this.activeServerIds.has(host.serverId)) { + this.needsPersist = true; + continue; + } + this.storedHosts.set(host.serverId, host); + if (host.timeline) this.lastFocusedAgentIds.set(host.serverId, host.timeline.agentId); + } + if (this.buildBoundedPayload().evicted) this.needsPersist = true; + for (const host of this.storedHosts.values()) { + useSessionStore.getState().restoreSessionReplica(host.serverId, deserializeHost(host)); + const session = useSessionStore.getState().sessions[host.serverId]; + if (session) this.capturedSessions.set(host.serverId, session); + } + } + + start(): void { + if (this.unsubscribe) return; + this.unsubscribe = useSessionStore.subscribe((state) => { + if (this.activeServerIds.size === 0) return; + for (const serverId of this.activeServerIds) { + const focusedAgentId = state.sessions[serverId]?.focusedAgentId; + if (focusedAgentId) this.lastFocusedAgentIds.set(serverId, focusedAgentId); + } + this.schedulePersist(); + }); + if (this.needsPersist) this.schedulePersist(); + } + + reconcileServerId(oldServerId: string, newServerId: string): void { + const stored = this.storedHosts.get(oldServerId); + if (stored) { + this.storedHosts.delete(oldServerId); + this.storedHosts.set(newServerId, { ...stored, serverId: newServerId }); + } + const focusedAgentId = this.lastFocusedAgentIds.get(oldServerId); + if (focusedAgentId) { + this.lastFocusedAgentIds.delete(oldServerId); + this.lastFocusedAgentIds.set(newServerId, focusedAgentId); + } + const capturedSession = this.capturedSessions.get(oldServerId); + if (capturedSession) { + this.capturedSessions.delete(oldServerId); + this.capturedSessions.set(newServerId, capturedSession); + } + if (this.activeServerIds.delete(oldServerId)) this.activeServerIds.add(newServerId); + this.needsPersist = true; + this.schedulePersist(); + } + + async flush(): Promise { + if (this.persistTimer) { + clearTimeout(this.persistTimer); + this.persistTimer = null; + } + this.captureSessions(); + const { payload } = this.buildBoundedPayload(); + this.needsPersist = false; + const write = this.writeQueue + .catch(() => undefined) + .then(() => this.storage.setItem(STORAGE_KEY, payload)); + this.writeQueue = write; + await write.catch(() => undefined); + } + + private captureSessions(): void { + const sessions = useSessionStore.getState().sessions; + for (const serverId of this.activeServerIds) { + const session = sessions[serverId]; + if (!session) continue; + if (this.capturedSessions.get(serverId) === session) continue; + this.capturedSessions.set(serverId, session); + if (session.focusedAgentId) { + this.lastFocusedAgentIds.set(serverId, session.focusedAgentId); + } + const focusedAgentId = this.lastFocusedAgentIds.get(serverId) ?? null; + const focusedAgent = focusedAgentId ? session.agents.get(focusedAgentId) : undefined; + const focusedWorkspace = focusedAgent + ? ((focusedAgent.workspaceId + ? session.workspaces.get(focusedAgent.workspaceId) + : undefined) ?? + Array.from(session.workspaces.values()).find( + (workspace) => workspace.workspaceDirectory === focusedAgent.cwd, + )) + : undefined; + const items = focusedAgentId ? session.agentStreamTail.get(focusedAgentId) : undefined; + const timeline = + focusedAgent && items + ? { + agentId: focusedAgent.id, + items: encodeDates(items.slice(-MAX_TIMELINE_ITEMS)), + cursor: session.agentTimelineCursor.get(focusedAgent.id) ?? null, + hasOlder: session.agentTimelineHasOlder.get(focusedAgent.id) ?? false, + } + : null; + const stored: StoredHost = { + serverId, + agents: focusedAgent ? [serializeAgent(focusedAgent)] : [], + workspaces: focusedWorkspace ? [serializeWorkspace(focusedWorkspace)] : [], + emptyProjects: [], + timeline, + }; + this.storedHosts.delete(serverId); + this.storedHosts.set(serverId, stored); + } + } + + private buildBoundedPayload(): { payload: string; evicted: boolean } { + let evicted = false; + let payload = this.serialize(); + while (Buffer.byteLength(payload, "utf8") > this.maxBytes && this.storedHosts.size > 0) { + const oldestServerId = this.storedHosts.keys().next().value; + if (oldestServerId === undefined) break; + this.storedHosts.delete(oldestServerId); + evicted = true; + payload = this.serialize(); + } + return { payload, evicted }; + } + + private serialize(): string { + return JSON.stringify({ + version: CACHE_VERSION, + hosts: Array.from(this.storedHosts.values()), + }); + } + + private schedulePersist(): void { + if (this.persistTimer) return; + this.persistTimer = setTimeout(() => { + this.persistTimer = null; + void this.flush(); + }, PERSIST_DELAY_MS); + } +} diff --git a/packages/app/src/schedules/schedule-form-model.test.ts b/packages/app/src/schedules/schedule-form-model.test.ts index ef490e857..5a50cd33a 100644 --- a/packages/app/src/schedules/schedule-form-model.test.ts +++ b/packages/app/src/schedules/schedule-form-model.test.ts @@ -113,6 +113,26 @@ function scheduleOnHost(input: { }; } +function heartbeatOnHost(cadence: ScheduleSummary["cadence"]): TestSchedule { + return { + id: "heartbeat-host-a", + serverId: "host-a", + serverName: "Host A", + name: "Babysit", + prompt: "Check status", + cadence, + target: { type: "agent", agentId: "agent-1" }, + status: "active", + createdAt: "2026-07-01T00:00:00.000Z", + updatedAt: "2026-07-01T00:00:00.000Z", + nextRunAt: "2026-07-02T00:00:00.000Z", + lastRunAt: null, + pausedAt: null, + expiresAt: null, + maxRuns: null, + }; +} + function providerSnapshot(models: AgentModelDefinition[]): { entries: ProviderSnapshotEntry[] } { return { entries: [ @@ -389,7 +409,7 @@ describe("schedule form model", () => { }); }); - it("normalizes interval cadences to cron cadences when opening the form", () => { + it("displays a representable legacy interval without submitting a cadence change", () => { const form = open({ mode: "edit", schedule: scheduleOnHost({ @@ -412,9 +432,12 @@ describe("schedule form model", () => { expression: "* * * * *", timezone: "Europe/Madrid", }); + form.setName("Renamed without touching cadence"); + + expect(form.getState().submitCadence).toBeUndefined(); }); - it("preserves an edited schedule's interval cadence until the cadence changes", () => { + it("does not rewrite an unrepresentable legacy interval until cadence changes", () => { const originalCadence = { type: "every" as const, everyMs: 90 * 60_000 }; const form = open({ mode: "edit", @@ -441,7 +464,7 @@ describe("schedule form model", () => { form.setName("Renamed without touching cadence"); - expect(form.getState().submitCadence).toEqual(originalCadence); + expect(form.getState().submitCadence).toBeUndefined(); form.setCadence({ type: "cron", @@ -456,6 +479,28 @@ describe("schedule form model", () => { }); }); + it("requires a cron choice before updating a legacy heartbeat", () => { + const form = open({ + mode: "edit", + schedule: heartbeatOnHost({ type: "every", everyMs: 90 * 60_000 }), + defaults: { + serverId: null, + projectTargets: PROJECT_TARGETS, + preferences: {}, + timezone: "Europe/Madrid", + }, + }); + + expect(form.getState()).toMatchObject({ targetKind: "agent", canSubmit: false }); + + form.setCadence({ type: "cron", expression: "0 9 * * *", timezone: "Europe/Madrid" }); + + expect(form.getState()).toMatchObject({ + submitCadence: { type: "cron", expression: "0 9 * * *", timezone: "Europe/Madrid" }, + canSubmit: true, + }); + }); + it("clears provider selection while resolving a different project", () => { const form = open({ mode: "create", diff --git a/packages/app/src/schedules/schedule-form-model.ts b/packages/app/src/schedules/schedule-form-model.ts index 39d4b66b3..33928ea72 100644 --- a/packages/app/src/schedules/schedule-form-model.ts +++ b/packages/app/src/schedules/schedule-form-model.ts @@ -77,6 +77,7 @@ export interface ScheduleFormProjectOption { } export type ScheduleFormTargetKind = "agent" | "new-agent"; +type CronCadence = Extract; type ProviderResolutionStatus = "idle" | "pending" | "complete"; export interface ScheduleFormState { @@ -86,7 +87,7 @@ export interface ScheduleFormState { prompt: string; maxRuns: string; cadence: ScheduleCadence; - submitCadence: ScheduleCadence; + submitCadence: CronCadence | undefined; hosts: ScheduleFormHost[]; projectOptions: ScheduleFormProjectOption[]; selectedServerId: string | null; @@ -436,13 +437,10 @@ function formatInitialMaxRuns(schedule: ScheduleFormSnapshot["schedule"]): strin } function resolveInitialSubmitCadence( - snapshot: ScheduleFormSnapshot, - initialCadence: ScheduleCadence, -): ScheduleCadence { - if (snapshot.mode === "edit" && snapshot.schedule) { - return snapshot.schedule.cadence; - } - return initialCadence; + schedule: ScheduleFormSnapshot["schedule"], + initialCadence: CronCadence, +): CronCadence | undefined { + return schedule ? undefined : initialCadence; } function resolveInitialIsolation(input: { @@ -547,12 +545,12 @@ function resolveDisclosure(state: ScheduleFormState): ScheduleDisclosureState { } function resolveCanSubmit(state: ScheduleFormState): boolean { + if (state.targetKind === "agent") { + return state.submitCadence !== undefined; + } if (state.prompt.trim().length === 0) { return false; } - if (state.targetKind === "agent") { - return true; - } const hasWorkingDir = state.workingDir.trim().length > 0; const hasMatchedProject = state.selectedProjectOptionId.trim().length > 0; if (state.mode === "create" && !hasMatchedProject) { @@ -657,7 +655,7 @@ function buildInitialState(snapshot: ScheduleFormSnapshot): ScheduleFormState { prompt: snapshot.schedule?.prompt ?? "", maxRuns: formatInitialMaxRuns(snapshot.schedule), cadence: initialCadence, - submitCadence: resolveInitialSubmitCadence(snapshot, initialCadence), + submitCadence: resolveInitialSubmitCadence(snapshot.schedule, initialCadence), hosts: [...snapshot.hosts], projectOptions: buildProjectOptions(snapshot.defaults.projectTargets, selectedServerId), selectedServerId, diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 5eefdf6fa..dcbaf1fa3 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -210,8 +210,6 @@ function selectedSidebarItemStyle({ hovered }: PressableStateCallbackType & { ho ]; } -const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder]; - function getSendBehaviorOptions(t: TFunction) { return [ { value: "interrupt" as const, label: t("settings.general.defaultSend.options.interrupt") }, @@ -360,7 +358,7 @@ function GeneralSection({ options={sendBehaviorOptions} /> - + {t("settings.general.language.label")} {t("settings.general.language.description")} @@ -387,7 +385,7 @@ function GeneralSection({ {isDesktopApp ? ( - + {t("settings.general.serviceUrls.label")} @@ -414,7 +412,7 @@ function GeneralSection({ ) : null} - + {t("settings.general.terminalScrollback.label")} @@ -700,7 +698,7 @@ function DesktopAppUpdateRow() { return ( <> - + {t("settings.about.releaseChannel.label")} @@ -714,7 +712,7 @@ function DesktopAppUpdateRow() { options={releaseChannelOptions} /> - + {t("settings.about.updates.label")} {statusText} diff --git a/packages/app/src/screens/settings/appearance/appearance-section.tsx b/packages/app/src/screens/settings/appearance/appearance-section.tsx index 4ae7b2886..3f8a41fe0 100644 --- a/packages/app/src/screens/settings/appearance/appearance-section.tsx +++ b/packages/app/src/screens/settings/appearance/appearance-section.tsx @@ -212,7 +212,6 @@ function AutoExpandReasoningRow({ value, onChange }: AutoExpandReasoningRowProps ); } -const TOOL_CALL_DETAIL_ROW_STYLE = [settingsStyles.row, settingsStyles.rowBorder]; const TOOL_CALL_DETAIL_LEVELS: readonly AppSettings["toolCallDetailLevel"][] = [ "detailed", "overview", @@ -250,7 +249,7 @@ function ToolCallDetailRow({ value, onChange }: ToolCallDetailRowProps) { const { t } = useTranslation(); const selectedLabel = getToolCallDetailLevelLabel(t, value); return ( - + {t("settings.general.toolCallDetail.label")} diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index e2a62970f..4fde68367 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -150,7 +150,7 @@ function HostNotFound() { const { t } = useTranslation(); return ( - + {t("settings.host.notFound")} @@ -276,7 +276,7 @@ export function HostAgentsPage({ serverId }: { serverId: string }) { ) : ( - + {t("settings.host.agents.unavailable")} )} @@ -300,7 +300,7 @@ export function HostWorkspacesPage({ serverId }: { serverId: string }) { ) : ( - + {t("settings.host.workspaces.unavailable")} )} @@ -1940,4 +1940,3 @@ const styles = StyleSheet.create((theme) => ({ })); const FLEX_1_STYLE = { flex: 1 }; -const EMPTY_CARD_STYLE = [settingsStyles.card, styles.emptyCard]; diff --git a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx index de1323df5..b81dcb4ed 100644 --- a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx +++ b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx @@ -264,7 +264,7 @@ export function KeyboardShortcutsSection() { if (isNative) { return ( - + {t("settings.shortcuts.unavailableOnMobile")} @@ -362,5 +362,3 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, }, })); - -const mobileCardStyle = [settingsStyles.card, styles.mobileCard]; diff --git a/packages/app/src/screens/settings/providers-section.tsx b/packages/app/src/screens/settings/providers-section.tsx index a21ac56a8..2d951c954 100644 --- a/packages/app/src/screens/settings/providers-section.tsx +++ b/packages/app/src/screens/settings/providers-section.tsx @@ -410,12 +410,12 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { style={styles.sectionSpacing} > {!hasServer || !isConnected ? ( - + {t("settings.providers.unavailable")} ) : null} {hasServer && isConnected && isLoading ? ( - + {t("settings.providers.loading")} ) : null} @@ -543,5 +543,3 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface3, }, })); - -const EMPTY_CARD_STYLE = [settingsStyles.card, styles.emptyCard]; diff --git a/packages/app/src/screens/workspace/visible-agent-ids.test.ts b/packages/app/src/screens/workspace/visible-agent-ids.test.ts new file mode 100644 index 000000000..3fbe56119 --- /dev/null +++ b/packages/app/src/screens/workspace/visible-agent-ids.test.ts @@ -0,0 +1,112 @@ +import { expect, test } from "vitest"; +import type { WorkspaceLayout } from "@/stores/workspace-layout-store"; +import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; +import { selectVisibleAgentIds } from "./visible-agent-ids"; + +test("selects only the active agent tab in every visible pane", () => { + const layout: WorkspaceLayout = { + focusedPaneId: "left", + root: { + kind: "group", + group: { + id: "root", + direction: "horizontal", + sizes: [0.5, 0.5], + children: [ + { kind: "pane", pane: { id: "left", tabIds: ["a", "hidden"], focusedTabId: "a" } }, + { kind: "pane", pane: { id: "right", tabIds: ["b"], focusedTabId: "b" } }, + ], + }, + }, + }; + const tabs: WorkspaceTab[] = [ + { tabId: "a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 }, + { tabId: "hidden", target: { kind: "agent", agentId: "agent-hidden" }, createdAt: 2 }, + { tabId: "b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 3 }, + ]; + + expect( + selectVisibleAgentIds({ layout, tabs, routeFocused: true, focusedPaneOnly: false }), + ).toEqual(["agent-a", "agent-b"]); +}); + +test("route blur publishes no viewed agents", () => { + const layout: WorkspaceLayout = { + focusedPaneId: "main", + root: { kind: "pane", pane: { id: "main", tabIds: ["a"], focusedTabId: "a" } }, + }; + const tabs: WorkspaceTab[] = [ + { tabId: "a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 }, + ]; + + expect( + selectVisibleAgentIds({ layout, tabs, routeFocused: false, focusedPaneOnly: false }), + ).toEqual([]); +}); + +test("compact and focus modes contribute only the focused pane", () => { + const layout: WorkspaceLayout = { + focusedPaneId: "right", + root: { + kind: "group", + group: { + id: "root", + direction: "horizontal", + sizes: [0.5, 0.5], + children: [ + { kind: "pane", pane: { id: "left", tabIds: ["a"], focusedTabId: "a" } }, + { kind: "pane", pane: { id: "right", tabIds: ["b"], focusedTabId: "b" } }, + ], + }, + }, + }; + const tabs: WorkspaceTab[] = [ + { tabId: "a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 }, + { tabId: "b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 }, + ]; + + expect( + selectVisibleAgentIds({ layout, tabs, routeFocused: true, focusedPaneOnly: true }), + ).toEqual(["agent-b"]); +}); + +test("pane retargeting replaces the viewed agent and duplicate panes collapse to one ID", () => { + const layout: WorkspaceLayout = { + focusedPaneId: "left", + root: { + kind: "group", + group: { + id: "root", + direction: "horizontal", + sizes: [0.5, 0.5], + children: [ + { kind: "pane", pane: { id: "left", tabIds: ["active"], focusedTabId: "active" } }, + { kind: "pane", pane: { id: "right", tabIds: ["duplicate"], focusedTabId: "duplicate" } }, + ], + }, + }, + }; + const duplicateTabs: WorkspaceTab[] = [ + { tabId: "active", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 }, + { tabId: "duplicate", target: { kind: "agent", agentId: "agent-a" }, createdAt: 2 }, + ]; + const retargetedTabs: WorkspaceTab[] = [ + { tabId: "active", target: { kind: "agent", agentId: "agent-b" }, createdAt: 1 }, + { tabId: "duplicate", target: { kind: "agent", agentId: "agent-a" }, createdAt: 2 }, + ]; + + expect({ + duplicate: selectVisibleAgentIds({ + layout, + tabs: duplicateTabs, + routeFocused: true, + focusedPaneOnly: false, + }), + retargeted: selectVisibleAgentIds({ + layout, + tabs: retargetedTabs, + routeFocused: true, + focusedPaneOnly: false, + }), + }).toEqual({ duplicate: ["agent-a"], retargeted: ["agent-a", "agent-b"] }); +}); diff --git a/packages/app/src/screens/workspace/visible-agent-ids.ts b/packages/app/src/screens/workspace/visible-agent-ids.ts new file mode 100644 index 000000000..cfaf4b3eb --- /dev/null +++ b/packages/app/src/screens/workspace/visible-agent-ids.ts @@ -0,0 +1,27 @@ +import { collectAllPanes, type WorkspaceLayout } from "@/stores/workspace-layout-store"; +import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; +import { deriveWorkspacePaneState } from "./workspace-pane-state"; + +export function selectVisibleAgentIds(input: { + layout: WorkspaceLayout | null; + tabs: WorkspaceTab[]; + routeFocused: boolean; + focusedPaneOnly: boolean; +}): string[] { + if (!input.routeFocused || !input.layout) { + return []; + } + const panes = input.focusedPaneOnly + ? collectAllPanes(input.layout.root).filter((pane) => pane.id === input.layout?.focusedPaneId) + : collectAllPanes(input.layout.root); + + return [ + ...new Set( + panes.flatMap((pane) => { + const target = deriveWorkspacePaneState({ pane, tabs: input.tabs }).activeTab?.descriptor + .target; + return target?.kind === "agent" ? [target.agentId] : []; + }), + ), + ].sort(); +} diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 3180bbc44..bee528e26 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -1165,7 +1165,9 @@ function ResolvedDesktopTabChip({ return ( - {showDropIndicatorBefore ? : null} + {showDropIndicatorBefore ? ( + + ) : null} - {showDropIndicatorAfter ? : null} + {showDropIndicatorAfter ? ( + + ) : null} ); }} @@ -1375,6 +1379,3 @@ const styles = StyleSheet.create((theme) => ({ height: 14, }, })); - -const TAB_DROP_INDICATOR_BEFORE_STYLE = [styles.tabDropIndicator, styles.tabDropIndicatorBefore]; -const TAB_DROP_INDICATOR_AFTER_STYLE = [styles.tabDropIndicator, styles.tabDropIndicatorAfter]; diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index cc585e95a..828eef184 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -2,6 +2,7 @@ import { memo, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -89,6 +90,7 @@ import { normalizeWorkspaceTabTarget, workspaceTabTargetsEqual, } from "@/workspace-tabs/identity"; +import { selectVisibleAgentIds } from "./visible-agent-ids"; import { getHostRuntimeStore, useHostRuntimeClient, @@ -2013,6 +2015,31 @@ function WorkspaceScreenContent({ }), [uiTabs, workspaceLayout], ); + const viewedTimelineSync = useSessionStore( + (state) => state.sessions[normalizedServerId]?.viewedTimelineSync ?? null, + ); + const visibleAgentIds = useMemo( + () => + selectVisibleAgentIds({ + layout: workspaceLayout, + tabs: uiTabs, + routeFocused: isRouteFocused, + focusedPaneOnly: isMobile || isFocusModeEnabled || !supportsDesktopPaneSplits(), + }), + [isFocusModeEnabled, isMobile, isRouteFocused, uiTabs, workspaceLayout], + ); + useLayoutEffect(() => { + if (!persistenceKey || !viewedTimelineSync) { + return; + } + viewedTimelineSync.replaceVisibleAgentIds(persistenceKey, visibleAgentIds); + }, [persistenceKey, viewedTimelineSync, visibleAgentIds]); + useEffect(() => { + if (!persistenceKey || !viewedTimelineSync) { + return; + } + return () => viewedTimelineSync.replaceVisibleAgentIds(persistenceKey, []); + }, [persistenceKey, viewedTimelineSync]); const setFocusedAgentId = useSessionStore((state) => state.setFocusedAgentId); const setFocusedTerminalId = useSessionStore((state) => state.setFocusedTerminalId); const focusedPaneAgentId = useMemo(() => { @@ -2740,7 +2767,7 @@ function WorkspaceScreenContent({ // dropped against the stale cursor. const sessionState = useSessionStore.getState().sessions[normalizedServerId]; const currentCursor = sessionState?.agentTimelineCursor.get(agentId); - await client.fetchAgentTimeline(agentId, { + await getHostRuntimeStore().fetchAgentTimeline(normalizedServerId, agentId, { direction: "tail", projection: "projected", ...(currentCursor @@ -3314,7 +3341,7 @@ function WorkspaceScreenContent({ [t], ); - const containerStyle = containerWithWorkspaceBackgroundStyle; + const containerStyle = [styles.container, styles.containerWorkspaceBackground]; const menuNewAgentIcon = MENU_NEW_AGENT_ICON; const menuNewTerminalIcon = MENU_NEW_TERMINAL_ICON; @@ -4063,9 +4090,4 @@ const styles = StyleSheet.create((theme) => ({ }, })); -const containerWithWorkspaceBackgroundStyle = [ - styles.container, - styles.containerWorkspaceBackground, -]; - const EXPLORER_TOGGLE_KEYS: ShortcutKey[] = ["mod", "E"]; diff --git a/packages/app/src/screens/workspace/workspace-scripts-button.tsx b/packages/app/src/screens/workspace/workspace-scripts-button.tsx index 4d3b25ec6..d310d759c 100644 --- a/packages/app/src/screens/workspace/workspace-scripts-button.tsx +++ b/packages/app/src/screens/workspace/workspace-scripts-button.tsx @@ -88,7 +88,9 @@ function ScriptActionButtonChildren({ ) : ( ); - const labelStyle = hovered ? actionButtonLabelHoveredStyle : styles.actionButtonLabel; + const labelStyle = hovered + ? [styles.actionButtonLabel, styles.actionButtonLabelHovered] + : styles.actionButtonLabel; return ( <> {iconElement} @@ -156,7 +158,7 @@ function HostLinkChildren({ hovered, disabled, label }: HostLinkChildrenProps): const showIcon = !disabled && (hovered || isNative); const isActive = Boolean(hovered) && !disabled; const colorMapping = isActive ? foregroundColorMapping : mutedColorMapping; - const hostLabelStyle = isActive ? hostLabelActiveStyle : styles.hostLabel; + const hostLabelStyle = isActive ? [styles.hostLabel, styles.hostLabelActive] : styles.hostLabel; return ( <> @@ -207,7 +209,8 @@ function HostLinkRow({ label, url, scriptName, onOpenInBrowserTab }: HostLinkPro function ExitCodeBadge({ code }: { code: number }): ReactElement { const { t } = useTranslation(); - const exitTextStyle = code === 0 ? styles.exitBadgeText : exitBadgeTextErrorStyle; + const exitTextStyle = + code === 0 ? styles.exitBadgeText : [styles.exitBadgeText, styles.exitBadgeTextError]; return ( {t("workspace.scripts.states.exitCode", { code })} @@ -305,7 +308,7 @@ function ScriptRow({ }, [onStartScript, script.scriptName]); const scriptNameStyle = useMemo( - () => (isRunning ? scriptNameActiveStyle : styles.scriptName), + () => (isRunning ? [styles.scriptName, styles.scriptNameActive] : styles.scriptName), [isRunning], ); @@ -631,8 +634,3 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, }, })); - -const actionButtonLabelHoveredStyle = [styles.actionButtonLabel, styles.actionButtonLabelHovered]; -const hostLabelActiveStyle = [styles.hostLabel, styles.hostLabelActive]; -const scriptNameActiveStyle = [styles.scriptName, styles.scriptNameActive]; -const exitBadgeTextErrorStyle = [styles.exitBadgeText, styles.exitBadgeTextError]; diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index 4353c4ea8..1588fd114 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -17,6 +17,7 @@ function createWorkspace( id: input.id, projectId: input.projectId ?? "project-1", projectDisplayName: input.projectDisplayName ?? "Project 1", + projectCustomName: input.projectCustomName ?? null, projectRootPath: input.projectRootPath ?? "/repo", workspaceDirectory: input.workspaceDirectory ?? "/repo", projectKind: input.projectKind ?? "git", diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 480deaa87..d6e37a171 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -2,6 +2,7 @@ import equal from "fast-deep-equal"; import { create } from "zustand"; import { subscribeWithSelector } from "zustand/middleware"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync"; import type { AgentDirectoryEntry } from "@/types/agent-directory"; import { handoffCreatedAgentUserMessageToStream, @@ -319,12 +320,30 @@ export interface AgentTimelineCursorState { endSeq: number; } +export interface SessionReplicaTimeline { + agentId: string; + items: StreamItem[]; + cursor: AgentTimelineCursorState | null; + hasOlder: boolean; +} + +export interface SessionReplica { + agents: Map; + workspaces: Map; + emptyProjects: Map; + timeline: SessionReplicaTimeline | null; +} + +export type WorkspaceRestoreStatus = "restoring" | "failed" | "needs-host-upgrade"; + // Per-session state export interface SessionState { serverId: string; // Daemon client (immutable reference) client: DaemonClient | null; + clientGeneration: number; + viewedTimelineSync: ViewedTimelineUiBridge | null; // Server metadata (from server_info handshake) serverInfo: DaemonServerInfo | null; @@ -365,6 +384,9 @@ export interface SessionState { // Project parents with no active workspaces, keyed by projectId. The // `emptyProjects` name is the existing protocol/store projection. emptyProjects: Map; + // Transient restore state for archived workspaces, keyed by normalized + // workspaceId. Cleared in mergeWorkspaces when the descriptor lands. + restoringWorkspaces: Map; // Permissions pendingPermissions: Map; @@ -390,10 +412,16 @@ interface SessionStoreState { // Action types interface SessionStoreActions { // Session management - initializeSession: (serverId: string, client: DaemonClient) => void; + initializeSession: ( + serverId: string, + client: DaemonClient | null, + clientGeneration?: number, + ) => void; + restoreSessionReplica: (serverId: string, replica: SessionReplica) => void; clearSession: (serverId: string) => void; getSession: (serverId: string) => SessionState | undefined; - updateSessionClient: (serverId: string, client: DaemonClient) => void; + updateSessionClient: (serverId: string, client: DaemonClient, clientGeneration?: number) => void; + setViewedTimelineSync: (serverId: string, sync: ViewedTimelineUiBridge | null) => void; updateSessionServerInfo: (serverId: string, info: DaemonServerInfo) => void; // Audio state @@ -485,6 +513,13 @@ interface SessionStoreActions { setEmptyProjects: (serverId: string, emptyProjects: Iterable) => void; addEmptyProject: (serverId: string, emptyProject: EmptyProjectDescriptor) => void; removeEmptyProject: (serverId: string, projectId: string) => void; + setWorkspaceRestoreStatus: ( + serverId: string, + workspaceId: string, + status: WorkspaceRestoreStatus, + ) => void; + clearWorkspaceRestoreStatus: (serverId: string, workspaceId: string) => void; + // Agent activity timestamps setAgentLastActivity: (agentId: string, timestamp: Date) => void; setAgentLastActivityBatch: ( @@ -531,10 +566,16 @@ type SessionStore = SessionStoreState & SessionStoreActions; const agentLastActivityCoalescer = createAgentLastActivityCoalescer(); // Helper to create initial session state -function createInitialSessionState(serverId: string, client: DaemonClient): SessionState { +function createInitialSessionState( + serverId: string, + client: DaemonClient | null, + clientGeneration = 0, +): SessionState { return { serverId, client, + clientGeneration, + viewedTimelineSync: null, serverInfo: null, hasHydratedAgents: false, hasHydratedWorkspaces: false, @@ -557,6 +598,7 @@ function createInitialSessionState(serverId: string, client: DaemonClient): Sess agentDetails: new Map(), workspaces: new Map(), emptyProjects: new Map(), + restoringWorkspaces: new Map(), pendingPermissions: new Map(), fileExplorer: new Map(), queuedMessages: new Map(), @@ -637,7 +679,7 @@ export const useSessionStore = create()( agentLastActivity: new Map(), // Session management - initializeSession: (serverId, client) => { + initializeSession: (serverId, client, clientGeneration) => { set((prev) => { if (prev.sessions[serverId]) { return prev; @@ -646,12 +688,57 @@ export const useSessionStore = create()( ...prev, sessions: { ...prev.sessions, - [serverId]: createInitialSessionState(serverId, client), + [serverId]: createInitialSessionState(serverId, client, clientGeneration), }, }; }); }, + restoreSessionReplica: (serverId, replica) => { + set((prev) => { + if (prev.sessions[serverId]) { + return prev; + } + const session = createInitialSessionState(serverId, null); + const timeline = replica.timeline; + const agentStreamTail = new Map(); + const agentTimelineCursor = new Map(); + const agentTimelineHasOlder = new Map(); + const agentAuthoritativeHistoryApplied = new Map(); + const agentHistorySyncGeneration = new Map(); + if (timeline) { + agentStreamTail.set(timeline.agentId, timeline.items); + agentTimelineHasOlder.set(timeline.agentId, timeline.hasOlder); + agentAuthoritativeHistoryApplied.set(timeline.agentId, true); + agentHistorySyncGeneration.set(timeline.agentId, session.historySyncGeneration); + if (timeline.cursor) agentTimelineCursor.set(timeline.agentId, timeline.cursor); + } + const agentLastActivity = new Map(prev.agentLastActivity); + for (const agent of replica.agents.values()) { + agentLastActivity.set(agent.id, agent.lastActivityAt); + } + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { + ...session, + agents: replica.agents, + workspaceAgentActivity: buildWorkspaceAgentActivityIndex(replica.agents), + workspaces: replica.workspaces, + emptyProjects: replica.emptyProjects, + agentStreamTail, + agentTimelineCursor, + agentTimelineHasOlder, + agentAuthoritativeHistoryApplied, + agentHistorySyncGeneration, + }, + }, + agentLastActivity, + }; + }); + }, + clearSession: (serverId) => { agentLastActivityCoalescer.flushNow(); set((prev) => { @@ -686,7 +773,7 @@ export const useSessionStore = create()( }); }, - updateSessionClient: (serverId, client) => { + updateSessionClient: (serverId, client, clientGeneration = 0) => { set((prev) => { const session = prev.sessions[serverId]; @@ -694,7 +781,7 @@ export const useSessionStore = create()( return prev; } - if (session.client === client) { + if (session.client === client && session.clientGeneration === clientGeneration) { return prev; } @@ -705,12 +792,29 @@ export const useSessionStore = create()( [serverId]: { ...session, client, + clientGeneration, }, }, }; }); }, + setViewedTimelineSync: (serverId, viewedTimelineSync) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session || session.viewedTimelineSync === viewedTimelineSync) { + return prev; + } + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { ...session, viewedTimelineSync }, + }, + }; + }); + }, + updateSessionServerInfo: (serverId, info) => { set((prev) => { const session = prev.sessions[serverId]; @@ -1320,6 +1424,54 @@ export const useSessionStore = create()( }); }, + setWorkspaceRestoreStatus: (serverId, workspaceId, status) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) { + return prev; + } + if (session.restoringWorkspaces.get(workspaceId) === status) { + return prev; + } + // A late dir-gone timeout must not override a successful restore: + // only mark failed while still restoring and the descriptor is absent. + if ( + status === "failed" && + (session.restoringWorkspaces.get(workspaceId) !== "restoring" || + session.workspaces.has(workspaceId)) + ) { + return prev; + } + const next = new Map(session.restoringWorkspaces); + next.set(workspaceId, status); + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { ...session, restoringWorkspaces: next }, + }, + }; + }); + }, + + clearWorkspaceRestoreStatus: (serverId, workspaceId) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session || !session.restoringWorkspaces.has(workspaceId)) { + return prev; + } + const next = new Map(session.restoringWorkspaces); + next.delete(workspaceId); + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { ...session, restoringWorkspaces: next }, + }, + }; + }); + }, + mergeWorkspaces: (serverId, workspaces) => { const nextEntries = Array.from(workspaces); set((prev) => { @@ -1333,10 +1485,18 @@ export const useSessionStore = create()( // empty: prune any stale empty descriptor so it stops governing the // project's rendered metadata. const nextEmptyProjects = new Map(session.emptyProjects); + // A descriptor arriving is the success signal for a pending restore: + // clear it at the source so every entry point converges to "ready". + let nextRestoring: Map | null = null; for (const workspace of nextEntries) { if (nextEmptyProjects.delete(workspace.projectId)) { changed = true; } + if (session.restoringWorkspaces.has(workspace.id)) { + nextRestoring ??= new Map(session.restoringWorkspaces); + nextRestoring.delete(workspace.id); + changed = true; + } const existing = next.get(workspace.id); const nextWorkspace = preserveWorkspaceDescriptorIdentity(workspace, existing); if (existing === nextWorkspace) { @@ -1356,6 +1516,7 @@ export const useSessionStore = create()( ...session, workspaces: next, emptyProjects: nextEmptyProjects, + restoringWorkspaces: nextRestoring ?? session.restoringWorkspaces, }, }, }; @@ -1574,3 +1735,14 @@ export const useSessionStore = create()( }; }), ); + +export function useWorkspaceRestoreStatus( + serverId: string | null, + workspaceId: string | null, +): WorkspaceRestoreStatus | null { + return useSessionStore((state) => + serverId && workspaceId + ? (state.sessions[serverId]?.restoringWorkspaces.get(workspaceId) ?? null) + : null, + ); +} diff --git a/packages/app/src/styles/unistyles-module-scope.test.ts b/packages/app/src/styles/unistyles-module-scope.test.ts new file mode 100644 index 000000000..714875d97 --- /dev/null +++ b/packages/app/src/styles/unistyles-module-scope.test.ts @@ -0,0 +1,76 @@ +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import * as ts from "typescript"; +import { describe, expect, it } from "vitest"; + +const SOURCE_ROOT = path.resolve(__dirname, ".."); + +function listSourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + return listSourceFiles(entryPath); + } + return /\.tsx?$/.test(entry.name) ? [entryPath] : []; + }); +} + +function containsEagerStyleRead(initializer: ts.Expression): boolean { + if (ts.isFunctionLike(initializer)) { + return false; + } + + let found = false; + + function visit(node: ts.Node): void { + if (node !== initializer && ts.isFunctionLike(node)) { + return; + } + if ( + ts.isPropertyAccessExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text !== "StyleSheet" && + /styles$/i.test(node.expression.text) + ) { + found = true; + return; + } + ts.forEachChild(node, visit); + } + + visit(initializer); + return found; +} + +function findEagerModuleStyleReads(filePath: string): string[] { + const source = readFileSync(filePath, "utf8"); + const sourceFile = ts.createSourceFile( + filePath, + source, + ts.ScriptTarget.Latest, + true, + filePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + + return sourceFile.statements.flatMap((statement) => { + if (!ts.isVariableStatement(statement)) { + return []; + } + return statement.declarationList.declarations.flatMap((declaration) => { + if (!declaration.initializer || !containsEagerStyleRead(declaration.initializer)) { + return []; + } + const line = + sourceFile.getLineAndCharacterOfPosition(declaration.getStart(sourceFile)).line + 1; + return [`${path.relative(SOURCE_ROOT, filePath)}:${line}`]; + }); + }); +} + +describe("Unistyles module scope", () => { + it("does not materialize style proxies before the persisted theme loads", () => { + const violations = listSourceFiles(SOURCE_ROOT).flatMap(findEagerModuleStyleReads); + + expect(violations).toEqual([]); + }); +}); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts index baa6ae350..1c191b1b1 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts @@ -66,6 +66,10 @@ async function waitFor(input: { predicate: () => boolean; timeoutMs?: number }): } } +function settleMountRefits(): Promise { + return new Promise((resolve) => setTimeout(resolve, 2_600)); +} + function createTerminalHost(input: { width: number; height: number; @@ -204,8 +208,17 @@ describe("terminal emulator runtime in a real browser", () => { const mounted = createTerminalHost({ width: 720, height: 360 }); await waitFor({ predicate: () => mounted.sizes.length > 0 }); + await settleMountRefits(); - expect(mounted.sizes[0]?.shouldClaim).toBe(false); + expect(mounted.sizes.length).toBeGreaterThan(1); + expect(mounted.sizes.filter((size) => size.shouldClaim)).toEqual([]); + + const settledSize = latestSize(mounted.sizes); + mounted.runtime.resize({ force: true, shouldClaim: true }); + + expect(mounted.sizes.filter((size) => size.shouldClaim)).toEqual([ + { ...settledSize, shouldClaim: true }, + ]); }); it("reports a larger PTY size when the terminal container grows", async () => { diff --git a/packages/app/src/terminal/runtime/terminal-mount-claim.browser.test.ts b/packages/app/src/terminal/runtime/terminal-mount-claim.browser.test.ts deleted file mode 100644 index ff3d36649..000000000 --- a/packages/app/src/terminal/runtime/terminal-mount-claim.browser.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { page } from "@vitest/browser/context"; -import { afterEach, describe, expect, it } from "vitest"; -import { TerminalEmulatorRuntime } from "./terminal-emulator-runtime"; - -/** - * Pins the mechanism behind "terminal stuck at 80x24". - * - * The daemon spawns a PTY at 80x24 and only resizes it when the client sends a resize frame. - * `handleTerminalResize` in terminal-pane only forwards a frame when the emitted fit carries - * `shouldClaim: true`. - * - * These tests show that a freshly mounted terminal NEVER emits such a fit. Every mount-time - * refit (the mount fit, the retry ladder, fonts.ready, the WebGL swap, visibility restore) is - * emitted with `shouldClaim: false`. The ResizeObserver's first delivery IS `shouldClaim: true`, - * but it is emitted without `force`, so the runtime's own dedupe drops it: the size has not - * changed since the mount fit. - * - * So after a fresh mount, in a pane whose size never changes, the client sends the daemon - * NOTHING. The PTY keeps whatever size it was created with. The terminal's size then depends - * entirely on either the size carried at subscribe, or an explicit forced reflow - * (`requestTerminalReflow` -> `runtime.resize({force, shouldClaim})`) -- which is exactly the - * path terminal-pane's focus effects can permanently latch off. - */ - -interface EmittedSize { - rows: number; - cols: number; - shouldClaim: boolean; -} - -interface MountedTerminal { - root: HTMLDivElement; - runtime: TerminalEmulatorRuntime; - sizes: EmittedSize[]; -} - -const mounted: MountedTerminal[] = []; - -afterEach(() => { - for (const entry of mounted.splice(0)) { - entry.runtime.unmount(); - entry.root.remove(); - } -}); - -function mountTerminal(input: { width: number; height: number }): MountedTerminal { - const root = document.createElement("div"); - root.style.cssText = `width:${input.width}px;height:${input.height}px;position:fixed;left:0;top:0;overflow:hidden`; - const host = document.createElement("div"); - host.style.cssText = "width:100%;height:100%"; - root.appendChild(host); - document.body.appendChild(root); - - const sizes: EmittedSize[] = []; - const runtime = new TerminalEmulatorRuntime(); - runtime.setCallbacks({ - callbacks: { - onResize: (size) => { - sizes.push(size as EmittedSize); - }, - }, - }); - runtime.mount({ - root, - host, - initialSnapshot: null, - scrollback: 1_000, - theme: { background: "#0b0b0b", foreground: "#e6e6e6", cursor: "#e6e6e6" }, - }); - - const entry = { root, runtime, sizes }; - mounted.push(entry); - return entry; -} - -function nextFrame(): Promise { - return new Promise((resolve) => { - requestAnimationFrame(() => { - resolve(); - }); - }); -} - -async function waitFor(input: { predicate: () => boolean; timeoutMs?: number }): Promise { - const startedAt = performance.now(); - const timeoutMs = input.timeoutMs ?? 2_000; - - while (!input.predicate()) { - if (performance.now() - startedAt > timeoutMs) { - throw new Error("Timed out waiting for terminal browser condition"); - } - await nextFrame(); - } -} - -/** - * Outlast the runtime's whole refit ladder (0/16/48/120/250/500/1000/2000ms) plus fonts and the - * WebGL swap, so nothing is still in flight when we assert. Unlike every other wait in this file - * this one cannot be a predicate: the point is to prove that no claim EVER arrives, and there is - * no event that says "the mount is done emitting". - */ -function settleMountRefits(): Promise { - return new Promise((resolve) => setTimeout(resolve, 2_600)); -} - -describe("a freshly mounted terminal never claims its size", () => { - it("emits many fits on mount, and not one of them claims the PTY size", async () => { - await page.viewport(1400, 900); - const terminal = mountTerminal({ width: 900, height: 600 }); - - await settleMountRefits(); - - // It really did refit repeatedly while mounting... - expect(terminal.sizes.length).toBeGreaterThan(1); - // ...and it measured a real viewport, not the daemon's default. - expect(terminal.sizes.at(-1)?.cols).not.toBe(80); - - // ...but every single one of those fits declined to own the PTY size, so terminal-pane - // forwards NOTHING to the daemon. This is why a terminal can sit at 80x24 while its xterm - // renders perfectly at the real size. - const claims = terminal.sizes.filter((size) => size.shouldClaim); - expect(claims).toEqual([]); - }); - - it("only an explicit forced reflow claims — the path the focus latch can kill", async () => { - await page.viewport(1400, 900); - const terminal = mountTerminal({ width: 900, height: 600 }); - await settleMountRefits(); - expect(terminal.sizes.filter((size) => size.shouldClaim)).toEqual([]); - const settledSize = terminal.sizes.at(-1); - - // This is what requestTerminalReflow does. It is the ONLY thing that makes a - // stable-sized, freshly mounted terminal tell the daemon how big it is. - terminal.runtime.resize({ force: true, shouldClaim: true }); - - // It claims the size the terminal actually settled at, so the PTY ends up matching what the - // user sees rendered. - expect(terminal.sizes.filter((size) => size.shouldClaim)).toEqual([ - { rows: settledSize?.rows, cols: settledSize?.cols, shouldClaim: true }, - ]); - }); - - it("a genuine size change does claim — which is why resizing the window unsticks it", async () => { - await page.viewport(1400, 900); - const terminal = mountTerminal({ width: 900, height: 600 }); - await settleMountRefits(); - expect(terminal.sizes.filter((size) => size.shouldClaim)).toEqual([]); - - // The ResizeObserver fit IS shouldClaim:true; it is only suppressed while the size is - // unchanged. Change the box and it gets through -- the user-visible "resize the window and - // it fixes itself" behaviour. - terminal.root.style.width = "1300px"; - - await waitFor({ predicate: () => terminal.sizes.some((size) => size.shouldClaim) }); - const claims = terminal.sizes.filter((size) => size.shouldClaim); - // The claim carries the NEW width, not the size it mounted at. - expect(claims.at(-1)?.cols).toBeGreaterThan(terminal.sizes[0]?.cols ?? 0); - }); -}); diff --git a/packages/app/src/timeline/fetch-agent-timeline-once.test.ts b/packages/app/src/timeline/fetch-agent-timeline-once.test.ts new file mode 100644 index 000000000..1ae97e028 --- /dev/null +++ b/packages/app/src/timeline/fetch-agent-timeline-once.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from "vitest"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { fetchAgentTimelineOnce } from "./fetch-agent-timeline-once"; + +type TimelinePage = Awaited>; + +test("concurrent identical timeline reads share one request", async () => { + let resolvePage: (page: TimelinePage) => void = () => {}; + const page = new Promise((resolve) => { + resolvePage = resolve; + }); + const requests: Array<{ agentId: string; direction: string }> = []; + const client = { + fetchAgentTimeline: async ( + agentId: string, + request: Parameters[1], + ) => { + requests.push({ agentId, direction: request?.direction ?? "tail" }); + return page; + }, + }; + const request = { direction: "tail" as const, limit: 100, projection: "projected" as const }; + + const first = fetchAgentTimelineOnce(client, "agent", request); + const second = fetchAgentTimelineOnce(client, "agent", request); + resolvePage({ hasNewer: false } as TimelinePage); + + await expect(Promise.all([first, second])).resolves.toHaveLength(2); + expect(requests).toEqual([{ agentId: "agent", direction: "tail" }]); +}); diff --git a/packages/app/src/timeline/fetch-agent-timeline-once.ts b/packages/app/src/timeline/fetch-agent-timeline-once.ts new file mode 100644 index 000000000..e0d67b489 --- /dev/null +++ b/packages/app/src/timeline/fetch-agent-timeline-once.ts @@ -0,0 +1,31 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; + +type TimelineClient = Pick; +type TimelineRequest = Parameters[1]; +type TimelinePage = Awaited>; + +const inFlightByClient = new WeakMap>>(); + +export function fetchAgentTimelineOnce( + client: TimelineClient, + agentId: string, + request: TimelineRequest, +): Promise { + let inFlight = inFlightByClient.get(client); + if (!inFlight) { + inFlight = new Map(); + inFlightByClient.set(client, inFlight); + } + + const key = `${agentId}:${JSON.stringify(request)}`; + const existing = inFlight.get(key); + if (existing) return existing; + + const fetch = client.fetchAgentTimeline(agentId, request); + inFlight.set(key, fetch); + const clear = () => { + if (inFlight.get(key) === fetch) inFlight.delete(key); + }; + void fetch.then(clear, clear); + return fetch; +} diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index e0026371c..05048e2d5 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -107,7 +107,10 @@ function makeStreamReducerEvent( }; } -function makeAssistantItem(text: string, id = `assistant-${text.length}`): StreamItem { +function makeAssistantItem( + text: string, + id = `assistant-${text.length}`, +): Extract { return { kind: "assistant_message", id, @@ -556,7 +559,7 @@ describe("processTimelineResponse", () => { item: { type: "user_message", text: "sent while catching up", - messageId: "canonical-after", + messageId: "optimistic-after", }, }, ], @@ -565,10 +568,125 @@ describe("processTimelineResponse", () => { const userMessages = result.tail.filter((item) => item.kind === "user_message"); expect(userMessages).toHaveLength(1); - expect(userMessages[0]?.id).toBe("canonical-after"); + expect(userMessages[0]?.id).toBe("optimistic-after"); expect(userMessages[0]?.optimistic).toBeUndefined(); }); + it("reconciles multiple optimistic user messages in canonical order", () => { + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [ + makeOptimisticUserMessage("first prompt", "optimistic-first"), + makeOptimisticUserMessage("second prompt", "optimistic-second"), + ], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, + payload: { + ...baseTimelineInput.payload, + epoch: "epoch-1", + startCursor: { seq: 2 }, + endCursor: { seq: 3 }, + entries: [ + { + ...makeTimelineEntry(2, "first prompt", "user_message"), + item: { type: "user_message", text: "first prompt", messageId: "optimistic-first" }, + }, + { + ...makeTimelineEntry(3, "second prompt", "user_message"), + item: { + type: "user_message", + text: "second prompt", + messageId: "optimistic-second", + }, + }, + ], + }, + }); + + expect( + result.tail + .filter((item) => item.kind === "user_message") + .map((item) => ({ id: item.id, text: item.text, optimistic: item.optimistic })), + ).toEqual([ + { id: "optimistic-first", text: "first prompt", optimistic: undefined }, + { id: "optimistic-second", text: "second prompt", optimistic: undefined }, + ]); + }); + + it("keeps a tail optimistic prompt before a reconciled live assistant head", () => { + const prompt = makeOptimisticUserMessage("new prompt", "optimistic-new-prompt"); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [prompt], + currentHead: [ + { + ...makeAssistantItem("Hel", "answer-1"), + messageId: "answer-1", + }, + ], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 2 }, + payload: { + ...baseTimelineInput.payload, + direction: "after", + epoch: "epoch-1", + startCursor: { seq: 3 }, + endCursor: { seq: 3 }, + entries: [ + { + ...makeTimelineEntry(2, "Hello", "assistant_message", 3), + sourceSeqRanges: [{ startSeq: 2, endSeq: 3 }], + item: { + type: "assistant_message", + text: "Hello", + messageId: "answer-1", + }, + }, + ], + }, + }); + + expect( + [...result.tail, ...result.head] + .filter((item) => item.kind === "assistant_message" || item.kind === "user_message") + .map((item) => item.text), + ).toEqual(["new prompt", "Hello"]); + }); + + it("keeps a tail optimistic prompt before a live head flushed by catch-up", () => { + const prompt = makeOptimisticUserMessage("new prompt", "optimistic-new-prompt"); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [prompt], + currentHead: [ + { + ...makeAssistantItem("Live response", "answer-1"), + messageId: "answer-1", + }, + ], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 2 }, + payload: { + ...baseTimelineInput.payload, + direction: "after", + epoch: "epoch-1", + startCursor: { seq: 3 }, + endCursor: { seq: 3 }, + entries: [ + makeToolCallTimelineEntry(3, "call-1", "running", { + type: "read", + filePath: "/tmp/example.ts", + }), + ], + }, + }); + + expect( + [...result.tail, ...result.head] + .filter((item) => item.kind === "assistant_message" || item.kind === "user_message") + .map((item) => item.text), + ).toEqual(["new prompt", "Live response"]); + }); + it("keeps an active assistant head live when an incremental fetch accepts same-turn assistant text", () => { const existingCursor: TimelineCursor = { epoch: "epoch-1", @@ -597,6 +715,504 @@ describe("processTimelineResponse", () => { }); }); + it("does not replay an assistant prefix when catch-up completes an earlier tool call", () => { + const live = processAgentStreamEvents({ + events: [ + makeStreamReducerEvent(makeToolCallTimelineEvent("call-1"), 1), + makeStreamReducerEvent(makeAssistantTimelineEvent("Hel", "answer-1"), 2), + ], + currentTail: [], + currentHead: [], + currentCursor: undefined, + currentAgent: null, + }); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: live.tail, + currentHead: live.head, + currentCursor: live.cursor ?? undefined, + payload: { + ...baseTimelineInput.payload, + epoch: "epoch-1", + startCursor: { seq: 3 }, + endCursor: { seq: 4 }, + entries: [ + { + ...makeToolCallTimelineEntry(1, "call-1", "completed", { + type: "read", + filePath: "/tmp/example.ts", + }), + seqEnd: 4, + sourceSeqRanges: [ + { startSeq: 1, endSeq: 1 }, + { startSeq: 4, endSeq: 4 }, + ], + }, + { + ...makeTimelineEntry(2, "Hello", "assistant_message", 3), + sourceSeqRanges: [{ startSeq: 2, endSeq: 3 }], + item: { + type: "assistant_message", + text: "Hello", + messageId: "answer-1", + }, + }, + ], + }, + }); + + expect(getAssistantTexts([...result.tail, ...result.head])).toEqual(["Hello"]); + }); + + it("reconciles an identified projection with an overlapping anonymous live prefix", () => { + const result = processTimelineResponse({ + ...baseTimelineInput, + currentHead: [makeAssistantItem("Hel")], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 2 }, + payload: { + ...baseTimelineInput.payload, + epoch: "epoch-1", + startCursor: { seq: 3 }, + endCursor: { seq: 3 }, + entries: [ + { + ...makeTimelineEntry(2, "Hello", "assistant_message", 3), + sourceSeqRanges: [{ startSeq: 2, endSeq: 3 }], + item: { + type: "assistant_message", + text: "Hello", + messageId: "answer-1", + }, + }, + ], + }, + }); + + const assistants = [...result.tail, ...result.head].filter( + (item) => item.kind === "assistant_message", + ); + expect(assistants).toHaveLength(1); + expect(assistants[0]).toMatchObject({ text: "Hello", messageId: "answer-1" }); + }); + + it("replaces every promoted assistant block when reconciling a projected message", () => { + const live = processAgentStreamEvents({ + events: [makeStreamReducerEvent(makeAssistantTimelineEvent("First paragraph.\n\nSec"), 2)], + currentTail: [], + currentHead: [], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, + currentAgent: null, + }); + expect(getAssistantTexts(live.tail)).toHaveLength(1); + expect(getAssistantTexts(live.head)).toHaveLength(1); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: live.tail, + currentHead: live.head, + currentCursor: live.cursor ?? undefined, + payload: { + ...baseTimelineInput.payload, + direction: "after", + epoch: "epoch-1", + startCursor: { seq: 3 }, + endCursor: { seq: 3 }, + entries: [ + { + ...makeTimelineEntry( + 2, + "First paragraph.\n\nSecond paragraph.", + "assistant_message", + 3, + ), + sourceSeqRanges: [{ startSeq: 2, endSeq: 3 }], + item: { + type: "assistant_message", + text: "First paragraph.\n\nSecond paragraph.", + }, + }, + ], + }, + }); + + expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([ + "First paragraph.\n\nSecond paragraph.", + ]); + }); + + it("does not replay a reasoning prefix when catch-up completes an earlier tool call", () => { + const live = processAgentStreamEvents({ + events: [ + makeStreamReducerEvent(makeToolCallTimelineEvent("call-1"), 1), + makeStreamReducerEvent(makeTimelineEvent("Thi", "reasoning"), 2), + ], + currentTail: [], + currentHead: [], + currentCursor: undefined, + currentAgent: null, + }); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: live.tail, + currentHead: live.head, + currentCursor: live.cursor ?? undefined, + payload: { + ...baseTimelineInput.payload, + epoch: "epoch-1", + startCursor: { seq: 3 }, + endCursor: { seq: 4 }, + entries: [ + { + ...makeToolCallTimelineEntry(1, "call-1", "completed", { + type: "read", + filePath: "/tmp/example.ts", + }), + seqEnd: 4, + sourceSeqRanges: [ + { startSeq: 1, endSeq: 1 }, + { startSeq: 4, endSeq: 4 }, + ], + }, + { + ...makeTimelineEntry(2, "Thinking", "reasoning", 3), + sourceSeqRanges: [{ startSeq: 2, endSeq: 3 }], + }, + ], + }, + }); + + const thoughts = [...result.tail, ...result.head].filter((item) => item.kind === "thought"); + expect(thoughts).toHaveLength(1); + expect(thoughts[0]?.text).toBe("Thinking"); + }); + + it("keeps delayed catch-up history before a newly submitted prompt", () => { + const prompt = makeOptimisticUserMessage("New prompt", "new-prompt"); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [makeAssistantItem("Earlier answer", "earlier-answer"), prompt], + currentHead: [], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 2 }, + payload: { + ...baseTimelineInput.payload, + epoch: "epoch-1", + startCursor: { seq: 3 }, + endCursor: { seq: 3 }, + entries: [makeTimelineEntry(3, "Missed answer")], + }, + }); + + expect( + [...result.tail, ...result.head] + .filter((item) => item.kind === "assistant_message" || item.kind === "user_message") + .map((item) => item.text), + ).toEqual(["Earlier answer", "Missed answer", "New prompt"]); + }); + + it("keeps delayed catch-up history between a live head and its unmatched head prompt", () => { + const prompt = makeOptimisticUserMessage("New prompt", "new-prompt"); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [], + currentHead: [makeAssistantItem("Live answer", "live-answer"), prompt], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 2 }, + payload: { + ...baseTimelineInput.payload, + epoch: "epoch-1", + startCursor: { seq: 3 }, + endCursor: { seq: 3 }, + entries: [ + makeToolCallTimelineEntry(3, "missed-call", "completed", { + type: "read", + filePath: "/tmp/missed.ts", + }), + ], + }, + }); + + expect([...result.tail, ...result.head].map((item) => item.kind)).toEqual([ + "assistant_message", + "tool_call", + "user_message", + ]); + }); + + it("keeps delayed catch-up history between a live head and its acknowledged head prompt", () => { + const prompt = makeOptimisticUserMessage("New prompt", "new-prompt"); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [], + currentHead: [makeAssistantItem("Live answer", "live-answer"), prompt], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 2 }, + payload: { + ...baseTimelineInput.payload, + epoch: "epoch-1", + startCursor: { seq: 3 }, + endCursor: { seq: 4 }, + entries: [ + makeToolCallTimelineEntry(3, "missed-call", "completed", { + type: "read", + filePath: "/tmp/missed.ts", + }), + { + ...makeTimelineEntry(4, "New prompt", "user_message"), + item: { + type: "user_message", + text: "New prompt", + messageId: "new-prompt", + }, + }, + ], + }, + }); + + expect([...result.tail, ...result.head].map((item) => item.kind)).toEqual([ + "assistant_message", + "tool_call", + "user_message", + ]); + expect( + [...result.tail, ...result.head] + .filter((item) => item.kind === "user_message") + .map((item) => item.optimistic), + ).toEqual([undefined]); + }); + + it("keeps unrelated delayed history before the prompt and its live response", () => { + const prompt = makeOptimisticUserMessage("New prompt", "new-prompt"); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [makeAssistantItem("Earlier answer", "earlier-answer"), prompt], + currentHead: [ + { + ...makeAssistantItem("Live response", "live-response"), + messageId: "live-response", + }, + ], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 2 }, + payload: { + ...baseTimelineInput.payload, + epoch: "epoch-1", + startCursor: { seq: 3 }, + endCursor: { seq: 4 }, + entries: [ + makeTimelineEntry(3, "Missed answer"), + { + ...makeTimelineEntry(4, "Remote prompt", "user_message"), + item: { + type: "user_message", + text: "Remote prompt", + messageId: "remote-prompt", + }, + }, + ], + }, + }); + + expect( + [...result.tail, ...result.head] + .filter((item) => item.kind === "assistant_message" || item.kind === "user_message") + .map((item) => item.text), + ).toEqual(["Earlier answer", "Missed answer", "Remote prompt", "New prompt", "Live response"]); + }); + + it("keeps delayed history before a prompt whose live answer has promoted blocks", () => { + const prompt = makeOptimisticUserMessage("New prompt", "new-prompt"); + const live = processAgentStreamEvents({ + events: [ + makeStreamReducerEvent( + makeAssistantTimelineEvent("First paragraph.\n\nSecond paragraph", "live-response"), + 2, + ), + ], + currentTail: [prompt], + currentHead: [], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, + currentAgent: null, + }); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: live.tail, + currentHead: live.head, + currentCursor: live.cursor ?? undefined, + payload: { + ...baseTimelineInput.payload, + epoch: "epoch-1", + startCursor: { seq: 3 }, + endCursor: { seq: 3 }, + entries: [makeTimelineEntry(3, "Missed answer")], + }, + }); + + expect( + [...result.tail, ...result.head] + .filter((item) => item.kind === "assistant_message" || item.kind === "user_message") + .map((item) => item.text), + ).toEqual(["Missed answer", "New prompt", "First paragraph.", "Second paragraph"]); + }); + + it("keeps delayed tool history before a prompt whose live answer has promoted blocks", () => { + const prompt = makeOptimisticUserMessage("New prompt", "new-prompt"); + const live = processAgentStreamEvents({ + events: [ + makeStreamReducerEvent( + makeAssistantTimelineEvent("First paragraph.\n\nSecond paragraph", "live-response"), + 2, + ), + ], + currentTail: [prompt], + currentHead: [], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, + currentAgent: null, + }); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: live.tail, + currentHead: live.head, + currentCursor: live.cursor ?? undefined, + payload: { + ...baseTimelineInput.payload, + epoch: "epoch-1", + startCursor: { seq: 3 }, + endCursor: { seq: 3 }, + entries: [ + makeToolCallTimelineEntry(3, "missed-call", "completed", { + type: "read", + filePath: "/tmp/missed.ts", + }), + ], + }, + }); + + expect([...result.tail, ...result.head].map((item) => item.kind)).toEqual([ + "tool_call", + "user_message", + "assistant_message", + "assistant_message", + ]); + }); + + it("matches a local optimistic prompt after an unrelated remote user row", () => { + const prompt = makeOptimisticUserMessage("Local prompt", "local-prompt"); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [prompt], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, + payload: { + ...baseTimelineInput.payload, + epoch: "epoch-1", + startCursor: { seq: 2 }, + endCursor: { seq: 3 }, + entries: [ + { + ...makeTimelineEntry(2, "Remote prompt", "user_message"), + item: { + type: "user_message", + text: "Remote prompt", + messageId: "remote-prompt", + }, + }, + { + ...makeTimelineEntry(3, "Local prompt", "user_message"), + item: { + type: "user_message", + text: "Local prompt", + messageId: "local-prompt", + }, + }, + ], + }, + }); + + expect( + result.tail + .filter((item) => item.kind === "user_message") + .map((item) => ({ text: item.text, optimistic: item.optimistic })), + ).toEqual([ + { text: "Remote prompt", optimistic: undefined }, + { text: "Local prompt", optimistic: undefined }, + ]); + }); + + it("keeps an unmatched optimistic prompt when catch-up contains only a remote user row", () => { + const prompt = makeOptimisticUserMessage("Local prompt", "local-prompt"); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [prompt], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, + payload: { + ...baseTimelineInput.payload, + epoch: "epoch-1", + startCursor: { seq: 2 }, + endCursor: { seq: 2 }, + entries: [ + { + ...makeTimelineEntry(2, "Remote prompt", "user_message"), + item: { + type: "user_message", + text: "Remote prompt", + messageId: "remote-prompt", + }, + }, + ], + }, + }); + + expect( + result.tail + .filter((item) => item.kind === "user_message") + .map((item) => ({ text: item.text, optimistic: item.optimistic })), + ).toEqual([ + { text: "Remote prompt", optimistic: undefined }, + { text: "Local prompt", optimistic: true }, + ]); + }); + + it("does not match equal prompt text when canonical message ids differ", () => { + const prompt = makeOptimisticUserMessage("continue", "local-prompt"); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [prompt], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, + payload: { + ...baseTimelineInput.payload, + epoch: "epoch-1", + startCursor: { seq: 2 }, + endCursor: { seq: 2 }, + entries: [ + { + ...makeTimelineEntry(2, "continue", "user_message"), + item: { + type: "user_message", + text: "continue", + messageId: "remote-prompt", + }, + }, + ], + }, + }); + + expect( + result.tail + .filter((item) => item.kind === "user_message") + .map((item) => ({ id: item.id, optimistic: item.optimistic })), + ).toEqual([ + { id: "remote-prompt", optimistic: undefined }, + { id: "local-prompt", optimistic: true }, + ]); + }); + it("hydrates a fetched in-progress tool call as one item and streams the next update on top", () => { const fetched = processTimelineResponse({ ...baseTimelineInput, diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index aaa0e21c0..854832762 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -2,9 +2,10 @@ import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle"; import type { Agent } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store"; -import type { StreamItem, UserMessageItem } from "@/types/stream"; +import type { AssistantMessageItem, StreamItem, UserMessageItem } from "@/types/stream"; import { applyStreamEvent, + flushHeadToTail, hydrateStreamState, isAgentToolCallItem, mergeAgentToolCallItem, @@ -544,6 +545,378 @@ function replaceLiveAssistantWithProjectedText(params: { return next; } +interface OptimisticUserMessagePosition { + item: UserMessageItem; + placement: "tail" | "head"; + index: number; +} + +function findOptimisticUserMessage(params: { + tail: StreamItem[]; + head: StreamItem[]; +}): OptimisticUserMessagePosition | null { + const tailIndex = params.tail.findIndex( + (item) => item.kind === "user_message" && item.optimistic, + ); + const tailItem = params.tail[tailIndex]; + if (tailItem?.kind === "user_message") { + return { item: tailItem, placement: "tail", index: tailIndex }; + } + + const headIndex = params.head.findIndex( + (item) => item.kind === "user_message" && item.optimistic, + ); + const headItem = params.head[headIndex]; + return headItem?.kind === "user_message" + ? { item: headItem, placement: "head", index: headIndex } + : null; +} + +function replaceOptimisticUserMessage(params: { + tail: StreamItem[]; + head: StreamItem[]; + position: OptimisticUserMessagePosition; + precedingItems: StreamItem[]; + replacement: StreamItem[]; +}): { tail: StreamItem[]; head: StreamItem[] } { + const { position } = params; + const items = [...params.precedingItems, ...params.replacement]; + if (position.placement === "tail") { + return { + tail: [ + ...params.tail.slice(0, position.index), + ...items, + ...params.tail.slice(position.index + 1), + ], + head: params.head, + }; + } + return { + tail: params.tail, + head: [ + ...params.head.slice(0, position.index), + ...items, + ...params.head.slice(position.index + 1), + ], + }; +} + +function reconcileOverlappingProjectedAssistant(params: { + tail: StreamItem[]; + head: StreamItem[]; + unit: TimelineUnit; + epoch: string; + currentEndSeq: number; +}): { tail: StreamItem[]; head: StreamItem[]; reconciled: boolean } { + const { unit } = params; + if ( + unit.event.type !== "timeline" || + unit.event.item.type !== "assistant_message" || + !unit.sourceSeqRanges.some( + (range) => range.startSeq <= params.currentEndSeq && range.endSeq > params.currentEndSeq, + ) + ) { + return { tail: params.tail, head: params.head, reconciled: false }; + } + + const projectedText = unit.event.item.text; + const projectedMessageId = unit.event.item.messageId; + const matches = (item: StreamItem) => { + if (item.kind !== "assistant_message") return false; + if (projectedMessageId && item.messageId) return item.messageId === projectedMessageId; + return projectedText.startsWith(item.text); + }; + const findMatch = (items: StreamItem[]) => { + const index = items.findLastIndex(matches); + const current = items[index]; + return current?.kind === "assistant_message" ? { current, index } : null; + }; + + const headMatch = findMatch(params.head); + const tailMatch = headMatch ? null : findMatch(params.tail); + const match = headMatch ?? tailMatch; + if (!match) { + return { tail: params.tail, head: params.head, reconciled: false }; + } + + const blockGroupId = match.current.blockGroupId; + const messageId = projectedMessageId ?? match.current.messageId; + const replacement: AssistantMessageItem = { + kind: "assistant_message", + id: blockGroupId ?? match.current.id, + ...(messageId !== undefined ? { messageId } : {}), + text: projectedText, + timestamp: unit.timestamp, + timelineCursor: { epoch: params.epoch, seq: unit.seqEnd }, + }; + const belongsToBlockGroup = (item: StreamItem) => + blockGroupId !== undefined && + item.kind === "assistant_message" && + item.blockGroupId === blockGroupId; + const removeBlockGroup = (items: StreamItem[]) => + blockGroupId !== undefined ? items.filter((item) => !belongsToBlockGroup(item)) : items; + const replaceMatch = (items: StreamItem[], index: number) => { + if (!blockGroupId) { + const next = [...items]; + next[index] = replacement; + return next; + } + const next: StreamItem[] = []; + let inserted = false; + for (const item of items) { + if (!belongsToBlockGroup(item)) { + next.push(item); + } else if (!inserted) { + next.push(replacement); + inserted = true; + } + } + return next; + }; + + if (headMatch) { + return { + tail: removeBlockGroup(params.tail), + head: replaceMatch(params.head, headMatch.index), + reconciled: true, + }; + } + return { + tail: replaceMatch(params.tail, match.index), + head: removeBlockGroup(params.head), + reconciled: true, + }; +} + +function reconcileOverlappingProjectedReasoning(params: { + tail: StreamItem[]; + head: StreamItem[]; + unit: TimelineUnit; + currentEndSeq: number; +}): { tail: StreamItem[]; head: StreamItem[]; reconciled: boolean } { + const { unit } = params; + if ( + unit.event.type !== "timeline" || + unit.event.item.type !== "reasoning" || + !unit.sourceSeqRanges.some( + (range) => range.startSeq <= params.currentEndSeq && range.endSeq > params.currentEndSeq, + ) + ) { + return { tail: params.tail, head: params.head, reconciled: false }; + } + + const projectedText = unit.event.item.text; + const replaceIn = (items: StreamItem[]): StreamItem[] | null => { + const index = items.findLastIndex( + (item) => item.kind === "thought" && projectedText.startsWith(item.text), + ); + const current = items[index]; + if (!current || current.kind !== "thought") return null; + const next = [...items]; + next[index] = { + ...current, + text: projectedText, + timestamp: unit.timestamp, + status: "loading", + }; + return next; + }; + + const nextHead = replaceIn(params.head); + if (nextHead) return { tail: params.tail, head: nextHead, reconciled: true }; + const nextTail = replaceIn(params.tail); + return nextTail + ? { tail: nextTail, head: params.head, reconciled: true } + : { tail: params.tail, head: params.head, reconciled: false }; +} + +function reconcileOverlappingProjectedStreamItems(params: { + tail: StreamItem[]; + head: StreamItem[]; + units: TimelineUnit[]; + epoch: string; + currentEndSeq: number | undefined; +}): { tail: StreamItem[]; head: StreamItem[]; reconciledUnits: Set } { + let tail = params.tail; + let head = params.head; + const reconciledUnits = new Set(); + if (params.currentEndSeq === undefined) return { tail, head, reconciledUnits }; + + for (const unit of params.units) { + let reconciled = reconcileOverlappingProjectedAssistant({ + tail, + head, + unit, + epoch: params.epoch, + currentEndSeq: params.currentEndSeq, + }); + if (!reconciled.reconciled) { + reconciled = reconcileOverlappingProjectedReasoning({ + tail, + head, + unit, + currentEndSeq: params.currentEndSeq, + }); + } + tail = reconciled.tail; + head = reconciled.head; + if (reconciled.reconciled) reconciledUnits.add(unit); + } + return { tail, head, reconciledUnits }; +} + +function matchesOptimisticUserMessage(params: { + unit: TimelineUnit; + optimistic: UserMessageItem; +}): boolean { + const { event } = params.unit; + if (event.type !== "timeline" || event.item.type !== "user_message") { + return false; + } + if (event.item.messageId !== undefined) { + return event.item.messageId === params.optimistic.id; + } + return event.item.text.length > 0 && event.item.text === params.optimistic.text; +} + +function acknowledgeOptimisticUserMessage(params: { + tail: StreamItem[]; + head: StreamItem[]; + unit: TimelineUnit; + epoch: string; + optimistic: OptimisticUserMessagePosition; + precedingItems: StreamItem[]; +}): { tail: StreamItem[]; head: StreamItem[] } { + const { event, timestamp, seqEnd } = params.unit; + const timelineCursor = { epoch: params.epoch, seq: seqEnd }; + const acknowledged = reduceStreamUpdate([params.optimistic.item], event, timestamp, { + source: "canonical", + timelineCursor, + }); + return replaceOptimisticUserMessage({ + tail: params.tail, + head: params.head, + position: params.optimistic, + precedingItems: params.precedingItems, + replacement: acknowledged, + }); +} + +function applyCanonicalForwardUnit(params: { + tail: StreamItem[]; + head: StreamItem[]; + unit: TimelineUnit; + epoch: string; +}): { tail: StreamItem[]; head: StreamItem[] } { + const { event, timestamp, seqEnd } = params.unit; + const timelineCursor = { epoch: params.epoch, seq: seqEnd }; + if (params.head.length === 0) { + return { + tail: reduceStreamUpdate(params.tail, event, timestamp, { + source: "canonical", + timelineCursor, + }), + head: params.head, + }; + } + const replacedHead = replaceLiveAssistantWithProjectedText({ + head: params.head, + event, + timestamp, + timelineCursor, + }); + if (replacedHead) return { tail: params.tail, head: replacedHead }; + + const applied = applyStreamEvent({ + tail: params.tail, + head: params.head, + event, + timestamp, + source: "canonical", + timelineCursor, + }); + return { tail: applied.tail, head: applied.head }; +} + +function applyAcceptedForwardTimelineUnits(params: { + units: TimelineUnit[]; + epoch: string; + currentTail: StreamItem[]; + currentHead: StreamItem[]; + currentEndSeq: number | undefined; +}): { tail: StreamItem[]; head: StreamItem[] } { + const reconciled = reconcileOverlappingProjectedStreamItems({ + tail: params.currentTail, + head: params.currentHead, + units: params.units, + epoch: params.epoch, + currentEndSeq: params.currentEndSeq, + }); + let tail = reconciled.tail; + let head = reconciled.head; + let delayedHistoryTail: StreamItem[] = []; + let delayedHistoryHead: StreamItem[] = []; + + for (const unit of params.units) { + if (reconciled.reconciledUnits.has(unit)) continue; + const nextOptimistic = findOptimisticUserMessage({ tail, head }); + if (nextOptimistic && matchesOptimisticUserMessage({ unit, optimistic: nextOptimistic.item })) { + const precedingItems = flushHeadToTail(delayedHistoryTail, delayedHistoryHead); + delayedHistoryTail = []; + delayedHistoryHead = []; + const applied = acknowledgeOptimisticUserMessage({ + tail, + head, + unit, + epoch: params.epoch, + optimistic: nextOptimistic, + precedingItems, + }); + tail = applied.tail; + head = applied.head; + continue; + } + if (nextOptimistic) { + if ( + unit.event.type === "timeline" && + (unit.event.item.type === "assistant_message" || unit.event.item.type === "reasoning") + ) { + delayedHistoryHead = reduceStreamUpdate(delayedHistoryHead, unit.event, unit.timestamp, { + source: "canonical", + timelineCursor: { epoch: params.epoch, seq: unit.seqEnd }, + }); + continue; + } + const applied = applyStreamEvent({ + tail: delayedHistoryTail, + head: delayedHistoryHead, + event: unit.event, + timestamp: unit.timestamp, + source: "canonical", + timelineCursor: { epoch: params.epoch, seq: unit.seqEnd }, + }); + delayedHistoryTail = applied.tail; + delayedHistoryHead = applied.head; + continue; + } + const applied = applyCanonicalForwardUnit({ tail, head, unit, epoch: params.epoch }); + tail = applied.tail; + head = applied.head; + } + + const remainingOptimistic = findOptimisticUserMessage({ tail, head }); + if (!remainingOptimistic) return { tail, head }; + const precedingItems = flushHeadToTail(delayedHistoryTail, delayedHistoryHead); + if (precedingItems.length === 0) return { tail, head }; + return replaceOptimisticUserMessage({ + tail, + head, + position: remainingOptimistic, + precedingItems, + replacement: [remainingOptimistic.item], + }); +} + function applyTimelineIncrementalPath(args: { timelineUnits: TimelineUnit[]; payload: ProcessTimelineResponseInput["payload"]; @@ -586,39 +959,16 @@ function applyTimelineIncrementalPath(args: { { source: "canonical" }, ); nextTail = mergePrependedCanonicalTail(olderTail, currentTail); - } else if (currentHead.length > 0) { - for (const { event, timestamp, seqEnd } of acceptedUnits) { - const timelineCursor = { epoch: payload.epoch, seq: seqEnd }; - const replacedHead = replaceLiveAssistantWithProjectedText({ - head: nextHead, - event, - timestamp, - timelineCursor, - }); - if (replacedHead) { - nextHead = replacedHead; - continue; - } - const applied = applyStreamEvent({ - tail: nextTail, - head: nextHead, - event, - timestamp, - source: "canonical", - timelineCursor, - }); - nextTail = applied.tail; - nextHead = applied.head; - } } else { - nextTail = acceptedUnits.reduce( - (state, { event, timestamp, seqEnd }) => - reduceStreamUpdate(state, event, timestamp, { - source: "canonical", - timelineCursor: { epoch: payload.epoch, seq: seqEnd }, - }), - currentTail, - ); + const applied = applyAcceptedForwardTimelineUnits({ + units: acceptedUnits, + epoch: payload.epoch, + currentTail: nextTail, + currentHead: nextHead, + currentEndSeq: currentCursor?.endSeq, + }); + nextTail = applied.tail; + nextHead = applied.head; } } @@ -1160,7 +1510,7 @@ export interface CreateSessionAgentStreamReducerQueueInput { state: (prev: Map) => Map, ) => void; setAgents: (serverId: string, state: (prev: Map) => Map) => void; - requestCanonicalCatchUp: (agentId: string, cursor: { epoch: string; endSeq: number }) => void; + recoverTimelineGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void; } function scheduleAgentStreamReducerFlush(callback: () => void): number { @@ -1174,13 +1524,8 @@ function cancelAgentStreamReducerFlush(id: number) { export function createSessionAgentStreamReducerQueue( input: CreateSessionAgentStreamReducerQueueInput, ): AgentStreamReducerQueue { - const { - serverId, - setAgentStreamState, - setAgentTimelineCursor, - setAgents, - requestCanonicalCatchUp, - } = input; + const { serverId, setAgentStreamState, setAgentTimelineCursor, setAgents, recoverTimelineGap } = + input; return createAgentStreamReducerQueue({ getSnapshot: (agentId) => { @@ -1258,7 +1603,7 @@ export function createSessionAgentStreamReducerQueue( handleSideEffects: (agentId, sideEffects) => { for (const effect of sideEffects) { if (effect.type === "catch_up") { - requestCanonicalCatchUp(agentId, effect.cursor); + recoverTimelineGap(agentId, effect.cursor); } } }, diff --git a/packages/app/src/timeline/timeline-sync-plan.test.ts b/packages/app/src/timeline/timeline-sync-plan.test.ts index 913f2501b..918c97357 100644 --- a/packages/app/src/timeline/timeline-sync-plan.test.ts +++ b/packages/app/src/timeline/timeline-sync-plan.test.ts @@ -4,7 +4,6 @@ import { isTimelineCatchUpComplete, planInitialAgentTimelineSync, planResumeTimelineSync, - planTimelineCatchUpFollowUp, planTimelineOlderFetch, } from "./timeline-sync-plan"; @@ -70,31 +69,7 @@ describe("timeline sync planning", () => { }); }); - test("catch-up keeps paging while the daemon reports newer rows", () => { - const plan = planTimelineCatchUpFollowUp({ - direction: "after", - hasNewer: true, - endCursor: { epoch: "epoch-1", seq: 200 }, - error: null, - }); - - expect(plan).toEqual({ - direction: "after", - cursor: { epoch: "epoch-1", seq: 200 }, - limit: TIMELINE_FETCH_PAGE_SIZE, - projection: "projected", - }); - }); - test("catch-up finishes when the daemon reports no newer rows", () => { - const plan = planTimelineCatchUpFollowUp({ - direction: "after", - hasNewer: false, - endCursor: { epoch: "epoch-1", seq: 200 }, - error: null, - }); - - expect(plan).toBeNull(); expect(isTimelineCatchUpComplete({ direction: "after", hasNewer: false, error: null })).toBe( true, ); diff --git a/packages/app/src/timeline/timeline-sync-plan.ts b/packages/app/src/timeline/timeline-sync-plan.ts index 43efec277..d1cd4b376 100644 --- a/packages/app/src/timeline/timeline-sync-plan.ts +++ b/packages/app/src/timeline/timeline-sync-plan.ts @@ -87,19 +87,6 @@ export function planTimelineOlderFetch(cursor: TimelineSyncCursor) { } as const; } -export function planTimelineCatchUpFollowUp(input: { - direction: "tail" | "before" | "after"; - hasNewer: boolean; - endCursor: TimelineSyncCursor | null; - error: string | null; -}): ProjectedTimelineAfterFetchPlan | null { - if (input.error || input.direction !== "after" || !input.hasNewer || !input.endCursor) { - return null; - } - - return planTimelineCatchUpAfter(input.endCursor); -} - export function isTimelineCatchUpComplete(input: { direction: "tail" | "before" | "after"; hasNewer: boolean; diff --git a/packages/app/src/timeline/viewed-timeline-sync.test.ts b/packages/app/src/timeline/viewed-timeline-sync.test.ts new file mode 100644 index 000000000..42f15507f --- /dev/null +++ b/packages/app/src/timeline/viewed-timeline-sync.test.ts @@ -0,0 +1,689 @@ +import { expect, test, vi } from "vitest"; +import type { ProjectedTimelineForwardFetchPlan } from "./timeline-sync-plan"; +import { + createViewedTimelineSync, + VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS, +} from "./viewed-timeline-sync"; + +interface Deferred { + promise: Promise; + resolve(value: T): void; + reject(error: Error): void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +interface MembershipRequest { + agentIds: string[]; + succeed(): void; + fail(message: string): void; +} + +interface TimelineFetch { + agentId: string; + request: ProjectedTimelineForwardFetchPlan; + respond(input: { hasNewer: boolean; seq?: number }): void; + fail(message: string): void; +} + +class TimelineWorld { + readonly errors: string[] = []; + readonly sync = createViewedTimelineSync({ + initialDeliveryMode: "selective", + setSubscription: async (agentIds) => { + const result = deferred(); + this.memberships.push({ + agentIds, + succeed: () => result.resolve(), + fail: (message) => result.reject(new Error(message)), + }); + this.releaseMembershipWaiter(); + return result.promise; + }, + readCursor: (agentId) => this.cursors.get(agentId), + hasAuthoritativeHistory: (agentId) => this.authoritativeHistory.has(agentId), + fetchPage: async (agentId, request) => { + const result = deferred<{ + hasNewer: boolean; + endCursor: { epoch: string; seq: number } | null; + }>(); + this.fetches.push({ + agentId, + request, + respond: ({ hasNewer, seq = 1 }) => + result.resolve({ + hasNewer, + endCursor: { epoch: `epoch-${agentId}`, seq }, + }), + fail: (message) => result.reject(new Error(message)), + }); + this.releaseFetchWaiters(); + return result.promise; + }, + reportError: (error) => { + this.errors.push(error instanceof Error ? error.message : String(error)); + const waiter = this.errorWaiters.shift(); + if (waiter) waiter(this.errors.at(-1) ?? ""); + }, + schedule: (task, delayMs) => { + const scheduled = { task, delayMs }; + this.scheduled.push(scheduled); + const waiter = this.retryWaiters.shift(); + if (waiter && delayMs === 1_000) { + this.scheduled.splice(this.scheduled.indexOf(scheduled), 1); + waiter(task); + } + return () => { + const index = this.scheduled.indexOf(scheduled); + if (index >= 0) this.scheduled.splice(index, 1); + }; + }, + }); + + private readonly memberships: MembershipRequest[] = []; + private readonly membershipWaiters: Array<(request: MembershipRequest) => void> = []; + private readonly fetches: TimelineFetch[] = []; + private readonly fetchWaiters: Array<{ + agentId: string; + resolve(fetch: TimelineFetch): void; + }> = []; + private readonly cursors = new Map(); + private readonly authoritativeHistory = new Set(); + private readonly errorWaiters: Array<(message: string) => void> = []; + private readonly scheduled: Array<{ task: () => void; delayMs: number }> = []; + private readonly retryWaiters: Array<(retry: () => void) => void> = []; + + setCursor(agentId: string, endSeq: number): void { + this.cursors.set(agentId, { epoch: `epoch-${agentId}`, startSeq: 1, endSeq }); + this.authoritativeHistory.add(agentId); + } + + setLiveCursor(agentId: string, endSeq: number): void { + this.cursors.set(agentId, { epoch: `epoch-${agentId}`, startSeq: 1, endSeq }); + } + + nextMembership(): Promise { + const request = this.memberships.shift(); + if (request) return Promise.resolve(request); + return new Promise((resolve) => this.membershipWaiters.push(resolve)); + } + + nextFetch(agentId: string): Promise { + const index = this.fetches.findIndex((fetch) => fetch.agentId === agentId); + if (index >= 0) return Promise.resolve(this.fetches.splice(index, 1)[0]); + return new Promise((resolve) => this.fetchWaiters.push({ agentId, resolve })); + } + + expectNoPendingMembership(): void { + expect(this.memberships).toEqual([]); + } + + expectNoPendingFetch(): void { + expect(this.fetches).toEqual([]); + } + + nextError(): Promise { + const message = this.errors.at(-1); + if (message) return Promise.resolve(message); + return new Promise((resolve) => this.errorWaiters.push(resolve)); + } + + nextRetry(): Promise<() => void> { + const index = this.scheduled.findIndex((entry) => entry.delayMs === 1_000); + if (index >= 0) return Promise.resolve(this.scheduled.splice(index, 1)[0].task); + return new Promise((resolve) => this.retryWaiters.push(resolve)); + } + + runUnsubscribeGrace(): void { + const index = this.scheduled.findIndex( + (entry) => entry.delayMs === VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS, + ); + expect(index).toBeGreaterThanOrEqual(0); + this.scheduled.splice(index, 1)[0].task(); + } + + expectNoPendingUnsubscribe(): void { + expect( + this.scheduled.filter((entry) => entry.delayMs === VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS), + ).toEqual([]); + } + + private releaseMembershipWaiter(): void { + const waiter = this.membershipWaiters.shift(); + if (!waiter) return; + const request = this.memberships.shift(); + if (request) waiter(request); + } + + private releaseFetchWaiters(): void { + for (let waiterIndex = this.fetchWaiters.length - 1; waiterIndex >= 0; waiterIndex -= 1) { + const waiter = this.fetchWaiters[waiterIndex]; + const index = this.fetches.findIndex((fetch) => fetch.agentId === waiter.agentId); + if (index < 0) continue; + this.fetchWaiters.splice(waiterIndex, 1); + waiter.resolve(this.fetches.splice(index, 1)[0]); + } + } +} + +test("uses a tail fetch when a live cursor is not authoritative", async () => { + const world = new TimelineWorld(); + world.setLiveCursor("agent-a", 9); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const membership = await world.nextMembership(); + membership.succeed(); + + const fetch = await world.nextFetch("agent-a"); + expect(fetch.request).toEqual({ direction: "tail", limit: 100, projection: "projected" }); + fetch.respond({ hasNewer: false }); +}); + +test("unchanged visible-set publication does not cancel paged catch-up", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending"); + const membership = await world.nextMembership(); + membership.succeed(); + const firstPage = await world.nextFetch("agent-a"); + + world.sync.replaceVisibleAgentIds("workspace", ["agent-a", "agent-a"]); + firstPage.respond({ hasNewer: true, seq: 5 }); + const secondPage = await world.nextFetch("agent-a"); + expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending"); + secondPage.respond({ hasNewer: false }); + + await vi.waitFor(() => { + expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"); + }); + + expect(secondPage.request).toEqual({ + direction: "after", + cursor: { epoch: "epoch-agent-a", seq: 5 }, + limit: 100, + projection: "projected", + }); + world.expectNoPendingMembership(); +}); + +test("all acknowledged agents begin catch-up independently", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-b", "agent-a"]); + const membership = await world.nextMembership(); + membership.succeed(); + + const [agentA, agentB] = await Promise.all([ + world.nextFetch("agent-a"), + world.nextFetch("agent-b"), + ]); + agentA.respond({ hasNewer: false }); + agentB.respond({ hasNewer: false }); + + expect(membership.agentIds).toEqual(["agent-a", "agent-b"]); +}); + +test("membership changes during acknowledgement never catch up the stale set", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const staleMembership = await world.nextMembership(); + + world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); + world.runUnsubscribeGrace(); + staleMembership.succeed(); + const currentMembership = await world.nextMembership(); + currentMembership.succeed(); + const agentB = await world.nextFetch("agent-b"); + agentB.respond({ hasNewer: false }); + + expect({ stale: staleMembership.agentIds, current: currentMembership.agentIds }).toEqual({ + stale: ["agent-a"], + current: ["agent-b"], + }); + world.expectNoPendingFetch(); +}); + +test("removing one agent during paging cancels only that agent", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a", "agent-b"]); + const initialMembership = await world.nextMembership(); + initialMembership.succeed(); + const [agentA, agentB] = await Promise.all([ + world.nextFetch("agent-a"), + world.nextFetch("agent-b"), + ]); + + world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); + world.runUnsubscribeGrace(); + const replacement = await world.nextMembership(); + agentA.respond({ hasNewer: true, seq: 4 }); + agentB.respond({ hasNewer: true, seq: 7 }); + const agentBNext = await world.nextFetch("agent-b"); + replacement.succeed(); + agentBNext.respond({ hasNewer: false }); + + expect(replacement.agentIds).toEqual(["agent-b"]); + world.expectNoPendingFetch(); +}); + +test("disconnect cancels paging and reconnect restores membership before fresh catch-up", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const firstMembership = await world.nextMembership(); + firstMembership.succeed(); + const stalePage = await world.nextFetch("agent-a"); + + world.sync.setConnected(false); + expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending"); + stalePage.respond({ hasNewer: true, seq: 8 }); + world.sync.setConnected(true); + const restoredMembership = await world.nextMembership(); + restoredMembership.succeed(); + const restoredPage = await world.nextFetch("agent-a"); + restoredPage.respond({ hasNewer: false }); + + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); + + expect(restoredMembership.agentIds).toEqual(["agent-a"]); + world.expectNoPendingFetch(); +}); + +test("overlapping sources deduplicate membership and source removal preserves remaining views", async () => { + const world = new TimelineWorld(); + world.sync.replaceVisibleAgentIds("left-route", ["agent-a"]); + world.sync.replaceVisibleAgentIds("right-route", ["agent-a", "agent-b"]); + world.sync.setConnected(true); + const combined = await world.nextMembership(); + combined.succeed(); + const [agentA, agentB] = await Promise.all([ + world.nextFetch("agent-a"), + world.nextFetch("agent-b"), + ]); + agentA.respond({ hasNewer: false }); + agentB.respond({ hasNewer: false }); + + world.sync.replaceVisibleAgentIds("left-route", []); + world.expectNoPendingMembership(); + world.expectNoPendingUnsubscribe(); + world.sync.replaceVisibleAgentIds("right-route", ["agent-b"]); + world.runUnsubscribeGrace(); + const remaining = await world.nextMembership(); + remaining.succeed(); + + expect({ combined: combined.agentIds, remaining: remaining.agentIds }).toEqual({ + combined: ["agent-a", "agent-b"], + remaining: ["agent-b"], + }); +}); + +test("a failed catch-up reports once and retries through the explicit retry policy", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const membership = await world.nextMembership(); + membership.succeed(); + const failed = await world.nextFetch("agent-a"); + failed.fail("timeline unavailable"); + const [error, retryCatchUp] = await Promise.all([world.nextError(), world.nextRetry()]); + expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"); + + retryCatchUp(); + const retry = await world.nextFetch("agent-a"); + expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"); + retry.respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); + + expect({ error, retryDirection: retry.request.direction }).toEqual({ + error: "timeline unavailable", + retryDirection: "tail", + }); + world.expectNoPendingMembership(); +}); + +test("gap recovery supersedes completed catch-up and pages through the current tail", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const membership = await world.nextMembership(); + membership.succeed(); + const initial = await world.nextFetch("agent-a"); + initial.respond({ hasNewer: false }); + + world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); + const gapPage = await world.nextFetch("agent-a"); + gapPage.respond({ hasNewer: true, seq: 15 }); + const finalPage = await world.nextFetch("agent-a"); + finalPage.respond({ hasNewer: false }); + + expect([gapPage.request, finalPage.request]).toEqual([ + { + direction: "after", + cursor: { epoch: "epoch-agent-a", seq: 10 }, + limit: 100, + projection: "projected", + }, + { + direction: "after", + cursor: { epoch: "epoch-agent-a", seq: 15 }, + limit: 100, + projection: "projected", + }, + ]); +}); + +test("repeated recovery for the same running gap reuses the in-flight fetch", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const membership = await world.nextMembership(); + membership.succeed(); + const initial = await world.nextFetch("agent-a"); + initial.respond({ hasNewer: false }); + + const cursor = { epoch: "epoch-agent-a", endSeq: 10 }; + world.sync.recoverGap("agent-a", cursor); + const gapPage = await world.nextFetch("agent-a"); + world.sync.recoverGap("agent-a", cursor); + + world.expectNoPendingFetch(); + gapPage.respond({ hasNewer: false }); +}); + +test("membership failure autonomously retries without another visibility declaration", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const failed = await world.nextMembership(); + failed.fail("subscription unavailable"); + const [error, retryMembership] = await Promise.all([world.nextError(), world.nextRetry()]); + expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"); + + retryMembership(); + const retry = await world.nextMembership(); + retry.succeed(); + const catchUp = await world.nextFetch("agent-a"); + expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"); + catchUp.respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); + + expect({ error, failed: failed.agentIds, retry: retry.agentIds }).toEqual({ + error: "subscription unavailable", + failed: ["agent-a"], + retry: ["agent-a"], + }); +}); + +test("background waits for grace before unsubscribing and catches up on return", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const initial = await world.nextMembership(); + initial.succeed(); + const initialCatchUp = await world.nextFetch("agent-a"); + initialCatchUp.respond({ hasNewer: false }); + + world.sync.setActive(false); + world.expectNoPendingMembership(); + world.runUnsubscribeGrace(); + const background = await world.nextMembership(); + background.succeed(); + world.sync.setActive(true); + const foreground = await world.nextMembership(); + foreground.succeed(); + const resumedCatchUp = await world.nextFetch("agent-a"); + resumedCatchUp.respond({ hasNewer: false }); + + expect({ background: background.agentIds, foreground: foreground.agentIds }).toEqual({ + background: [], + foreground: ["agent-a"], + }); +}); + +test("foregrounding within grace preserves the live membership", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const membership = await world.nextMembership(); + membership.succeed(); + const catchUp = await world.nextFetch("agent-a"); + catchUp.respond({ hasNewer: false }); + + world.sync.setActive(false); + world.expectNoPendingMembership(); + world.sync.setActive(true); + + world.expectNoPendingUnsubscribe(); + world.expectNoPendingMembership(); + world.expectNoPendingFetch(); +}); + +test("stale membership retry cannot overwrite a newer effective set", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const failed = await world.nextMembership(); + failed.fail("subscription unavailable"); + const staleRetry = await world.nextRetry(); + + world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); + world.runUnsubscribeGrace(); + const current = await world.nextMembership(); + staleRetry(); + current.succeed(); + const catchUp = await world.nextFetch("agent-b"); + catchUp.respond({ hasNewer: false }); + + expect(current.agentIds).toEqual(["agent-b"]); + world.expectNoPendingMembership(); +}); + +test("membership retry cannot run while disconnected", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const failed = await world.nextMembership(); + failed.fail("subscription unavailable"); + const disconnectedRetry = await world.nextRetry(); + + world.sync.setConnected(false); + disconnectedRetry(); + world.expectNoPendingMembership(); + world.sync.setConnected(true); + const restored = await world.nextMembership(); + restored.succeed(); + const catchUp = await world.nextFetch("agent-a"); + catchUp.respond({ hasNewer: false }); + + expect(restored.agentIds).toEqual(["agent-a"]); +}); + +test("quickly returning to an agent cancels its pending unsubscribe without another catch-up", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const membership = await world.nextMembership(); + membership.succeed(); + const initialCatchUp = await world.nextFetch("agent-a"); + initialCatchUp.respond({ hasNewer: false }); + + await vi.waitFor(() => { + expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"); + }); + + world.sync.replaceVisibleAgentIds("workspace", []); + world.expectNoPendingMembership(); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + + expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"); + + world.expectNoPendingUnsubscribe(); + world.expectNoPendingMembership(); + world.expectNoPendingFetch(); +}); + +test("unsubscribe grace expiry removes the agent exactly once", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const membership = await world.nextMembership(); + membership.succeed(); + const catchUp = await world.nextFetch("agent-a"); + catchUp.respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); + + const readinessChanges: string[] = []; + world.sync.subscribe(() => { + readinessChanges.push(world.sync.getAgentTimelineStatus("agent-a")); + }); + + world.sync.replaceVisibleAgentIds("workspace", []); + world.runUnsubscribeGrace(); + const unsubscribe = await world.nextMembership(); + unsubscribe.succeed(); + + expect(unsubscribe.agentIds).toEqual([]); + expect(readinessChanges.at(-1)).toBe("pending"); + world.expectNoPendingUnsubscribe(); + world.expectNoPendingMembership(); +}); + +test("a new visible agent subscribes immediately while the previous agent lingers", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const initialMembership = await world.nextMembership(); + initialMembership.succeed(); + const initialCatchUp = await world.nextFetch("agent-a"); + initialCatchUp.respond({ hasNewer: false }); + + world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); + const expandedMembership = await world.nextMembership(); + expandedMembership.succeed(); + const agentBCatchUp = await world.nextFetch("agent-b"); + agentBCatchUp.respond({ hasNewer: false }); + + expect(expandedMembership.agentIds).toEqual(["agent-a", "agent-b"]); + world.runUnsubscribeGrace(); + const settledMembership = await world.nextMembership(); + settledMembership.succeed(); + expect(settledMembership.agentIds).toEqual(["agent-b"]); +}); + +test("backgrounding preserves an existing unsubscribe grace period", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const membership = await world.nextMembership(); + membership.succeed(); + const catchUp = await world.nextFetch("agent-a"); + catchUp.respond({ hasNewer: false }); + + world.sync.replaceVisibleAgentIds("workspace", []); + world.sync.setActive(false); + world.expectNoPendingMembership(); + world.runUnsubscribeGrace(); + const unsubscribe = await world.nextMembership(); + unsubscribe.succeed(); + + expect(unsubscribe.agentIds).toEqual([]); + world.expectNoPendingMembership(); +}); + +test("disconnecting cancels pending unsubscribe grace without publishing on the closed socket", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a", "agent-b"]); + const membership = await world.nextMembership(); + membership.succeed(); + const [agentA, agentB] = await Promise.all([ + world.nextFetch("agent-a"), + world.nextFetch("agent-b"), + ]); + agentA.respond({ hasNewer: false }); + agentB.respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); + + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const agentAStatuses: string[] = []; + world.sync.subscribe(() => { + agentAStatuses.push(world.sync.getAgentTimelineStatus("agent-a")); + }); + world.sync.setConnected(false); + + expect(agentAStatuses).toEqual(["pending"]); + world.expectNoPendingUnsubscribe(); + world.expectNoPendingMembership(); +}); + +test("disposing cancels pending unsubscribe grace", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const membership = await world.nextMembership(); + membership.succeed(); + const catchUp = await world.nextFetch("agent-a"); + catchUp.respond({ hasNewer: false }); + + world.sync.replaceVisibleAgentIds("workspace", []); + world.sync.dispose(); + + world.expectNoPendingUnsubscribe(); + world.expectNoPendingMembership(); +}); + +test("legacy delivery skips subscription RPCs while retaining visibility catch-up and gap recovery", async () => { + const world = new TimelineWorld(); + world.sync.setDeliveryMode("legacy"); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.setConnected(true); + + world.expectNoPendingMembership(); + const initial = await world.nextFetch("agent-a"); + initial.respond({ hasNewer: false }); + + world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); + const recovery = await world.nextFetch("agent-a"); + recovery.respond({ hasNewer: false }); + + expect(recovery.request).toEqual({ + direction: "after", + cursor: { epoch: "epoch-agent-a", seq: 10 }, + limit: 100, + projection: "projected", + }); +}); + +test("switching from legacy to selective delivery publishes membership and catches up once", async () => { + const world = new TimelineWorld(); + world.sync.setDeliveryMode("legacy"); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.setConnected(true); + const legacyCatchUp = await world.nextFetch("agent-a"); + legacyCatchUp.respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); + + world.sync.setDeliveryMode("selective"); + expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending"); + const membership = await world.nextMembership(); + membership.succeed(); + const catchUp = await world.nextFetch("agent-a"); + catchUp.respond({ hasNewer: false }); + + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); + + expect(membership.agentIds).toEqual(["agent-a"]); + world.expectNoPendingMembership(); + world.expectNoPendingFetch(); +}); diff --git a/packages/app/src/timeline/viewed-timeline-sync.ts b/packages/app/src/timeline/viewed-timeline-sync.ts new file mode 100644 index 000000000..d928d032c --- /dev/null +++ b/packages/app/src/timeline/viewed-timeline-sync.ts @@ -0,0 +1,458 @@ +import type { AgentTimelineCursorState } from "@/stores/session-store"; +import { + planInitialAgentTimelineSync, + planResumeTimelineSync, + planTimelineCatchUpAfter, + type ProjectedTimelineForwardFetchPlan, +} from "./timeline-sync-plan"; + +interface TimelinePageResult { + hasNewer: boolean; + endCursor: { epoch: string; seq: number } | null; +} + +interface ViewedTimelineSyncPorts { + initialDeliveryMode: TimelineDeliveryMode; + setSubscription(agentIds: string[]): Promise; + readCursor(agentId: string): AgentTimelineCursorState | undefined; + hasAuthoritativeHistory(agentId: string): boolean; + fetchPage( + agentId: string, + request: ProjectedTimelineForwardFetchPlan, + ): Promise; + reportError(error: unknown): void; + schedule(task: () => void, delayMs: number): () => void; +} + +export type TimelineDeliveryMode = "legacy" | "selective"; +export type ViewedTimelineStatus = "ready" | "pending" | "error"; + +export interface ViewedTimelineUiBridge { + replaceVisibleAgentIds(sourceId: string, agentIds: string[]): void; + subscribe(listener: () => void): () => void; + getAgentTimelineStatus(agentId: string): ViewedTimelineStatus; +} + +export interface ViewedTimelineSync extends ViewedTimelineUiBridge { + setActive(active: boolean): void; + setConnected(connected: boolean): void; + setDeliveryMode(mode: TimelineDeliveryMode): void; + recoverGap(agentId: string, cursor: { epoch: string; endSeq: number }): void; + dispose(): void; +} + +const RETRY_DELAY_MS = 1_000; +export const VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS = 5_000; + +type CatchUpStatus = "running" | "complete" | "error"; + +interface CatchUpState { + generation: number; + status: CatchUpStatus; + request?: ProjectedTimelineForwardFetchPlan; + cancelRetry?: () => void; +} + +function isSameCatchUpRequest( + left: ProjectedTimelineForwardFetchPlan | undefined, + right: ProjectedTimelineForwardFetchPlan | undefined, +): boolean { + if (!left || !right || left.direction !== right.direction) return false; + if (left.direction !== "after" || right.direction !== "after") return true; + return left.cursor.epoch === right.cursor.epoch && left.cursor.seq === right.cursor.seq; +} + +function shouldKeepCurrentCatchUp(input: { + current: CatchUpState | undefined; + request: ProjectedTimelineForwardFetchPlan | undefined; + supersede: boolean; +}): boolean { + if (!input.current) return false; + if (input.supersede) { + return ( + input.current.status === "running" && + isSameCatchUpRequest(input.current.request, input.request) + ); + } + return input.current.status === "running" || input.current.status === "complete"; +} + +function normalizeAgentIds(agentIds: string[]): string[] { + return [...new Set(agentIds)].filter(Boolean).sort(); +} + +function sameAgentIds(left: string[], right: string[]): boolean { + return left.length === right.length && left.every((agentId, index) => agentId === right[index]); +} + +export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): ViewedTimelineSync { + const sources = new Map(); + const catchUps = new Map(); + const catchUpGenerations = new Map(); + const pendingGaps = new Map(); + const lingeringRemovals = new Map void>(); + const visibilityCatchUpPending = new Set(); + const visibilityCatchUpErrors = new Set(); + const listeners = new Set<() => void>(); + let active = true; + let connected = false; + let deliveryMode = ports.initialDeliveryMode; + let disposed = false; + let desired: string[] = []; + let acknowledged: string[] = []; + let membershipGeneration = 0; + let reconciling = false; + let reconcileRequested = false; + let membershipNeedsRetry = false; + let cancelMembershipRetry: (() => void) | null = null; + + const visibleAgentIds = () => (active ? normalizeAgentIds([...sources.values()].flat()) : []); + const effectiveAgentIds = () => + normalizeAgentIds([...visibleAgentIds(), ...lingeringRemovals.keys()]); + + const isAcknowledged = (agentId: string) => acknowledged.includes(agentId); + const isDesired = (agentId: string) => desired.includes(agentId); + + const notifyListeners = () => { + for (const listener of listeners) listener(); + }; + + const setVisibilityCatchUpReady = (agentId: string) => { + const wasPending = visibilityCatchUpPending.delete(agentId); + const hadError = visibilityCatchUpErrors.delete(agentId); + if (wasPending || hadError) notifyListeners(); + }; + + const setVisibilityCatchUpError = (agentIds: string[]) => { + let changed = false; + for (const agentId of agentIds) { + if (!visibilityCatchUpPending.delete(agentId)) continue; + visibilityCatchUpErrors.add(agentId); + changed = true; + } + if (changed) notifyListeners(); + }; + + const cancelCatchUp = (agentId: string) => { + catchUpGenerations.set(agentId, (catchUpGenerations.get(agentId) ?? 0) + 1); + catchUps.get(agentId)?.cancelRetry?.(); + catchUps.delete(agentId); + pendingGaps.delete(agentId); + }; + + const fetchUntilCurrent = async ( + agentId: string, + generation: number, + request: ProjectedTimelineForwardFetchPlan, + ): Promise => { + if ( + disposed || + !connected || + !isDesired(agentId) || + !isAcknowledged(agentId) || + catchUps.get(agentId)?.generation !== generation + ) { + return; + } + + try { + const page = await ports.fetchPage(agentId, request); + if ( + disposed || + !connected || + !isDesired(agentId) || + !isAcknowledged(agentId) || + catchUps.get(agentId)?.generation !== generation + ) { + return; + } + if (page.hasNewer && page.endCursor) { + await fetchUntilCurrent(agentId, generation, planTimelineCatchUpAfter(page.endCursor)); + return; + } + if (page.hasNewer) { + throw new Error(`Timeline page for ${agentId} hasNewer without an end cursor`); + } + catchUps.set(agentId, { generation, status: "complete" }); + setVisibilityCatchUpReady(agentId); + } catch (error) { + if (catchUps.get(agentId)?.generation === generation) { + const cancelRetry = ports.schedule(() => { + const current = catchUps.get(agentId); + if (current?.generation !== generation || current.status !== "error") return; + startCatchUp(agentId); + }, RETRY_DELAY_MS); + catchUps.set(agentId, { generation, status: "error", cancelRetry }); + setVisibilityCatchUpError([agentId]); + ports.reportError(error); + } + } + }; + + const startCatchUp = ( + agentId: string, + options: { + request?: ProjectedTimelineForwardFetchPlan; + supersede?: boolean; + } = {}, + ) => { + const { request, supersede = false } = options; + if (!connected || !isDesired(agentId) || !isAcknowledged(agentId)) { + if (request) pendingGaps.set(agentId, request); + return; + } + const current = catchUps.get(agentId); + if (shouldKeepCurrentCatchUp({ current, request, supersede })) { + return; + } + current?.cancelRetry?.(); + const generation = (catchUpGenerations.get(agentId) ?? 0) + 1; + catchUpGenerations.set(agentId, generation); + catchUps.set(agentId, { generation, status: "running", request }); + pendingGaps.delete(agentId); + const cursor = ports.readCursor(agentId); + const nextRequest = + request ?? + (ports.hasAuthoritativeHistory(agentId) + ? planResumeTimelineSync({ cursor }) + : planInitialAgentTimelineSync({ cursor, hasAuthoritativeHistory: false })); + void fetchUntilCurrent(agentId, generation, nextRequest); + }; + + const startAcknowledgedCatchUps = () => { + for (const agentId of acknowledged) { + const gap = pendingGaps.get(agentId); + startCatchUp(agentId, { request: gap, supersede: Boolean(gap) }); + } + }; + + const reconcileLatestMembership = async (): Promise => { + if (disposed || !connected || deliveryMode !== "selective") return; + const generation = membershipGeneration; + const requested = desired; + if (!membershipNeedsRetry && sameAgentIds(requested, acknowledged)) return; + membershipNeedsRetry = false; + try { + await ports.setSubscription(requested); + } catch (error) { + membershipNeedsRetry = true; + setVisibilityCatchUpError(requested); + cancelMembershipRetry?.(); + cancelMembershipRetry = ports.schedule(() => { + cancelMembershipRetry = null; + if ( + disposed || + !connected || + membershipGeneration !== generation || + !sameAgentIds(desired, requested) + ) { + return; + } + void reconcileMembership(); + }, RETRY_DELAY_MS); + ports.reportError(error); + return; + } + cancelMembershipRetry?.(); + cancelMembershipRetry = null; + if (disposed || !connected || deliveryMode !== "selective") return; + acknowledged = requested; + if (generation !== membershipGeneration) { + await reconcileLatestMembership(); + return; + } + startAcknowledgedCatchUps(); + if (!sameAgentIds(desired, acknowledged)) await reconcileLatestMembership(); + }; + + const reconcileMembership = async () => { + if (reconciling) { + reconcileRequested = true; + return; + } + if (disposed || !connected) return; + reconciling = true; + try { + await reconcileLatestMembership(); + } finally { + reconciling = false; + if (reconcileRequested && !disposed && connected && deliveryMode === "selective") { + reconcileRequested = false; + void reconcileMembership(); + } else if ( + !disposed && + connected && + deliveryMode === "selective" && + !membershipNeedsRetry && + !sameAgentIds(desired, acknowledged) + ) { + void reconcileMembership(); + } + } + }; + + const retryFailedCatchUps = () => { + for (const agentId of acknowledged) { + if (catchUps.get(agentId)?.status === "error") startCatchUp(agentId); + } + }; + + const commitDesiredMembership = ( + nextDesired: string[], + options: { resetCatchUpStatus?: boolean } = {}, + ) => { + let statusChanged = false; + if (options.resetCatchUpStatus) { + for (const agentId of nextDesired) { + if (!visibilityCatchUpPending.has(agentId)) { + visibilityCatchUpPending.add(agentId); + statusChanged = true; + } + if (visibilityCatchUpErrors.delete(agentId)) statusChanged = true; + } + } + if (sameAgentIds(nextDesired, desired)) { + if (statusChanged) notifyListeners(); + if (deliveryMode === "selective" && membershipNeedsRetry) void reconcileMembership(); + retryFailedCatchUps(); + return; + } + + for (const agentId of desired) { + if (!nextDesired.includes(agentId)) { + cancelCatchUp(agentId); + visibilityCatchUpPending.delete(agentId); + visibilityCatchUpErrors.delete(agentId); + } + } + for (const agentId of nextDesired) { + if (!desired.includes(agentId)) { + visibilityCatchUpPending.add(agentId); + visibilityCatchUpErrors.delete(agentId); + } + } + cancelMembershipRetry?.(); + cancelMembershipRetry = null; + desired = nextDesired; + membershipGeneration += 1; + notifyListeners(); + if (deliveryMode === "legacy") { + acknowledged = connected ? desired : []; + if (connected) startAcknowledgedCatchUps(); + return; + } + void reconcileMembership(); + }; + + const clearLingeringRemovals = () => { + for (const cancel of lingeringRemovals.values()) cancel(); + lingeringRemovals.clear(); + }; + + const publishVisibleMembership = (allowGrace: boolean) => { + const visible = visibleAgentIds(); + for (const agentId of visible) { + lingeringRemovals.get(agentId)?.(); + lingeringRemovals.delete(agentId); + } + + if (allowGrace && connected && deliveryMode === "selective") { + for (const agentId of desired) { + if (visible.includes(agentId) || lingeringRemovals.has(agentId)) continue; + const cancel = ports.schedule(() => { + lingeringRemovals.delete(agentId); + commitDesiredMembership(effectiveAgentIds()); + }, VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS); + lingeringRemovals.set(agentId, cancel); + } + } else { + clearLingeringRemovals(); + } + + commitDesiredMembership(effectiveAgentIds()); + }; + + return { + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + getAgentTimelineStatus(agentId) { + if (visibilityCatchUpErrors.has(agentId)) return "error"; + if (!isDesired(agentId) || visibilityCatchUpPending.has(agentId)) return "pending"; + return "ready"; + }, + replaceVisibleAgentIds(sourceId, agentIds) { + const normalized = normalizeAgentIds(agentIds); + if (normalized.length === 0) sources.delete(sourceId); + else sources.set(sourceId, normalized); + publishVisibleMembership(true); + }, + setActive(nextActive) { + if (active === nextActive) return; + active = nextActive; + publishVisibleMembership(true); + }, + setConnected(nextConnected) { + if (connected === nextConnected) return; + connected = nextConnected; + if (!connected) { + clearLingeringRemovals(); + commitDesiredMembership(visibleAgentIds(), { resetCatchUpStatus: true }); + cancelMembershipRetry?.(); + cancelMembershipRetry = null; + acknowledged = []; + membershipGeneration += 1; + for (const agentId of desired) cancelCatchUp(agentId); + return; + } + membershipGeneration += 1; + if (deliveryMode === "legacy") { + acknowledged = desired; + startAcknowledgedCatchUps(); + } else { + void reconcileMembership(); + } + }, + setDeliveryMode(nextMode) { + if (deliveryMode === nextMode) return; + deliveryMode = nextMode; + clearLingeringRemovals(); + cancelMembershipRetry?.(); + cancelMembershipRetry = null; + membershipNeedsRetry = false; + membershipGeneration += 1; + for (const agentId of desired) cancelCatchUp(agentId); + desired = visibleAgentIds(); + visibilityCatchUpPending.clear(); + visibilityCatchUpErrors.clear(); + for (const agentId of desired) visibilityCatchUpPending.add(agentId); + acknowledged = deliveryMode === "legacy" && connected ? desired : []; + notifyListeners(); + if (deliveryMode === "selective" && connected) void reconcileMembership(); + else if (connected) startAcknowledgedCatchUps(); + }, + recoverGap(agentId, cursor) { + if (!isDesired(agentId)) return; + startCatchUp(agentId, { + request: planTimelineCatchUpAfter({ epoch: cursor.epoch, seq: cursor.endSeq }), + supersede: true, + }); + }, + dispose() { + disposed = true; + clearLingeringRemovals(); + cancelMembershipRetry?.(); + cancelMembershipRetry = null; + sources.clear(); + membershipGeneration += 1; + for (const agentId of desired) cancelCatchUp(agentId); + desired = []; + acknowledged = []; + visibilityCatchUpPending.clear(); + visibilityCatchUpErrors.clear(); + notifyListeners(); + listeners.clear(); + }, + }; +} diff --git a/packages/app/src/utils/agent-directory-reconciliation.test.ts b/packages/app/src/utils/agent-directory-reconciliation.test.ts new file mode 100644 index 000000000..03387d010 --- /dev/null +++ b/packages/app/src/utils/agent-directory-reconciliation.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from "vitest"; +import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; +import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; +import type { Agent } from "@/stores/session-store"; +import { reconcileAgentDirectory } from "./agent-directory-reconciliation"; + +function snapshot(id: string, status: AgentSnapshotPayload["status"]): AgentSnapshotPayload { + return { + id, + provider: "codex", + cwd: "/repo", + model: null, + createdAt: "2026-07-12T10:00:00.000Z", + updatedAt: "2026-07-12T10:00:00.000Z", + lastUserMessageAt: null, + status, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: null, + labels: {}, + }; +} + +function entry(id: string, status: AgentSnapshotPayload["status"]): FetchAgentsEntry { + return { + agent: snapshot(id, status), + project: { + projectKey: "/repo", + projectName: "repo", + checkout: { + cwd: "/repo", + isGit: false, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }, + }; +} + +function replica(id: string, status: Agent["status"]): Agent { + return { + ...snapshot(id, status), + serverId: "server", + createdAt: new Date("2026-07-12T10:00:00.000Z"), + updatedAt: new Date("2026-07-12T10:00:00.000Z"), + lastActivityAt: new Date("2026-07-12T10:00:00.000Z"), + lastUserMessageAt: null, + attentionTimestamp: null, + archivedAt: null, + parentAgentId: null, + }; +} + +describe("agent directory reconciliation", () => { + it("reports snapshot-only and buffered running transitions exactly once", () => { + const result = reconcileAgentDirectory({ + previous: new Map([ + ["snapshot", replica("snapshot", "running")], + ["buffered", replica("buffered", "running")], + ]), + snapshot: [entry("snapshot", "idle"), entry("buffered", "running")], + deltas: [ + { + kind: "upsert", + agent: snapshot("buffered", "idle"), + project: entry("buffered", "idle").project, + }, + { + kind: "upsert", + agent: snapshot("buffered", "idle"), + project: entry("buffered", "idle").project, + }, + ], + }); + + expect(result.stoppedRunningAgentIds).toEqual(["snapshot", "buffered"]); + expect(result.entries.map(({ agent }) => [agent.id, agent.status])).toEqual([ + ["snapshot", "idle"], + ["buffered", "idle"], + ]); + }); + + it("preserves ordered upserts and removals received after page one", () => { + const result = reconcileAgentDirectory({ + previous: new Map(), + snapshot: [entry("updated", "idle"), entry("removed", "idle")], + deltas: [ + { + kind: "upsert", + agent: { ...snapshot("updated", "idle"), title: "live" }, + project: entry("updated", "idle").project, + }, + { kind: "remove", agentId: "removed" }, + ], + }); + + expect(result.entries.map(({ agent }) => [agent.id, agent.title])).toEqual([ + ["updated", "live"], + ]); + }); + + it("keeps newer page metadata when a stale buffered upsert arrives", () => { + const snapshotEntry = entry("agent", "running"); + const staleProject = { + ...snapshotEntry.project, + projectName: "stale project", + }; + const result = reconcileAgentDirectory({ + previous: new Map([["agent", replica("agent", "running")]]), + snapshot: [ + { + ...entry("agent", "running"), + agent: { + ...snapshot("agent", "running"), + title: "newer page", + updatedAt: "2026-07-12T12:00:00.000Z", + }, + }, + ], + deltas: [ + { + kind: "upsert", + agent: { + ...snapshot("agent", "idle"), + title: "stale live", + updatedAt: "2026-07-12T11:00:00.000Z", + }, + project: staleProject, + }, + ], + }); + + expect({ + title: result.entries[0]?.agent.title, + status: result.entries[0]?.agent.status, + projectName: result.entries[0]?.project.projectName, + stopped: result.stoppedRunningAgentIds, + }).toEqual({ title: "newer page", status: "running", projectName: "repo", stopped: [] }); + }); + + it("clears a snapshot stop when a newer buffered upsert is running", () => { + const result = reconcileAgentDirectory({ + previous: new Map([["agent", replica("agent", "running")]]), + snapshot: [entry("agent", "idle")], + deltas: [ + { + kind: "upsert", + agent: { + ...snapshot("agent", "running"), + updatedAt: "2026-07-12T11:00:00.000Z", + }, + project: entry("agent", "running").project, + }, + ], + }); + + expect(result.entries[0]?.agent.status).toBe("running"); + expect(result.stoppedRunningAgentIds).toEqual([]); + }); + + it("accepts usage from a stale buffered upsert without regressing metadata", () => { + const result = reconcileAgentDirectory({ + previous: new Map(), + snapshot: [ + { + ...entry("agent", "idle"), + agent: { + ...snapshot("agent", "idle"), + title: "newer page", + updatedAt: "2026-07-12T12:00:00.000Z", + lastUsage: { inputTokens: 10, outputTokens: 5 }, + }, + }, + ], + deltas: [ + { + kind: "upsert", + agent: { + ...snapshot("agent", "running"), + title: "stale live", + updatedAt: "2026-07-12T11:00:00.000Z", + lastUsage: { inputTokens: 20, outputTokens: 8 }, + }, + project: entry("agent", "idle").project, + }, + ], + }); + + expect({ + title: result.entries[0]?.agent.title, + status: result.entries[0]?.agent.status, + usage: result.entries[0]?.agent.lastUsage, + }).toEqual({ + title: "newer page", + status: "idle", + usage: { inputTokens: 20, outputTokens: 8 }, + }); + }); + + it("preserves usage when a stale buffered upsert omits it", () => { + const result = reconcileAgentDirectory({ + previous: new Map(), + snapshot: [ + { + ...entry("agent", "idle"), + agent: { + ...snapshot("agent", "idle"), + updatedAt: "2026-07-12T12:00:00.000Z", + lastUsage: { inputTokens: 10, outputTokens: 5 }, + }, + }, + ], + deltas: [ + { + kind: "upsert", + agent: { + ...snapshot("agent", "running"), + updatedAt: "2026-07-12T11:00:00.000Z", + }, + project: entry("agent", "idle").project, + }, + ], + }); + + expect(result.entries[0]?.agent.lastUsage).toEqual({ inputTokens: 10, outputTokens: 5 }); + }); +}); diff --git a/packages/app/src/utils/agent-directory-reconciliation.ts b/packages/app/src/utils/agent-directory-reconciliation.ts new file mode 100644 index 000000000..86130f5d2 --- /dev/null +++ b/packages/app/src/utils/agent-directory-reconciliation.ts @@ -0,0 +1,62 @@ +import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; +import type { Agent } from "@/stores/session-store"; +import type { AgentDirectoryDelta } from "./agent-directory-sync"; +import { acceptAgentDirectoryUpdate } from "./agent-directory-update-policy"; + +export function reconcileAgentDirectory(input: { + previous: ReadonlyMap; + snapshot: FetchAgentsEntry[]; + deltas: readonly AgentDirectoryDelta[]; +}): { entries: FetchAgentsEntry[]; stoppedRunningAgentIds: string[] } { + const entries = new Map(input.snapshot.map((entry) => [entry.agent.id, entry])); + const statuses = new Map(Array.from(input.previous, ([id, agent]) => [id, agent.status])); + const stoppedRunningAgentIds = new Set(); + + for (const entry of input.snapshot) { + if (statuses.get(entry.agent.id) === "running" && entry.agent.status !== "running") { + stoppedRunningAgentIds.add(entry.agent.id); + } + statuses.set(entry.agent.id, entry.agent.status); + } + + for (const delta of input.deltas) { + if (delta.kind === "remove") { + entries.delete(delta.agentId); + statuses.delete(delta.agentId); + stoppedRunningAgentIds.delete(delta.agentId); + continue; + } + const previousEntry = entries.get(delta.agent.id); + const acceptedAgent = acceptAgentDirectoryUpdate(previousEntry?.agent, delta.agent); + if (acceptedAgent.status === "running") { + stoppedRunningAgentIds.delete(delta.agent.id); + } else if (statuses.get(delta.agent.id) === "running") { + stoppedRunningAgentIds.add(delta.agent.id); + } + statuses.set(delta.agent.id, acceptedAgent.status); + const previousProject = previousEntry?.project; + const acceptedProject = + acceptedAgent === delta.agent ? (delta.project ?? previousProject) : previousProject; + entries.set(delta.agent.id, { + agent: acceptedAgent, + project: acceptedProject ?? { + projectKey: delta.agent.cwd, + projectName: /[^/]+$/.exec(delta.agent.cwd)?.[0] ?? delta.agent.cwd, + checkout: { + cwd: delta.agent.cwd, + isGit: false, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }, + }); + } + + return { + entries: Array.from(entries.values()), + stoppedRunningAgentIds: Array.from(stoppedRunningAgentIds), + }; +} diff --git a/packages/app/src/utils/agent-directory-sync.test.ts b/packages/app/src/utils/agent-directory-sync.test.ts index d1c0d1df1..1ec16bde2 100644 --- a/packages/app/src/utils/agent-directory-sync.test.ts +++ b/packages/app/src/utils/agent-directory-sync.test.ts @@ -2,8 +2,12 @@ import { describe, expect, it } from "vitest"; import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; +import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; import { useSessionStore } from "@/stores/session-store"; -import { replaceFetchedAgentDirectory } from "./agent-directory-sync"; +import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent"; +import { queryClient } from "@/data/query-client"; +import { applyAgentDirectoryDelta, replaceFetchedAgentDirectory } from "./agent-directory-sync"; function createAgentPayload( input: Partial> & { @@ -56,7 +60,28 @@ function createEntry(agent: AgentSnapshotPayload): FetchAgentsEntry { }; } +function permission(id: string): AgentPermissionRequest { + return { id, provider: "codex", name: id, kind: "tool", title: id }; +} + describe("replaceFetchedAgentDirectory", () => { + it("preserves timeline initialization while replacing directory state", () => { + const serverId = "server-initializing"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + store.setInitializingAgents(serverId, new Map([["agent", true]])); + + replaceFetchedAgentDirectory({ + serverId, + entries: [createEntry(createAgentPayload({ id: "agent" }))], + }); + + expect(useSessionStore.getState().sessions[serverId]?.initializingAgents.get("agent")).toBe( + true, + ); + store.clearSession(serverId); + }); + it("re-derives parentAgentId every time an agent snapshot is ingested", () => { const serverId = "server-1"; const store = useSessionStore.getState(); @@ -92,4 +117,142 @@ describe("replaceFetchedAgentDirectory", () => { store.clearSession(serverId); }); + + it("removes every replica-owned artifact for a removed agent", () => { + const serverId = "server-removal"; + const agentId = "removed-agent"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const agent = { + ...normalizeAgentSnapshot(createAgentPayload({ id: agentId }), serverId), + projectPlacement: null, + }; + store.setAgents(serverId, new Map([[agentId, agent]])); + store.setAgentDetails(serverId, new Map([[agentId, agent]])); + store.setQueuedMessages( + serverId, + new Map([[agentId, [{ id: "queued", text: "next", attachments: [] }]]]), + ); + store.setAgentTimelineCursor( + serverId, + new Map([[agentId, { epoch: "epoch", startSeq: 1, endSeq: 2 }]]), + ); + store.setPendingPermissions( + serverId, + new Map([["permission", { key: "permission", agentId, request: null as never }]]), + ); + store.setInitializingAgents(serverId, new Map([[agentId, true]])); + setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true }); + + applyAgentDirectoryDelta({ serverId, delta: { kind: "remove", agentId } }); + + const session = useSessionStore.getState().sessions[serverId]; + expect({ + agents: session?.agents.has(agentId), + details: session?.agentDetails.has(agentId), + queued: session?.queuedMessages.has(agentId), + cursor: session?.agentTimelineCursor.has(agentId), + permissions: session?.pendingPermissions.size, + initializing: session?.initializingAgents.has(agentId), + archivePending: isAgentArchiving({ queryClient, serverId, agentId }), + }).toEqual({ + agents: false, + details: false, + queued: false, + cursor: false, + permissions: 0, + initializing: false, + archivePending: false, + }); + + store.clearSession(serverId); + }); + + it("keeps newer metadata while accepting usage-only updates and legacy workspace ownership", () => { + const serverId = "server-usage"; + const agentId = "usage-agent"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + store.setWorkspaces( + serverId, + new Map([ + [ + "legacy-workspace", + { + id: "legacy-workspace", + projectId: "project", + projectDisplayName: "Project", + projectRootPath: "/repo", + workspaceDirectory: "/repo", + projectKind: "git", + workspaceKind: "worktree", + name: "repo", + status: "done", + statusEnteredAt: null, + archivingAt: null, + diffStat: null, + scripts: [], + }, + ], + ]), + ); + const current = createAgentPayload({ + id: agentId, + title: "current", + status: "running", + updatedAt: "2026-07-12T11:00:00.000Z", + lastUsage: { inputTokens: 10, outputTokens: 5 }, + pendingPermissions: [permission("current-permission")], + }); + applyAgentDirectoryDelta({ + serverId, + delta: { kind: "upsert", agent: current, project: createEntry(current).project }, + }); + store.flushAgentLastActivity(); + setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true }); + + const staleResult = applyAgentDirectoryDelta({ + serverId, + delta: { + kind: "upsert", + agent: { + ...current, + title: "stale", + status: "idle", + updatedAt: "2026-07-12T10:00:00.000Z", + lastUsage: { inputTokens: 20, outputTokens: 8 }, + pendingPermissions: [permission("stale-permission")], + archivedAt: "2026-07-12T10:00:00.000Z", + }, + project: createEntry(current).project, + }, + }); + store.flushAgentLastActivity(); + + const state = useSessionStore.getState(); + const agent = state.sessions[serverId]?.agents.get(agentId); + expect({ + title: agent?.title, + status: agent?.status, + usage: agent?.lastUsage, + workspaceId: agent?.workspaceId, + stoppedRunning: staleResult.stoppedRunning, + permissions: Array.from(state.sessions[serverId]?.pendingPermissions.values() ?? []).map( + ({ request }) => request.id, + ), + archivePending: isAgentArchiving({ queryClient, serverId, agentId }), + activity: state.agentLastActivity.get(agentId)?.toISOString(), + }).toEqual({ + title: "current", + status: "running", + usage: { inputTokens: 20, outputTokens: 8 }, + workspaceId: "legacy-workspace", + stoppedRunning: false, + permissions: ["current-permission"], + archivePending: true, + activity: "2026-07-12T11:00:00.000Z", + }); + + store.clearSession(serverId); + }); }); diff --git a/packages/app/src/utils/agent-directory-sync.ts b/packages/app/src/utils/agent-directory-sync.ts index 684aaf82b..24e14c420 100644 --- a/packages/app/src/utils/agent-directory-sync.ts +++ b/packages/app/src/utils/agent-directory-sync.ts @@ -2,8 +2,132 @@ import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; import { type Agent, useSessionStore } from "@/stores/session-store"; import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { resolveProjectPlacement } from "@/utils/project-placement"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { clearArchiveAgentPending } from "@/hooks/use-archive-agent"; +import { queryClient } from "@/data/query-client"; +import { acceptAgentDirectoryUpdate } from "@/utils/agent-directory-update-policy"; +import { buildDraftStoreKey } from "@/stores/draft-keys"; +import { useDraftStore } from "@/stores/draft-store"; +import { getInitDeferred, getInitKey, rejectInitDeferred } from "@/utils/agent-initialization"; type AgentDirectoryFetchEntry = FetchAgentsEntry; +export type AgentDirectoryDelta = Extract< + SessionOutboundMessage, + { type: "agent_update" } +>["payload"]; + +export function applyAgentDirectoryDelta(input: { serverId: string; delta: AgentDirectoryDelta }): { + agentId: string; + stoppedRunning: boolean; +} { + if (input.delta.kind === "remove") { + removeAgentDirectoryReplica(input.serverId, input.delta.agentId); + return { agentId: input.delta.agentId, stoppedRunning: false }; + } + return upsertAgentDirectoryReplica(input.serverId, input.delta); +} + +type AgentUpsertDelta = Extract; + +function upsertAgentDirectoryReplica( + serverId: string, + delta: AgentUpsertDelta, +): { agentId: string; stoppedRunning: boolean } { + const normalized = normalizeAgentSnapshot(delta.agent, serverId); + const session = useSessionStore.getState().sessions[serverId]; + const previousAgent = + session?.agents.get(normalized.id) ?? session?.agentDetails.get(normalized.id); + const legacyWorkspaceId = + previousAgent?.workspaceId ?? + Array.from(session?.workspaces.values() ?? []).find( + (workspace) => + session?.serverInfo?.features?.workspaceMultiplicity !== true && + workspace.workspaceDirectory === normalized.cwd, + )?.id; + const agent: Agent = { + ...normalized, + workspaceId: normalized.workspaceId ?? legacyWorkspaceId, + projectPlacement: + resolveProjectPlacement({ projectPlacement: delta.project, cwd: normalized.cwd }) ?? + previousAgent?.projectPlacement, + }; + const acceptedAgent = upsertAgentReplica(serverId, agent); + if (acceptedAgent.archivedAt) { + clearArchiveAgentPending({ queryClient, serverId, agentId: acceptedAgent.id }); + } + replaceAgentPendingPermissions(serverId, acceptedAgent); + useSessionStore.getState().setAgentLastActivity(acceptedAgent.id, acceptedAgent.lastActivityAt); + return { + agentId: acceptedAgent.id, + stoppedRunning: previousAgent?.status === "running" && acceptedAgent.status !== "running", + }; +} + +export function upsertAgentReplica(serverId: string, agent: Agent): Agent { + let acceptedAgent = agent; + useSessionStore.getState().setAgents(serverId, (current) => { + const currentAgent = current.get(agent.id); + acceptedAgent = acceptAgentDirectoryUpdate(currentAgent, agent); + if (acceptedAgent === currentAgent) return current; + const next = new Map(current); + next.set(agent.id, acceptedAgent); + return next; + }); + return acceptedAgent; +} + +export function replaceAgentPendingPermissions(serverId: string, agent: Agent): void { + const pendingPermissions = new Map( + useSessionStore.getState().sessions[serverId]?.pendingPermissions, + ); + for (const [key, pending] of pendingPermissions) { + if (pending.agentId === agent.id) pendingPermissions.delete(key); + } + for (const request of agent.pendingPermissions) { + const key = derivePendingPermissionKey(agent.id, request); + pendingPermissions.set(key, { key, agentId: agent.id, request }); + } + useSessionStore.getState().setPendingPermissions(serverId, pendingPermissions); +} + +export function removeAgentDirectoryReplica(serverId: string, agentId: string): void { + const store = useSessionStore.getState(); + clearArchiveAgentPending({ queryClient, serverId, agentId }); + const removeKey = (current: Map): Map => { + if (!current.has(agentId)) return current; + const next = new Map(current); + next.delete(agentId); + return next; + }; + store.setAgents(serverId, removeKey); + store.setAgentDetails(serverId, removeKey); + store.setQueuedMessages(serverId, removeKey); + store.setAgentTimelineCursor(serverId, removeKey); + store.setInitializingAgents(serverId, removeKey); + store.setPendingPermissions(serverId, (current) => { + const next = new Map(current); + for (const [key, pending] of next) { + if (pending.agentId === agentId) next.delete(key); + } + return next.size === current.size ? current : next; + }); + store.setAgentAuthoritativeHistoryApplied(serverId, agentId, false); + store.setAgentStreamTail(serverId, removeKey); + store.clearAgentStreamHead(serverId, agentId); + useSessionStore.setState((state) => { + if (!state.agentLastActivity.has(agentId)) return state; + const agentLastActivity = new Map(state.agentLastActivity); + agentLastActivity.delete(agentId); + return { ...state, agentLastActivity }; + }); + useDraftStore.getState().clearDraftInput({ + draftKey: buildDraftStoreKey({ serverId, agentId }), + }); + const initKey = getInitKey(serverId, agentId); + if (getInitDeferred(initKey)) { + rejectInitDeferred(initKey, new Error("Agent was removed during initialization")); + } +} interface PendingPermissionEntry { key: string; @@ -49,6 +173,12 @@ export function replaceFetchedAgentDirectory(input: { const { agents: fetchedAgents, pendingPermissions } = buildAgentDirectoryState(input); const store = useSessionStore.getState(); + for (const agent of fetchedAgents.values()) { + if (agent.archivedAt) { + clearArchiveAgentPending({ queryClient, serverId: input.serverId, agentId: agent.id }); + } + } + store.setAgents(input.serverId, fetchedAgents); store.setAgentDetails(input.serverId, (prev) => { let next: Map | null = null; @@ -69,7 +199,6 @@ export function replaceFetchedAgentDirectory(input: { store.setAgentLastActivityBatch(lastActivityByAgentId); store.setPendingPermissions(input.serverId, new Map(pendingPermissions)); - store.setInitializingAgents(input.serverId, new Map()); store.setHasHydratedAgents(input.serverId, true); return { agents: fetchedAgents }; } diff --git a/packages/app/src/utils/agent-directory-update-policy.ts b/packages/app/src/utils/agent-directory-update-policy.ts new file mode 100644 index 000000000..4d14f101b --- /dev/null +++ b/packages/app/src/utils/agent-directory-update-policy.ts @@ -0,0 +1,21 @@ +import equal from "fast-deep-equal"; +import type { AgentUsage } from "@getpaseo/protocol/agent-types"; + +interface AgentUpdateValue { + updatedAt: Date | string; + lastUsage?: AgentUsage; +} + +function timestamp(value: Date | string): number { + return value instanceof Date ? value.getTime() : Date.parse(value); +} + +export function acceptAgentDirectoryUpdate( + current: T | undefined, + incoming: T, +): T { + if (!current || timestamp(incoming.updatedAt) >= timestamp(current.updatedAt)) return incoming; + if (incoming.lastUsage === undefined) return current; + if (equal(incoming.lastUsage, current.lastUsage)) return current; + return { ...current, lastUsage: incoming.lastUsage }; +} diff --git a/packages/app/src/utils/app-visibility.test.ts b/packages/app/src/utils/app-visibility.test.ts new file mode 100644 index 000000000..eec95463d --- /dev/null +++ b/packages/app/src/utils/app-visibility.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "vitest"; +import { isAppActivelyVisible, isAppVisible } from "./app-visibility"; + +test("a visible desktop app remains visible when another window has focus", () => { + const input = { + appState: "active", + native: false, + documentVisible: true, + windowFocused: false, + }; + + expect(isAppVisible(input)).toBe(true); + expect(isAppActivelyVisible(input)).toBe(false); +}); + +test("a hidden desktop page is neither visible nor actively visible", () => { + const input = { + appState: "active", + native: false, + documentVisible: false, + windowFocused: true, + }; + + expect(isAppVisible(input)).toBe(false); + expect(isAppActivelyVisible(input)).toBe(false); +}); diff --git a/packages/app/src/utils/app-visibility.ts b/packages/app/src/utils/app-visibility.ts index 896d70eb1..d078f1022 100644 --- a/packages/app/src/utils/app-visibility.ts +++ b/packages/app/src/utils/app-visibility.ts @@ -1,20 +1,49 @@ import { AppState } from "react-native"; import { isNative } from "@/constants/platform"; -export function getIsAppActivelyVisible(appState: string = AppState.currentState): boolean { - if (appState !== "active") { - return false; - } +interface AppVisibilityInput { + appState: string; + native: boolean; + documentVisible: boolean; +} - if (isNative) { - return true; - } +interface ActiveAppVisibilityInput extends AppVisibilityInput { + windowFocused: boolean; +} - const documentVisible = typeof document === "undefined" || document.visibilityState === "visible"; - const windowFocused = +export function isAppVisible(input: AppVisibilityInput): boolean { + return input.appState === "active" && (input.native || input.documentVisible); +} + +export function isAppActivelyVisible(input: ActiveAppVisibilityInput): boolean { + return isAppVisible(input) && (input.native || input.windowFocused); +} + +function getDocumentVisible(): boolean { + return typeof document === "undefined" || document.visibilityState === "visible"; +} + +function getWindowFocused(): boolean { + return ( typeof document === "undefined" || typeof document.hasFocus !== "function" || - document.hasFocus(); - - return documentVisible && windowFocused; + document.hasFocus() + ); +} + +export function getIsAppVisible(appState: string = AppState.currentState): boolean { + return isAppVisible({ + appState, + native: isNative, + documentVisible: getDocumentVisible(), + }); +} + +export function getIsAppActivelyVisible(appState: string = AppState.currentState): boolean { + return isAppActivelyVisible({ + appState, + native: isNative, + documentVisible: getDocumentVisible(), + windowFocused: getWindowFocused(), + }); } diff --git a/packages/app/src/utils/provider-notice-toast.ts b/packages/app/src/utils/provider-notice-toast.ts index 478f929f8..7c3ace8b5 100644 --- a/packages/app/src/utils/provider-notice-toast.ts +++ b/packages/app/src/utils/provider-notice-toast.ts @@ -13,7 +13,7 @@ export function showProviderNoticeToast( return; } toast.show(notice.message, { - variant: "default", - durationMs: notice.type === "warning" ? 3200 : undefined, + variant: notice.type, + durationMs: notice.type === "warning" ? 5000 : undefined, }); } diff --git a/packages/app/src/utils/schedule-format.test.ts b/packages/app/src/utils/schedule-format.test.ts index 8715aa03c..529c3d913 100644 --- a/packages/app/src/utils/schedule-format.test.ts +++ b/packages/app/src/utils/schedule-format.test.ts @@ -6,6 +6,7 @@ import { formatCadence, formatNextRun, isNewAgentSchedule, + scheduleProductName, partsToEveryMs, resolveScheduleTitle, validateCron, @@ -54,6 +55,11 @@ describe("schedule title helpers", () => { expect(isNewAgentSchedule(createSchedule({ targetType: "agent" }))).toBe(false); }); + it("labels engine records by product meaning", () => { + expect(scheduleProductName(createSchedule({ targetType: "new-agent" }))).toBe("Schedule"); + expect(scheduleProductName(createSchedule({ targetType: "agent" }))).toBe("Heartbeat"); + }); + it("resolves display titles by name, config title, prompt, then fallback", () => { expect( resolveScheduleTitle(createSchedule({ name: "Morning run", title: "Config title" })), diff --git a/packages/app/src/utils/schedule-format.ts b/packages/app/src/utils/schedule-format.ts index 2fa21ce4e..8a239768f 100644 --- a/packages/app/src/utils/schedule-format.ts +++ b/packages/app/src/utils/schedule-format.ts @@ -28,6 +28,10 @@ export function isNewAgentSchedule(schedule: ScheduleSummary): boolean { return schedule.target.type === "new-agent"; } +export function scheduleProductName(schedule: ScheduleSummary): "Heartbeat" | "Schedule" { + return schedule.target.type === "agent" ? "Heartbeat" : "Schedule"; +} + export function resolveScheduleTitle(schedule: ScheduleSummary): string { const name = schedule.name?.trim(); if (name) { @@ -43,7 +47,7 @@ export function resolveScheduleTitle(schedule: ScheduleSummary): string { .split("\n") .map((line) => line.trim()) .find((line) => line.length > 0); - return firstPromptLine || "Untitled schedule"; + return firstPromptLine || `Untitled ${scheduleProductName(schedule).toLowerCase()}`; } function pluralize(value: number, noun: string): string { diff --git a/packages/app/src/workspace/legacy-daemon-workspaces.ts b/packages/app/src/workspace/legacy-daemon-workspaces.ts index 23de643b3..820e37368 100644 --- a/packages/app/src/workspace/legacy-daemon-workspaces.ts +++ b/packages/app/src/workspace/legacy-daemon-workspaces.ts @@ -121,7 +121,7 @@ export async function backfillLegacyDaemonWorkspaceDirectoryIfEmpty( return true; } -async function readLegacyDaemonWorkspaceDirectory(input: { +export async function readLegacyDaemonWorkspaceDirectory(input: { client: Pick; subscribe?: FetchAgentsOptions["subscribe"]; page?: FetchAgentsOptions["page"]; @@ -193,7 +193,7 @@ export function applyLegacyDaemonWorkspaceOwnership(input: { }; } -function replaceLegacyDaemonWorkspaceDirectory(input: { +export function replaceLegacyDaemonWorkspaceDirectory(input: { serverId: string; entries: FetchAgentsEntry[]; }): LegacyDaemonWorkspaceSnapshot { @@ -242,7 +242,7 @@ function readFetchAgentsNextCursor( return null; } -function stampLegacyWorkspaceIds(entries: FetchAgentsEntry[]): FetchAgentsEntry[] { +export function stampLegacyWorkspaceIds(entries: FetchAgentsEntry[]): FetchAgentsEntry[] { return entries.map((entry) => { const workspaceId = resolveLegacyWorkspaceId(entry); return { @@ -255,7 +255,9 @@ function stampLegacyWorkspaceIds(entries: FetchAgentsEntry[]): FetchAgentsEntry[ }); } -function buildLegacyWorkspaces(entries: FetchAgentsEntry[]): Map { +export function buildLegacyWorkspaces( + entries: FetchAgentsEntry[], +): Map { const workspaces = new Map(); for (const entry of entries) { const workspaceId = entry.agent.workspaceId ?? resolveLegacyWorkspaceId(entry); diff --git a/packages/cli/package.json b/packages/cli/package.json index e4e158885..84a4e314a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.1.110", + "version": "0.2.0-beta.1", "description": "Paseo CLI - control your AI coding agents from the command line", "bin": { "paseo": "bin/paseo" @@ -27,9 +27,9 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.1.110", - "@getpaseo/protocol": "0.1.110", - "@getpaseo/server": "0.1.110", + "@getpaseo/client": "0.2.0-beta.1", + "@getpaseo/protocol": "0.2.0-beta.1", + "@getpaseo/server": "0.2.0-beta.1", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/cli/src/cli-surface.test.ts b/packages/cli/src/cli-surface.test.ts new file mode 100644 index 000000000..fa772f85c --- /dev/null +++ b/packages/cli/src/cli-surface.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { createCli } from "./cli.js"; + +describe("canonical CLI surface", () => { + it("shows workspace and heartbeat commands while hiding worktree compatibility", () => { + const cli = createCli(); + const help = cli.helpInformation(); + expect(help).toContain("workspace"); + expect(help).toContain("heartbeat"); + expect(help).not.toContain("worktree"); + }); + + it("hides legacy run worktree syntax", () => { + const run = createCli().commands.find((command) => command.name() === "run"); + expect(run?.helpInformation()).toContain("--isolation "); + expect(run?.helpInformation()).not.toContain("--worktree "); + }); + + it("uses background for execution and reserves detach for ownership", () => { + const run = createCli().commands.find((command) => command.name() === "run"); + expect(run?.helpInformation()).toContain("--background"); + expect(run?.helpInformation()).not.toContain("--detach"); + }); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 9c98310ba..9124fc6c3 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -9,6 +9,9 @@ import { createScheduleCommand } from "./commands/schedule/index.js"; import { createSpeechCommand } from "./commands/speech/index.js"; import { createTerminalCommand } from "./commands/terminal/index.js"; import { createWorktreeCommand } from "./commands/worktree/index.js"; +import { createWorkspaceCommand } from "./commands/workspace/index.js"; +import { createHeartbeatCommand } from "./commands/heartbeat/index.js"; +import { createHubCommand } from "./commands/hub/index.js"; import { createHooksCommand } from "./commands/hooks.js"; import { startCommand as daemonStartCommand } from "./commands/daemon/start.js"; import { runStatusCommand as runDaemonStatusCommand } from "./commands/daemon/status.js"; @@ -160,6 +163,7 @@ export function createCli(): Command { // Daemon commands program.addCommand(createDaemonCommand()); + program.addCommand(createHubCommand()); // Chat commands program.addCommand(createChatCommand()); @@ -172,6 +176,7 @@ export function createCli(): Command { // Schedule commands program.addCommand(createScheduleCommand()); + program.addCommand(createHeartbeatCommand()); // Permission commands program.addCommand(createPermitCommand()); @@ -182,8 +187,11 @@ export function createCli(): Command { // Speech model commands program.addCommand(createSpeechCommand()); - // Worktree commands - program.addCommand(createWorktreeCommand()); + // Workspace commands + program.addCommand(createWorkspaceCommand()); + // COMPAT(worktreeCli): legacy command alias added before workspace was the product unit. + // Added in v0.2.0; remove after 2027-01-17. + program.addCommand(createWorktreeCommand(), { hidden: true }); return program; } diff --git a/packages/cli/src/commands/agent/detach.ts b/packages/cli/src/commands/agent/detach.ts new file mode 100644 index 000000000..5de8ae8c9 --- /dev/null +++ b/packages/cli/src/commands/agent/detach.ts @@ -0,0 +1,58 @@ +import type { Command } from "commander"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js"; +import type { + CommandError, + CommandOptions, + OutputSchema, + SingleResult, +} from "../../output/index.js"; + +interface AgentDetachResult { + agentId: string; + status: "detached"; +} + +const detachSchema: OutputSchema = { + idField: "agentId", + columns: [ + { header: "AGENT ID", field: "agentId" }, + { header: "STATUS", field: "status" }, + ], +}; + +export async function runDetachCommand( + agentIdArg: string, + options: CommandOptions, + _command: Command, +): Promise> { + const host = getDaemonHost({ host: options.host }); + let client: DaemonClient; + try { + client = await connectToDaemon({ host: options.host }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw { + code: "DAEMON_NOT_RUNNING", + message: `Cannot connect to daemon at ${host}: ${message}`, + } satisfies CommandError; + } + + try { + const payload = await client.fetchAgents({ filter: { includeArchived: true } }); + const agentId = resolveAgentId( + agentIdArg, + payload.entries.map((entry) => entry.agent), + ); + if (!agentId) { + throw { + code: "AGENT_NOT_FOUND", + message: `Agent not found: ${agentIdArg}`, + } satisfies CommandError; + } + await client.detachAgent(agentId); + return { type: "single", data: { agentId, status: "detached" }, schema: detachSchema }; + } finally { + await client.close().catch(() => undefined); + } +} diff --git a/packages/cli/src/commands/agent/index.ts b/packages/cli/src/commands/agent/index.ts index 9c592d2ae..37d8f6949 100644 --- a/packages/cli/src/commands/agent/index.ts +++ b/packages/cli/src/commands/agent/index.ts @@ -13,6 +13,7 @@ import { addAttachOptions, runAttachCommand } from "./attach.js"; import { addReloadOptions, runReloadCommand } from "./reload.js"; import { addImportOptions, runImportCommand } from "./import.js"; import { runUpdateCommand } from "./update.js"; +import { runDetachCommand } from "./detach.js"; import { withOutput } from "../../output/index.js"; import { addDaemonHostOption, @@ -76,6 +77,13 @@ export function createAgentCommand(): Command { withOutput(runReloadCommand), ); + addJsonAndDaemonHostOptions( + agent + .command("detach") + .description("Make a subagent independent without stopping or moving it") + .argument("", "Agent ID, prefix, or name"), + ).action(withOutput(runDetachCommand)); + addJsonAndDaemonHostOptions( agent .command("update") diff --git a/packages/cli/src/commands/agent/run.test.ts b/packages/cli/src/commands/agent/run.test.ts index 65ea370ec..59081c99d 100644 --- a/packages/cli/src/commands/agent/run.test.ts +++ b/packages/cli/src/commands/agent/run.test.ts @@ -1,5 +1,52 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { runRunCommand, type AgentRunOptions } from "./run"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + resolveExistingRunWorkspace, + resolveRunCallerAgentId, + runRunCommand, + type AgentRunOptions, +} from "./run"; + +describe("managed agent caller context", () => { + it("propagates a trimmed PASEO_AGENT_ID", () => { + expect(resolveRunCallerAgentId({ PASEO_AGENT_ID: " parent-agent " })).toBe("parent-agent"); + }); + + it("omits blank caller ids", () => { + expect(resolveRunCallerAgentId({ PASEO_AGENT_ID: " " })).toBeUndefined(); + }); +}); + +describe("existing run workspace resolution", () => { + it("queries the daemon for an exact workspace id and uses its directory", async () => { + const fetchWorkspaces = vi.fn().mockResolvedValue({ + entries: [{ id: "workspace-2", workspaceDirectory: "/workspace/two" }], + pageInfo: { nextCursor: null }, + }); + + await expect(resolveExistingRunWorkspace({ fetchWorkspaces }, "workspace-2")).resolves.toEqual({ + id: "workspace-2", + cwd: "/workspace/two", + }); + expect(fetchWorkspaces).toHaveBeenCalledWith({ + filter: { query: "workspace-2" }, + page: { limit: 200 }, + }); + }); + + it("rejects a workspace id absent from daemon state", async () => { + const fetchWorkspaces = vi.fn().mockResolvedValue({ + entries: [], + pageInfo: { nextCursor: null }, + }); + + await expect(resolveExistingRunWorkspace({ fetchWorkspaces }, "missing")).rejects.toMatchObject( + { + code: "WORKSPACE_NOT_FOUND", + message: "Workspace not found: missing", + }, + ); + }); +}); // validateRunOptions runs before the CLI ever connects to a daemon, so these // invalid combinations reject without one running. @@ -25,27 +72,23 @@ describe("runRunCommand option validation", () => { }); } - it("rejects --worktree combined with --workspace", async () => { + it("rejects --isolation combined with --workspace", async () => { await expectInvalidOptions( - { worktree: "feat", workspace: "ws-1" }, - /--worktree and --workspace cannot be combined/, + { isolation: "worktree", workspace: "ws-1" }, + /--isolation and --workspace cannot be combined/, ); }); - it("rejects --worktree combined with an ambient PASEO_WORKSPACE_ID", async () => { - process.env.PASEO_WORKSPACE_ID = "ws-ambient"; - await expectInvalidOptions( - { worktree: "feat" }, - /--worktree cannot be combined with an ambient PASEO_WORKSPACE_ID/, - ); - }); - - it("allows a bare --worktree through validation when no workspace is selected", async () => { - // A bare --worktree with no --workspace and no ambient PASEO_WORKSPACE_ID + it("allows explicit worktree isolation through validation", async () => { + // Explicit isolation with no --workspace // must clear validation. It still fails later (provider resolution), which // is enough to prove the new guard did not reject it. await expect( - runRunCommand("do something", { worktree: "feat", provider: undefined }, {} as never), + runRunCommand("do something", { isolation: "worktree", provider: undefined }, {} as never), ).rejects.not.toMatchObject({ code: "INVALID_OPTIONS" }); }); + + it("rejects unknown workspace isolation", async () => { + await expectInvalidOptions({ isolation: "container" }, /Unsupported workspace isolation/); + }); }); diff --git a/packages/cli/src/commands/agent/run.ts b/packages/cli/src/commands/agent/run.ts index 60f30abc3..d283a6d10 100644 --- a/packages/cli/src/commands/agent/run.ts +++ b/packages/cli/src/commands/agent/run.ts @@ -18,55 +18,61 @@ import { resolveProviderAndModel } from "../../utils/provider-model.js"; export { resolveProviderAndModel } from "../../utils/provider-model.js"; export function addRunOptions(cmd: Command): Command { - return cmd - .description("Create and start an agent with a task") - .argument("", "The task/prompt for the agent") - .option("-d, --detach", "Run in background (detached)") - .option("--title ", "Assign a title to the agent") - .addOption(new Option("--name <name>", "Hidden alias for --title").hideHelp()) - .option( - "--provider <provider>", - "Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)", - ) - .option( - "--model <model>", - "Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)", - ) - .option("--thinking <id>", "Thinking option ID to use for this run") - .option("--mode <mode>", "Provider-specific mode (e.g., plan, default, bypass)") - .option("--worktree <name>", "Create agent in a new git worktree") - .option("--base <branch>", "Base branch for worktree (default: current branch)") - .option( - "--workspace <id>", - "Run in an existing workspace (default: a new workspace is created per run; falls back to $PASEO_WORKSPACE_ID)", - ) - .option( - "--image <path>", - "Attach image(s) to the initial prompt (can be used multiple times)", - collectMultiple, - [], - ) - .option("--cwd <path>", "Working directory (default: current)") - .option( - "--env <key=value>", - "Set environment variable(s) for the agent process (can be used multiple times)", - collectMultiple, - [], - ) - .option( - "--label <key=value>", - "Add label(s) to the agent (can be used multiple times)", - collectMultiple, - [], - ) - .option( - "--wait-timeout <duration>", - "Maximum time to wait for agent to finish (e.g., 30s, 5m, 1h). Default: no limit", - ) - .option( - "--output-schema <schema>", - "Output JSON matching the provided schema file path or inline JSON schema", - ); + return ( + cmd + .description("Create and start an agent with a task") + .argument("<prompt>", "The task/prompt for the agent") + .option("-d, --background", "Run in background") + // COMPAT(detachRunFlag): --detach used to mean background execution, not + // ownership transfer. Added in v0.2.0; remove after 2027-01-17. + .addOption(new Option("--detach", "Legacy alias for --background").hideHelp()) + .option("--title <title>", "Assign a title to the agent") + .addOption(new Option("--name <name>", "Hidden alias for --title").hideHelp()) + .option( + "--provider <provider>", + "Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)", + ) + .option( + "--model <model>", + "Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)", + ) + .option("--thinking <id>", "Thinking option ID to use for this run") + .option("--mode <mode>", "Provider-specific mode (e.g., plan, default, bypass)") + .option("--isolation <local|worktree>", "Create a new workspace with this isolation") + .addOption(new Option("--worktree <name>", "Legacy workspace isolation alias").hideHelp()) + .option("--base <branch>", "Base branch for an isolated workspace") + .option( + "--workspace <id>", + "Run in an existing workspace (default: a new workspace is created per run; falls back to $PASEO_WORKSPACE_ID)", + ) + .option( + "--image <path>", + "Attach image(s) to the initial prompt (can be used multiple times)", + collectMultiple, + [], + ) + .option("--cwd <path>", "Working directory (default: current)") + .option( + "--env <key=value>", + "Set environment variable(s) for the agent process (can be used multiple times)", + collectMultiple, + [], + ) + .option( + "--label <key=value>", + "Add label(s) to the agent (can be used multiple times)", + collectMultiple, + [], + ) + .option( + "--wait-timeout <duration>", + "Maximum time to wait for agent to finish (e.g., 30s, 5m, 1h). Default: no limit", + ) + .option( + "--output-schema <schema>", + "Output JSON matching the provided schema file path or inline JSON schema", + ) + ); } /** Result type for agent run command */ @@ -91,6 +97,7 @@ export const agentRunSchema: OutputSchema<AgentRunResult> = { }; export interface AgentRunOptions extends CommandOptions { + background?: boolean; detach?: boolean; title?: string; name?: string; @@ -98,6 +105,7 @@ export interface AgentRunOptions extends CommandOptions { model?: string; thinking?: string; mode?: string; + isolation?: string; worktree?: string; base?: string; workspace?: string; @@ -270,40 +278,54 @@ function validateRunOptions(prompt: string, options: AgentRunOptions, outputSche } satisfies CommandError; } - if (options.base && !options.worktree) { + const createsIsolatedWorkspace = options.isolation === "worktree" || Boolean(options.worktree); + if (options.isolation && options.isolation !== "local" && options.isolation !== "worktree") { throw { code: "INVALID_OPTIONS", - message: "--base can only be used with --worktree", - details: "Usage: paseo agent run --worktree <name> --base <branch> <prompt>", + message: `Unsupported workspace isolation: ${options.isolation}`, + details: "Use --isolation local or --isolation worktree", } satisfies CommandError; } + if (options.base && !createsIsolatedWorkspace) { + throw { + code: "INVALID_OPTIONS", + message: "--base can only be used with --isolation worktree", + details: "Usage: paseo agent run --isolation worktree --base <branch> <prompt>", + } satisfies CommandError; + } + + if (options.isolation && options.workspace) { + throw { + code: "INVALID_OPTIONS", + message: "--isolation and --workspace cannot be combined", + details: "Select an existing workspace or create a new one with an isolation choice", + } satisfies CommandError; + } + + // COMPAT(worktreeRunFlag): --worktree implies a new worktree-isolated workspace. + // Added in v0.2.0; remove after 2027-01-17. if (options.worktree && options.workspace) { throw { code: "INVALID_OPTIONS", message: "--worktree and --workspace cannot be combined", - details: "--workspace runs in an existing workspace; --worktree mints a new one", + details: "Use --isolation worktree instead of the legacy --worktree flag", } satisfies CommandError; } - if (options.worktree && !options.workspace && process.env.PASEO_WORKSPACE_ID) { + if (outputSchema && runsInBackground(options)) { throw { code: "INVALID_OPTIONS", - message: "--worktree cannot be combined with an ambient PASEO_WORKSPACE_ID", - details: - "PASEO_WORKSPACE_ID selects an existing workspace; --worktree mints a new one. Unset PASEO_WORKSPACE_ID to use --worktree.", - } satisfies CommandError; - } - - if (outputSchema && options.detach) { - throw { - code: "INVALID_OPTIONS", - message: "--output-schema cannot be used with --detach", + message: "--output-schema cannot be used with --background", details: "Structured output requires waiting for the agent to finish", } satisfies CommandError; } } +function runsInBackground(options: Pick<AgentRunOptions, "background" | "detach">): boolean { + return Boolean(options.background || options.detach); +} + function parseWaitTimeoutOption(waitTimeout: string | undefined): number { if (!waitTimeout) return 0; try { @@ -409,45 +431,79 @@ async function connectToDaemonOrThrow( // runs in. The CLI resolves one before creating any agent, so no run leans on // createAgent's legacy cwd->workspace fallback. interface RunWorkspace { - id: string; + id?: string; cwd: string; } +export interface RunWorkspaceLookupClient { + fetchWorkspaces(options: { filter: { query: string }; page: { limit: number } }): Promise<{ + entries: Array<{ id: string; workspaceDirectory: string }>; + pageInfo: { nextCursor: string | null }; + }>; +} + +export async function resolveExistingRunWorkspace( + client: RunWorkspaceLookupClient, + workspaceId: string, +): Promise<RunWorkspace> { + const result = await client.fetchWorkspaces({ + filter: { query: workspaceId }, + page: { limit: 200 }, + }); + const workspace = result.entries.find((entry) => entry.id === workspaceId); + if (workspace) { + return { id: workspace.id, cwd: workspace.workspaceDirectory }; + } + + throw { + code: "WORKSPACE_NOT_FOUND", + message: `Workspace not found: ${workspaceId}`, + } satisfies CommandError; +} + // Workspace policy for `paseo run`. Precedence: // 1. --workspace <id> -> run in that existing workspace -// 2. $PASEO_WORKSPACE_ID -> exported by workspace terminals -// 3. --worktree <name> -> mint a new worktree-backed workspace -// 4. bare run -> mint a new local-backed workspace for cwd -// --worktree is rejected alongside both --workspace and an ambient -// $PASEO_WORKSPACE_ID (validateRunOptions), so worktree resolution here never -// races an existing-workspace selection. +// 2. $PASEO_AGENT_ID -> daemon resolves the caller's workspace +// 3. $PASEO_WORKSPACE_ID -> exported by workspace terminals +// 4. --isolation <kind> -> mint a new workspace with explicit isolation +// 5. bare run -> mint a new local-backed workspace for cwd async function resolveRunWorkspace( client: ConnectedDaemonClient, options: AgentRunOptions, cwd: string, ): Promise<RunWorkspace> { - // An explicit --worktree mints its own workspace; --workspace and an ambient - // PASEO_WORKSPACE_ID are both rejected alongside --worktree upstream. - const explicit = options.worktree - ? undefined - : options.workspace?.trim() || process.env.PASEO_WORKSPACE_ID?.trim(); + const requestedIsolation = options.isolation ?? (options.worktree ? "worktree" : undefined); + const explicit = requestedIsolation ? undefined : options.workspace?.trim(); if (explicit) { console.error(`Using workspace ${explicit}`); - return { id: explicit, cwd }; + return resolveExistingRunWorkspace(client, explicit); + } + + if (!requestedIsolation && resolveRunCallerAgentId()) { + return { cwd }; + } + + const ambientWorkspaceId = requestedIsolation + ? undefined + : process.env.PASEO_WORKSPACE_ID?.trim(); + if (ambientWorkspaceId) { + console.error(`Using workspace ${ambientWorkspaceId}`); + return resolveExistingRunWorkspace(client, ambientWorkspaceId); } // TODO: thread the run `prompt` as firstAgentContext so workspace-level // title/branch generation picks up the task description (U8/U6 deferred). - const result = options.worktree - ? await client.createWorkspace({ - source: { - kind: "worktree", - cwd, - worktreeSlug: options.worktree, - baseBranch: options.base, - }, - }) - : await client.createWorkspace({ source: { kind: "directory", path: cwd } }); + const result = + requestedIsolation === "worktree" + ? await client.createWorkspace({ + source: { + kind: "worktree", + cwd, + worktreeSlug: options.worktree, + baseBranch: options.base, + }, + }) + : await client.createWorkspace({ source: { kind: "directory", path: cwd } }); if (!result.workspace) { throw { @@ -503,6 +559,7 @@ export async function runRunCommand( const workspace = await resolveRunWorkspace(client, options, cwd); const workspaceId = workspace.id; + const callerAgentId = resolveRunCallerAgentId(); const runCwd = workspace.cwd; if (outputSchema) { @@ -514,6 +571,7 @@ export async function runRunCommand( provider: resolvedProviderModel.provider, cwd: runCwd, workspaceId, + callerAgentId, title: resolvedTitle, modeId: options.mode, model: resolvedProviderModel.model, @@ -584,6 +642,7 @@ export async function runRunCommand( provider: resolvedProviderModel.provider, cwd: runCwd, workspaceId, + callerAgentId, title: resolvedTitle, modeId: options.mode, model: resolvedProviderModel.model, @@ -594,8 +653,8 @@ export async function runRunCommand( labels: Object.keys(labels).length > 0 ? labels : undefined, }); - // Default run behavior is foreground: wait for completion unless --detach is set. - if (!options.detach) { + // Default run behavior is foreground: wait for completion unless background execution is set. + if (!runsInBackground(options)) { const state = await client.waitForFinish(agent.id, waitTimeoutMs); await client.close(); @@ -631,3 +690,9 @@ export async function runRunCommand( throw error; } } + +export function resolveRunCallerAgentId( + env: { PASEO_AGENT_ID?: string } = process.env, +): string | undefined { + return env.PASEO_AGENT_ID?.trim() || undefined; +} diff --git a/packages/cli/src/commands/heartbeat/index.ts b/packages/cli/src/commands/heartbeat/index.ts new file mode 100644 index 000000000..7ded358e8 --- /dev/null +++ b/packages/cli/src/commands/heartbeat/index.ts @@ -0,0 +1,180 @@ +import { Command } from "commander"; +import type { CommandOptions, OutputSchema, SingleResult } from "../../output/index.js"; +import { withOutput } from "../../output/index.js"; +import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; +import { parseDuration } from "../../utils/duration.js"; +import { + connectScheduleClient, + toScheduleCommandError, + toScheduleRow, + type ScheduleRow, +} from "../schedule/shared.js"; +import { scheduleSchema } from "../schedule/schema.js"; + +interface HeartbeatOptions extends CommandOptions { + cron?: string; + timezone?: string; + name?: string; + maxRuns?: string; + expiresIn?: string; +} + +interface HeartbeatDeleteRow { + id: string; + status: "deleted"; +} + +const heartbeatDeleteSchema: OutputSchema<HeartbeatDeleteRow> = { + idField: "id", + columns: [ + { header: "ID", field: "id" }, + { header: "STATUS", field: "status" }, + ], +}; + +function requireCallerAgentId(): string { + const agentId = process.env.PASEO_AGENT_ID?.trim(); + if (!agentId) { + throw new Error("Heartbeat commands must run inside a Paseo agent"); + } + return agentId; +} + +async function requireOwnedHeartbeat( + client: Awaited<ReturnType<typeof connectScheduleClient>>["client"], + id: string, + agentId: string, +): Promise<void> { + const payload = await client.scheduleInspect({ id }); + if (payload.error || !payload.schedule) { + throw new Error(payload.error ?? `Heartbeat not found: ${id}`); + } + if (payload.schedule.target.type !== "agent" || payload.schedule.target.agentId !== agentId) { + throw new Error(`Heartbeat ${id} does not belong to agent ${agentId}`); + } +} + +async function runCreateHeartbeat( + prompt: string, + options: HeartbeatOptions, + _command: Command, +): Promise<SingleResult<ScheduleRow>> { + const agentId = requireCallerAgentId(); + const cron = options.cron?.trim(); + if (!cron) { + throw new Error("--cron is required"); + } + const { client } = await connectScheduleClient(options.host); + try { + const maxRuns = options.maxRuns ? Number.parseInt(options.maxRuns, 10) : undefined; + if (maxRuns !== undefined && (!Number.isSafeInteger(maxRuns) || maxRuns <= 0)) { + throw new Error("--max-runs must be a positive integer"); + } + const payload = await client.scheduleCreate({ + prompt: prompt.trim(), + cadence: { + type: "cron", + expression: cron, + ...(options.timezone?.trim() ? { timezone: options.timezone.trim() } : {}), + }, + target: { type: "agent", agentId }, + ...(options.name?.trim() ? { name: options.name.trim() } : {}), + ...(maxRuns ? { maxRuns } : {}), + ...(options.expiresIn + ? { expiresAt: new Date(Date.now() + parseDuration(options.expiresIn)).toISOString() } + : {}), + }); + if (payload.error || !payload.schedule) { + throw new Error(payload.error ?? "Heartbeat creation failed"); + } + return { type: "single", data: toScheduleRow(payload.schedule), schema: scheduleSchema }; + } catch (error) { + throw toScheduleCommandError("HEARTBEAT_CREATE_FAILED", "create heartbeat", error); + } finally { + await client.close().catch(() => undefined); + } +} + +async function runUpdateHeartbeat( + id: string, + options: HeartbeatOptions, + _command: Command, +): Promise<SingleResult<ScheduleRow>> { + const agentId = requireCallerAgentId(); + const cron = options.cron?.trim(); + if (!cron) { + throw new Error("--cron is required"); + } + const { client } = await connectScheduleClient(options.host); + try { + await requireOwnedHeartbeat(client, id, agentId); + const payload = await client.scheduleUpdate({ + id, + cadence: { + type: "cron", + expression: cron, + ...(options.timezone?.trim() ? { timezone: options.timezone.trim() } : {}), + }, + }); + if (payload.error || !payload.schedule) { + throw new Error(payload.error ?? `Heartbeat update failed: ${id}`); + } + return { type: "single", data: toScheduleRow(payload.schedule), schema: scheduleSchema }; + } catch (error) { + throw toScheduleCommandError("HEARTBEAT_UPDATE_FAILED", "update heartbeat", error); + } finally { + await client.close().catch(() => undefined); + } +} + +async function runDeleteHeartbeat( + id: string, + options: HeartbeatOptions, + _command: Command, +): Promise<SingleResult<HeartbeatDeleteRow>> { + const agentId = requireCallerAgentId(); + const { client } = await connectScheduleClient(options.host); + try { + await requireOwnedHeartbeat(client, id, agentId); + const payload = await client.scheduleDelete({ id }); + if (payload.error) { + throw new Error(payload.error); + } + return { + type: "single", + data: { id: payload.scheduleId, status: "deleted" }, + schema: heartbeatDeleteSchema, + }; + } catch (error) { + throw toScheduleCommandError("HEARTBEAT_DELETE_FAILED", "delete heartbeat", error); + } finally { + await client.close().catch(() => undefined); + } +} + +export function createHeartbeatCommand(): Command { + const heartbeat = new Command("heartbeat").description("Manage this agent's heartbeats"); + addJsonAndDaemonHostOptions( + heartbeat + .command("create") + .description("Create a recurring prompt for this agent") + .argument("<prompt>", "Prompt to send") + .requiredOption("--cron <expr>", "Five-field cron cadence") + .option("--timezone <iana>", "IANA time zone") + .option("--name <name>", "Heartbeat name") + .option("--max-runs <n>", "Maximum number of runs") + .option("--expires-in <duration>", "Time to live"), + ).action(withOutput(runCreateHeartbeat)); + addJsonAndDaemonHostOptions( + heartbeat + .command("update") + .description("Change a heartbeat cron cadence") + .argument("<id>", "Heartbeat ID") + .requiredOption("--cron <expr>", "Five-field cron cadence") + .option("--timezone <iana>", "IANA time zone"), + ).action(withOutput(runUpdateHeartbeat)); + addJsonAndDaemonHostOptions( + heartbeat.command("delete").description("Delete a heartbeat").argument("<id>", "Heartbeat ID"), + ).action(withOutput(runDeleteHeartbeat)); + return heartbeat; +} diff --git a/packages/cli/src/commands/hub/index.ts b/packages/cli/src/commands/hub/index.ts new file mode 100644 index 000000000..fce9f1c65 --- /dev/null +++ b/packages/cli/src/commands/hub/index.ts @@ -0,0 +1,104 @@ +import { Command } from "commander"; +import { withOutput, type ListResult, type OutputSchema } from "../../output/index.js"; +import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; +import { connectToDaemon } from "../../utils/client.js"; + +interface HubRow { + state: string; + daemonId: string | null; + hub: string | null; + scopes: string; + connectedAt: string | null; + error: string | null; + warning?: string; +} + +const schema: OutputSchema<HubRow> = { + idField: "state", + columns: [ + { header: "STATE", field: "state" }, + { header: "HUB", field: "hub" }, + { header: "DAEMON", field: "daemonId" }, + { header: "SCOPES", field: "scopes" }, + { header: "CONNECTED", field: "connectedAt" }, + { header: "ERROR", field: "error" }, + { header: "WARNING", field: "warning" }, + ], +}; + +function result( + status: { + state: string; + daemonId: string | null; + hubOrigin: string | null; + scopes: string[]; + connectedAt: string | null; + lastError: string | null; + }, + warning?: string, +): ListResult<HubRow> { + return { + type: "list", + data: [ + { + state: status.state, + daemonId: status.daemonId, + hub: status.hubOrigin, + scopes: status.scopes.join(", "), + connectedAt: status.connectedAt, + error: status.lastError, + warning, + }, + ], + schema, + }; +} + +async function withClient<T>( + host: string | undefined, + action: (client: Awaited<ReturnType<typeof connectToDaemon>>) => Promise<T>, +): Promise<T> { + const client = await connectToDaemon({ host }); + try { + return await action(client); + } finally { + await client.close().catch(() => undefined); + } +} + +export function createHubCommand(): Command { + const hub = new Command("hub").description("Manage this daemon's Paseo Hub relationship"); + addJsonAndDaemonHostOptions( + hub.command("connect").argument("<url>").requiredOption("--token <token>"), + ).action( + withOutput(async (...args) => { + const url = args[0] as string; + const options = args.at(-2) as { token: string; host?: string }; + return withClient(options.host, async (client) => + result((await client.connectHub(url, options.token)).status), + ); + }), + ); + addJsonAndDaemonHostOptions(hub.command("status")).action( + withOutput(async (...args) => { + const options = args.at(-2) as { host?: string }; + return withClient(options.host, async (client) => + result((await client.getHubStatus()).status), + ); + }), + ); + addJsonAndDaemonHostOptions( + hub + .command("disconnect") + .option("--force", "Remove local authority even if the Hub is offline"), + ).action( + withOutput(async (...args) => { + const options = args.at(-2) as { host?: string; force?: boolean }; + return withClient(options.host, async (client) => { + const response = await client.disconnectHub(options.force ?? false); + return result(response.status, response.warning); + }); + }), + ); + return hub; +} diff --git a/packages/cli/src/commands/schedule/index.ts b/packages/cli/src/commands/schedule/index.ts index 5087db18a..f72a59b19 100644 --- a/packages/cli/src/commands/schedule/index.ts +++ b/packages/cli/src/commands/schedule/index.ts @@ -1,4 +1,4 @@ -import { Command } from "commander"; +import { Command, Option } from "commander"; import { withOutput } from "../../output/index.js"; import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; import { runCreateCommand } from "./create.js"; @@ -19,11 +19,11 @@ export function createScheduleCommand(): Command { .command("create") .description("Create a schedule") .argument("<prompt>", "Prompt to run on the schedule") - .option("--every <duration>", "Fixed interval cadence (for example: 5m, 1h)") + .option("--every <duration>", "Cron-compatible cadence preset (for example: 5m, 1h)") .option("--cron <expr>", "Cron cadence expression") .option("--timezone <iana>", "IANA time zone for cron cadence (default: UTC)") .option("--name <name>", "Optional schedule name") - .option("--target <self|new-agent|agent-id>", "Run target") + .addOption(new Option("--target <target>", "Legacy schedule target").hideHelp()) .option( "--provider <provider>", "Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)", @@ -33,8 +33,7 @@ export function createScheduleCommand(): Command { "Provider-specific mode (e.g. claude bypassPermissions, opencode build)", ) .option("--cwd <path>", "Working directory (default: current; required with --host)") - .option("--run-now", "Fire one immediate run on creation (only with --cron)") - .option("--no-run-now", "Wait the full interval before the first run (only with --every)") + .option("--run-now", "Fire one immediate run on creation") .option("--max-runs <n>", "Maximum number of runs") .option("--expires-in <duration>", "Time to live for the schedule"), ).action(withOutput(runCreateCommand)); @@ -81,7 +80,7 @@ export function createScheduleCommand(): Command { .command("update") .description("Update an existing schedule in place") .argument("<id>", "Schedule ID") - .option("--every <duration>", "Switch to fixed interval cadence (for example: 5m, 1h)") + .option("--every <duration>", "Cron-compatible cadence preset (for example: 5m, 1h)") .option("--cron <expr>", "Switch to cron cadence expression") .option("--timezone <iana>", "IANA time zone for cron cadence (requires --cron)") .option("--name <name>", "Rename the schedule (empty string clears the name)") diff --git a/packages/cli/src/commands/schedule/inspect.ts b/packages/cli/src/commands/schedule/inspect.ts index d9044382f..d020f2408 100644 --- a/packages/cli/src/commands/schedule/inspect.ts +++ b/packages/cli/src/commands/schedule/inspect.ts @@ -22,6 +22,9 @@ export async function runInspectCommand( if (payload.error || !payload.schedule) { throw new Error(payload.error ?? `Schedule not found: ${id}`); } + if (payload.schedule.target.type !== "new-agent") { + throw new Error(`Schedule not found: ${id}`); + } const rows = createScheduleInspectRows(payload.schedule); return { type: "list", diff --git a/packages/cli/src/commands/schedule/logs.ts b/packages/cli/src/commands/schedule/logs.ts index b411822d9..c9565b5c0 100644 --- a/packages/cli/src/commands/schedule/logs.ts +++ b/packages/cli/src/commands/schedule/logs.ts @@ -3,6 +3,7 @@ import type { ListResult } from "../../output/index.js"; import { scheduleLogSchema, toScheduleLogRow, type ScheduleLogRow } from "./schema.js"; import { connectScheduleClient, + requireNewAgentSchedule, toScheduleCommandError, type ScheduleCommandOptions, } from "./shared.js"; @@ -14,6 +15,7 @@ export async function runLogsCommand( ): Promise<ListResult<ScheduleLogRow>> { const { client } = await connectScheduleClient(options.host); try { + await requireNewAgentSchedule(client, id); const payload = await client.scheduleLogs({ id }); if (payload.error) { throw new Error(payload.error); diff --git a/packages/cli/src/commands/schedule/ls.ts b/packages/cli/src/commands/schedule/ls.ts index da2c0dfbc..501c86653 100644 --- a/packages/cli/src/commands/schedule/ls.ts +++ b/packages/cli/src/commands/schedule/ls.ts @@ -21,7 +21,9 @@ export async function runLsCommand( } return { type: "list", - data: payload.schedules.map(toScheduleRow), + data: payload.schedules + .filter((schedule) => schedule.target.type === "new-agent") + .map(toScheduleRow), schema: scheduleSchema, }; } catch (error) { diff --git a/packages/cli/src/commands/schedule/pause.ts b/packages/cli/src/commands/schedule/pause.ts index 97e5914a5..ed8e12108 100644 --- a/packages/cli/src/commands/schedule/pause.ts +++ b/packages/cli/src/commands/schedule/pause.ts @@ -3,6 +3,7 @@ import type { SingleResult } from "../../output/index.js"; import { scheduleSchema } from "./schema.js"; import { connectScheduleClient, + requireNewAgentSchedule, toScheduleCommandError, toScheduleRow, type ScheduleCommandOptions, @@ -16,6 +17,7 @@ export async function runPauseCommand( ): Promise<SingleResult<ScheduleRow>> { const { client } = await connectScheduleClient(options.host); try { + await requireNewAgentSchedule(client, id); const payload = await client.schedulePause({ id }); if (payload.error || !payload.schedule) { throw new Error(payload.error ?? `Failed to pause schedule: ${id}`); diff --git a/packages/cli/src/commands/schedule/resume.ts b/packages/cli/src/commands/schedule/resume.ts index 88d4853dc..efac2d634 100644 --- a/packages/cli/src/commands/schedule/resume.ts +++ b/packages/cli/src/commands/schedule/resume.ts @@ -3,6 +3,7 @@ import type { SingleResult } from "../../output/index.js"; import { scheduleSchema } from "./schema.js"; import { connectScheduleClient, + requireNewAgentSchedule, toScheduleCommandError, toScheduleRow, type ScheduleCommandOptions, @@ -16,6 +17,7 @@ export async function runResumeCommand( ): Promise<SingleResult<ScheduleRow>> { const { client } = await connectScheduleClient(options.host); try { + await requireNewAgentSchedule(client, id); const payload = await client.scheduleResume({ id }); if (payload.error || !payload.schedule) { throw new Error(payload.error ?? `Failed to resume schedule: ${id}`); diff --git a/packages/cli/src/commands/schedule/run-once.ts b/packages/cli/src/commands/schedule/run-once.ts index 705e9548f..2818afc5d 100644 --- a/packages/cli/src/commands/schedule/run-once.ts +++ b/packages/cli/src/commands/schedule/run-once.ts @@ -3,6 +3,7 @@ import type { SingleResult } from "../../output/index.js"; import { scheduleSchema } from "./schema.js"; import { connectScheduleClient, + requireNewAgentSchedule, toScheduleCommandError, toScheduleRow, type ScheduleCommandOptions, @@ -16,6 +17,7 @@ export async function runRunOnceCommand( ): Promise<SingleResult<ScheduleRow>> { const { client } = await connectScheduleClient(options.host); try { + await requireNewAgentSchedule(client, id); const payload = await client.scheduleRunOnce({ id }); if (payload.error || !payload.schedule) { throw new Error(payload.error ?? `Failed to run schedule once: ${id}`); diff --git a/packages/cli/src/commands/schedule/shared.test.ts b/packages/cli/src/commands/schedule/shared.test.ts index e483e71aa..d1684e7ac 100644 --- a/packages/cli/src/commands/schedule/shared.test.ts +++ b/packages/cli/src/commands/schedule/shared.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { parseScheduleCreateInput, parseScheduleUpdateInput } from "./shared.js"; +import { + compileEveryPresetToCron, + parseScheduleCreateInput, + parseScheduleUpdateInput, +} from "./shared.js"; const baseOptions = { prompt: "do the thing", @@ -68,13 +72,9 @@ describe("parseScheduleCreateInput cwd/host validation", () => { }); describe("parseScheduleCreateInput first-run timing", () => { - test("--every with no run-now flag fires immediately on creation", () => { + test("--every compiles to cron and waits for the next slot", () => { const input = parseScheduleCreateInput(baseOptions); - expect(input.runOnCreate).toBe(true); - }); - - test("--every with --no-run-now waits the interval", () => { - const input = parseScheduleCreateInput({ ...baseOptions, runNow: false }); + expect(input.cadence).toEqual({ type: "cron", expression: "*/5 * * * *" }); expect(input.runOnCreate).toBe(false); }); @@ -101,22 +101,9 @@ describe("parseScheduleCreateInput first-run timing", () => { }); }); - test("--every with --run-now is rejected as redundant", () => { - expect(() => parseScheduleCreateInput({ ...baseOptions, runNow: true })).toThrow( - expect.objectContaining({ - code: "REDUNDANT_RUN_NOW", - message: expect.stringContaining("--run-now is redundant with --every"), - }), - ); - }); - - test("--cron with --no-run-now is rejected as redundant", () => { - expect(() => parseScheduleCreateInput({ ...baseCron, runNow: false })).toThrow( - expect.objectContaining({ - code: "REDUNDANT_NO_RUN_NOW", - message: expect.stringContaining("--no-run-now is redundant with --cron"), - }), - ); + test("--run-now is orthogonal to a compiled preset", () => { + const input = parseScheduleCreateInput({ ...baseOptions, runNow: true }); + expect(input.runOnCreate).toBe(true); }); test("--timezone without --cron is rejected", () => { @@ -159,10 +146,10 @@ describe("parseScheduleUpdateInput", () => { ); }); - test("parses --every cadence", () => { + test("compiles --every cadence to cron", () => { expect(parseScheduleUpdateInput({ id: "abc", every: "5m" })).toEqual({ id: "abc", - cadence: { type: "every", everyMs: 5 * 60_000 }, + cadence: { type: "cron", expression: "*/5 * * * *" }, }); }); @@ -271,3 +258,25 @@ describe("parseScheduleUpdateInput", () => { ).toThrow(expect.objectContaining({ code: "CONFLICTING_EXPIRES" })); }); }); + +describe("compileEveryPresetToCron", () => { + test.each([ + ["1m", "*/1 * * * *"], + ["15m", "*/15 * * * *"], + ["1h", "0 * * * *"], + ["6h", "0 */6 * * *"], + ["1d", "0 0 * * *"], + ])("compiles %s", (value, expected) => { + expect(compileEveryPresetToCron(value)).toBe(expected); + }); + + test.each(["30s", "7m", "5h", "2d"])("rejects rolling interval %s", (value) => { + expect(() => compileEveryPresetToCron(value)).toThrow( + expect.objectContaining({ code: "UNREPRESENTABLE_CADENCE" }), + ); + }); + + test.each(["15minutes", "junk15m", "1h-nope"])("rejects malformed preset %s", (value) => { + expect(() => compileEveryPresetToCron(value)).toThrow("Invalid duration format"); + }); +}); diff --git a/packages/cli/src/commands/schedule/shared.ts b/packages/cli/src/commands/schedule/shared.ts index 643037c06..f70677db4 100644 --- a/packages/cli/src/commands/schedule/shared.ts +++ b/packages/cli/src/commands/schedule/shared.ts @@ -12,6 +12,7 @@ import type { } from "./types.js"; import { parseDuration } from "../../utils/duration.js"; import { resolveProviderAndModel } from "../../utils/provider-model.js"; +import { everyMsToFiveFieldCron } from "@getpaseo/protocol/schedule/cadence"; export interface ScheduleCommandOptions extends CommandOptions { host?: string; @@ -47,6 +48,16 @@ export function toScheduleCommandError(code: string, action: string, error: unkn }; } +export async function requireNewAgentSchedule( + client: ScheduleDaemonClient, + id: string, +): Promise<void> { + const payload = await client.scheduleInspect({ id }); + if (payload.error || !payload.schedule || payload.schedule.target.type !== "new-agent") { + throw new Error(payload.error ?? `Schedule not found: ${id}`); + } +} + export function formatCadence(cadence: ScheduleCadence): string { if (cadence.type === "cron") { const timezoneSuffix = cadence.timezone ? ` (${cadence.timezone})` : ""; @@ -92,12 +103,7 @@ function resolveScheduleTarget(args: { createNewAgentTarget: () => ScheduleTarget; }): ScheduleTarget { const { targetValue, hasExplicitNewAgentOption, createNewAgentTarget } = args; - const currentAgentId = process.env.PASEO_AGENT_ID?.trim(); - if (!targetValue) { - if (currentAgentId && !hasExplicitNewAgentOption) { - return { type: "self", agentId: currentAgentId }; - } return createNewAgentTarget(); } @@ -114,6 +120,9 @@ function resolveScheduleTarget(args: { } if (targetValue === "self") { + // COMPAT(scheduleSelfTarget): heartbeat creation moved to `paseo heartbeat create`. + // Added in v0.2.0; remove after 2027-01-17. + const currentAgentId = process.env.PASEO_AGENT_ID?.trim(); if (!currentAgentId) { throw { code: "INVALID_TARGET", @@ -211,23 +220,9 @@ export function parseScheduleCreateInput(options: { function resolveRunOnCreate( runNow: boolean | undefined, - cadenceType: ScheduleCadence["type"], + _cadenceType: ScheduleCadence["type"], ): boolean { - if (runNow === true && cadenceType === "every") { - throw { - code: "REDUNDANT_RUN_NOW", - message: "--run-now is redundant with --every (interval schedules already fire on creation)", - details: "Drop --run-now, or use --no-run-now to wait the full interval before the first run", - } satisfies CommandError; - } - if (runNow === false && cadenceType === "cron") { - throw { - code: "REDUNDANT_NO_RUN_NOW", - message: "--no-run-now is redundant with --cron (cron schedules never fire on creation)", - details: "Drop --no-run-now, or use --run-now to fire one immediate run on creation", - } satisfies CommandError; - } - return runNow ?? cadenceType === "every"; + return runNow ?? false; } export interface ScheduleUpdateOptionsInput { @@ -307,7 +302,7 @@ function parseCadenceFromFlags( } satisfies CommandError; } if (every !== undefined) { - return { type: "every", everyMs: parseDuration(every) }; + return { type: "cron", expression: compileEveryPresetToCron(every) }; } if (cron !== undefined) { return { @@ -319,6 +314,20 @@ function parseCadenceFromFlags( return undefined; } +export function compileEveryPresetToCron(value: string): string { + const durationMs = parseDuration(value); + const cron = everyMsToFiveFieldCron(durationMs); + if (cron) { + return cron; + } + + throw { + code: "UNREPRESENTABLE_CADENCE", + message: `${value} cannot be represented faithfully by five-field cron`, + details: "Use --cron for calendar schedules", + } satisfies CommandError; +} + function parseTimeZoneFlag(timeZone: string | undefined): string | undefined { if (timeZone === undefined) { return undefined; diff --git a/packages/cli/src/commands/schedule/update.ts b/packages/cli/src/commands/schedule/update.ts index 5b62590fd..30873853c 100644 --- a/packages/cli/src/commands/schedule/update.ts +++ b/packages/cli/src/commands/schedule/update.ts @@ -8,6 +8,7 @@ import { import { connectScheduleClient, parseScheduleUpdateInput, + requireNewAgentSchedule, toScheduleCommandError, type ScheduleCommandOptions, } from "./shared.js"; @@ -51,6 +52,7 @@ export async function runUpdateCommand( }); const { client } = await connectScheduleClient(options.host); try { + await requireNewAgentSchedule(client, id); const payload = await client.scheduleUpdate(input); if (payload.error || !payload.schedule) { throw new Error(payload.error ?? `Failed to update schedule: ${id}`); diff --git a/packages/cli/src/commands/workspace/archive.ts b/packages/cli/src/commands/workspace/archive.ts new file mode 100644 index 000000000..958d0451d --- /dev/null +++ b/packages/cli/src/commands/workspace/archive.ts @@ -0,0 +1,52 @@ +import type { Command } from "commander"; +import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import type { CommandError, OutputSchema, SingleResult } from "../../output/index.js"; + +interface WorkspaceArchiveResult { + workspaceId: string; + status: "archived"; + archivedAt: string; +} + +const workspaceArchiveSchema: OutputSchema<WorkspaceArchiveResult> = { + idField: "workspaceId", + columns: [ + { header: "WORKSPACE ID", field: "workspaceId", width: 20 }, + { header: "STATUS", field: "status", width: 10 }, + { header: "ARCHIVED AT", field: "archivedAt", width: 26 }, + ], +}; + +export async function runArchiveCommand( + workspaceId: string, + options: { host?: string }, + _command: Command, +): Promise<SingleResult<WorkspaceArchiveResult>> { + const host = getDaemonHost({ host: options.host }); + const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + throw { + code: "DAEMON_NOT_RUNNING", + message: `Cannot connect to daemon at ${host}: ${message}`, + } satisfies CommandError; + }); + try { + const payload = await client.archiveWorkspace(workspaceId); + if (payload.error) { + throw new Error(payload.error); + } + if (!payload.archivedAt) { + throw new Error("Workspace archive did not return an archive timestamp"); + } + return { + type: "single", + data: { workspaceId, status: "archived", archivedAt: payload.archivedAt }, + schema: workspaceArchiveSchema, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw { code: "WORKSPACE_ARCHIVE_FAILED", message } satisfies CommandError; + } finally { + await client.close().catch(() => undefined); + } +} diff --git a/packages/cli/src/commands/workspace/create.test.ts b/packages/cli/src/commands/workspace/create.test.ts new file mode 100644 index 000000000..62bfcdc6f --- /dev/null +++ b/packages/cli/src/commands/workspace/create.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { buildWorkspaceSource } from "./create.js"; + +describe("workspace create source", () => { + it("maps local isolation to a directory workspace", () => { + expect( + buildWorkspaceSource({ isolation: "local", path: "/tmp/project", project: "project-1" }), + ).toEqual({ kind: "directory", path: "/tmp/project", projectId: "project-1" }); + }); + + it("keeps branch names separate from managed worktree slugs", () => { + expect( + buildWorkspaceSource({ + isolation: "worktree", + path: "/tmp/project", + mode: "branch-off", + newBranch: "feature/auth", + worktreeSlug: "feature-auth", + base: "main", + }), + ).toEqual({ + kind: "worktree", + cwd: "/tmp/project", + action: "branch-off", + branchName: "feature/auth", + worktreeSlug: "feature-auth", + baseBranch: "main", + }); + }); + + it("uses a project as the worktree source without capturing the ambient directory", () => { + expect( + buildWorkspaceSource({ + isolation: "worktree", + project: "project-1", + mode: "branch-off", + newBranch: "fix-x", + }), + ).toEqual({ + kind: "worktree", + projectId: "project-1", + action: "branch-off", + branchName: "fix-x", + }); + }); + + it("checks out an existing branch into a worktree workspace", () => { + expect( + buildWorkspaceSource({ + isolation: "worktree", + path: "/tmp/project", + mode: "checkout-branch", + branch: "existing-work", + worktreeSlug: "existing-work-copy", + }), + ).toEqual({ + kind: "worktree", + cwd: "/tmp/project", + action: "checkout", + refName: "existing-work", + worktreeSlug: "existing-work-copy", + }); + }); + + it("checks out a pull request into a worktree workspace", () => { + expect( + buildWorkspaceSource({ + isolation: "worktree", + path: "/tmp/project", + mode: "checkout-pr", + prNumber: "42", + forge: "gitlab", + }), + ).toEqual({ + kind: "worktree", + cwd: "/tmp/project", + action: "checkout", + checkoutSource: { + kind: "change_request", + forge: "gitlab", + number: 42, + }, + }); + }); + + it("lets the source checkout select the forge when it is omitted", () => { + expect( + buildWorkspaceSource({ + isolation: "worktree", + path: "/tmp/project", + mode: "checkout-pr", + prNumber: "42", + }), + ).toEqual({ + kind: "worktree", + cwd: "/tmp/project", + action: "checkout", + checkoutSource: { + kind: "change_request", + number: 42, + }, + }); + }); + + it("requires the mode-specific checkout target", () => { + expect(() => buildWorkspaceSource({ isolation: "worktree", mode: "checkout-branch" })).toThrow( + "--branch is required", + ); + expect(() => buildWorkspaceSource({ isolation: "worktree", mode: "checkout-pr" })).toThrow( + "--pr-number is required", + ); + }); + + it("rejects worktree options for local isolation", () => { + expect(() => buildWorkspaceSource({ isolation: "local", mode: "branch-off" })).toThrow( + "Worktree options require --isolation worktree", + ); + }); + + it("rejects unknown isolation", () => { + expect(() => buildWorkspaceSource({ isolation: "container" })).toThrow( + "Unsupported workspace isolation", + ); + }); +}); diff --git a/packages/cli/src/commands/workspace/create.ts b/packages/cli/src/commands/workspace/create.ts new file mode 100644 index 000000000..078eefb06 --- /dev/null +++ b/packages/cli/src/commands/workspace/create.ts @@ -0,0 +1,161 @@ +import type { Command } from "commander"; +import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import type { CommandError, CommandOptions, SingleResult } from "../../output/index.js"; +import { toWorkspaceRow, workspaceSchema, type WorkspaceRow } from "./shared.js"; + +export interface WorkspaceCreateOptions extends CommandOptions { + isolation?: string; + path?: string; + project?: string; + title?: string; + mode?: string; + worktreeSlug?: string; + newBranch?: string; + base?: string; + branch?: string; + prNumber?: string; + forge?: string; +} + +interface WorktreeSourceBase { + kind: "worktree"; + cwd?: string; + projectId?: string; + worktreeSlug?: string; +} + +function assertOptionsAbsent(values: unknown[], message: string): void { + if (values.some((value) => value !== undefined)) { + throw new Error(message); + } +} + +function buildLocalWorkspaceSource(options: WorkspaceCreateOptions, path: string) { + assertOptionsAbsent( + [ + options.mode, + options.worktreeSlug, + options.newBranch, + options.base, + options.branch, + options.prNumber, + options.forge, + ], + "Worktree options require --isolation worktree", + ); + return { + kind: "directory" as const, + path, + ...(options.project ? { projectId: options.project } : {}), + }; +} + +function buildBranchOffSource(options: WorkspaceCreateOptions, source: WorktreeSourceBase) { + assertOptionsAbsent( + [options.branch, options.prNumber, options.forge], + "--branch, --pr-number, and --forge require a checkout mode", + ); + return { + ...source, + action: "branch-off" as const, + ...(options.newBranch ? { branchName: options.newBranch } : {}), + ...(options.base ? { baseBranch: options.base } : {}), + }; +} + +function buildBranchCheckoutSource(options: WorkspaceCreateOptions, source: WorktreeSourceBase) { + if (!options.branch) { + throw new Error("--branch is required for --mode checkout-branch"); + } + assertOptionsAbsent( + [options.newBranch, options.base, options.prNumber, options.forge], + "--new-branch, --base, --pr-number, and --forge are not valid for --mode checkout-branch", + ); + return { ...source, action: "checkout" as const, refName: options.branch }; +} + +function buildPullRequestCheckoutSource( + options: WorkspaceCreateOptions, + source: WorktreeSourceBase, +) { + if (options.prNumber === undefined || options.prNumber === "") { + throw new Error("--pr-number is required for --mode checkout-pr"); + } + const prNumber = Number(options.prNumber); + if (!Number.isInteger(prNumber) || prNumber <= 0) { + throw new Error("--pr-number must be a positive integer"); + } + assertOptionsAbsent( + [options.newBranch, options.base, options.branch], + "--new-branch, --base, and --branch are not valid for --mode checkout-pr", + ); + return { + ...source, + action: "checkout" as const, + checkoutSource: { + kind: "change_request" as const, + ...(options.forge ? { forge: options.forge } : {}), + number: prNumber, + }, + }; +} + +function buildWorktreeWorkspaceSource(options: WorkspaceCreateOptions, path: string | undefined) { + const source: WorktreeSourceBase = { + kind: "worktree", + ...(path ? { cwd: path } : {}), + ...(options.project ? { projectId: options.project } : {}), + ...(options.worktreeSlug ? { worktreeSlug: options.worktreeSlug } : {}), + }; + switch (options.mode ?? "branch-off") { + case "branch-off": + return buildBranchOffSource(options, source); + case "checkout-branch": + return buildBranchCheckoutSource(options, source); + case "checkout-pr": + return buildPullRequestCheckoutSource(options, source); + default: + throw new Error(`Unsupported worktree mode: ${String(options.mode)}`); + } +} + +export function buildWorkspaceSource(options: WorkspaceCreateOptions) { + if (options.isolation === "local") { + return buildLocalWorkspaceSource(options, options.path ?? process.cwd()); + } + if (options.isolation === "worktree") { + const sourcePath = options.path ?? (options.project ? undefined : process.cwd()); + return buildWorktreeWorkspaceSource(options, sourcePath); + } + throw new Error(`Unsupported workspace isolation: ${String(options.isolation)}`); +} + +export async function runCreateCommand( + options: WorkspaceCreateOptions, + _command: Command, +): Promise<SingleResult<WorkspaceRow>> { + const host = getDaemonHost({ host: options.host }); + const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + throw { + code: "DAEMON_NOT_RUNNING", + message: `Cannot connect to daemon at ${host}: ${message}`, + } satisfies CommandError; + }); + + try { + const payload = await client.createWorkspace({ + source: buildWorkspaceSource(options), + ...(options.title ? { title: options.title } : {}), + }); + if (!payload.workspace) { + throw new Error(payload.error ?? "Workspace creation failed"); + } + return { type: "single", data: toWorkspaceRow(payload.workspace), schema: workspaceSchema }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw { code: "WORKSPACE_CREATE_FAILED", message } satisfies CommandError; + } finally { + await client.close().catch(() => undefined); + } +} diff --git a/packages/cli/src/commands/workspace/index.ts b/packages/cli/src/commands/workspace/index.ts new file mode 100644 index 000000000..27d7131dc --- /dev/null +++ b/packages/cli/src/commands/workspace/index.ts @@ -0,0 +1,43 @@ +import { Command } from "commander"; +import { withOutput } from "../../output/index.js"; +import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; +import { runArchiveCommand } from "./archive.js"; +import { runCreateCommand } from "./create.js"; +import { runLsCommand } from "./ls.js"; + +export function createWorkspaceCommand(): Command { + const workspace = new Command("workspace").description("Manage workspaces"); + + addJsonAndDaemonHostOptions( + workspace + .command("create") + .description("Create a workspace") + .requiredOption("--isolation <local|worktree>", "Workspace isolation") + .option("--path <path>", "Local directory or source checkout (default: current)") + .option("--project <id>", "Existing project id") + .option("--title <title>", "Workspace title") + .option( + "--mode <mode>", + "Worktree mode: branch-off, checkout-branch, or checkout-pr (default: branch-off)", + ) + .option("--worktree-slug <slug>", "Managed worktree path slug") + .option("--new-branch <name>", "New branch name (--mode branch-off)") + .option("--base <ref>", "Base ref (--mode branch-off)") + .option("--branch <name>", "Existing branch (--mode checkout-branch)") + .option("--pr-number <n>", "Pull request or change request number (--mode checkout-pr)") + .option("--forge <forge>", "Forge for --mode checkout-pr (default: source checkout)"), + ).action(withOutput(runCreateCommand)); + + addJsonAndDaemonHostOptions(workspace.command("ls").description("List active workspaces")).action( + withOutput(runLsCommand), + ); + + addJsonAndDaemonHostOptions( + workspace + .command("archive") + .description("Archive a workspace and everything it owns") + .argument("<workspace-id>", "Workspace id"), + ).action(withOutput(runArchiveCommand)); + + return workspace; +} diff --git a/packages/cli/src/commands/workspace/ls.ts b/packages/cli/src/commands/workspace/ls.ts new file mode 100644 index 000000000..7ea9e8f56 --- /dev/null +++ b/packages/cli/src/commands/workspace/ls.ts @@ -0,0 +1,32 @@ +import type { Command } from "commander"; +import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import type { CommandError, ListResult } from "../../output/index.js"; +import { toWorkspaceRow, workspaceSchema, type WorkspaceRow } from "./shared.js"; + +export async function runLsCommand( + options: { host?: string }, + _command: Command, +): Promise<ListResult<WorkspaceRow>> { + const host = getDaemonHost({ host: options.host }); + const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + throw { + code: "DAEMON_NOT_RUNNING", + message: `Cannot connect to daemon at ${host}: ${message}`, + } satisfies CommandError; + }); + try { + const workspaces: WorkspaceRow[] = []; + let cursor: string | undefined; + do { + const payload = await client.fetchWorkspaces({ + page: { limit: 200, ...(cursor ? { cursor } : {}) }, + }); + workspaces.push(...payload.entries.map(toWorkspaceRow)); + cursor = payload.pageInfo.nextCursor ?? undefined; + } while (cursor); + return { type: "list", data: workspaces, schema: workspaceSchema }; + } finally { + await client.close().catch(() => undefined); + } +} diff --git a/packages/cli/src/commands/workspace/shared.ts b/packages/cli/src/commands/workspace/shared.ts new file mode 100644 index 000000000..cd800c9f1 --- /dev/null +++ b/packages/cli/src/commands/workspace/shared.ts @@ -0,0 +1,31 @@ +import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages"; +import type { OutputSchema } from "../../output/index.js"; + +export interface WorkspaceRow { + workspaceId: string; + project: string; + name: string; + isolation: "local" | "worktree"; + cwd: string; +} + +export const workspaceSchema: OutputSchema<WorkspaceRow> = { + idField: "workspaceId", + columns: [ + { header: "WORKSPACE ID", field: "workspaceId", width: 20 }, + { header: "PROJECT", field: "project", width: 20 }, + { header: "NAME", field: "name", width: 22 }, + { header: "ISOLATION", field: "isolation", width: 10 }, + { header: "CWD", field: "cwd", width: 42 }, + ], +}; + +export function toWorkspaceRow(workspace: WorkspaceDescriptorPayload): WorkspaceRow { + return { + workspaceId: workspace.id, + project: workspace.projectDisplayName, + name: workspace.name, + isolation: workspace.workspaceKind === "worktree" ? "worktree" : "local", + cwd: workspace.workspaceDirectory, + }; +} diff --git a/packages/cli/src/utils/duration.ts b/packages/cli/src/utils/duration.ts index 1041917fd..0fe1dc274 100644 --- a/packages/cli/src/utils/duration.ts +++ b/packages/cli/src/utils/duration.ts @@ -1,6 +1,6 @@ /** * Parse duration string to milliseconds. - * Supports formats like: 5m, 30s, 1h, 2h30m, 90, etc. + * Supports formats like: 5m, 30s, 1h, 2h30m, 1d, 90, etc. * If no unit is specified, assumes seconds. */ export function parseDuration(input: string): number { @@ -11,14 +11,16 @@ export function parseDuration(input: string): number { return parseInt(trimmed, 10) * 1000; } + if (!/^(?:\d+[smhd])+$/.test(trimmed)) { + throw new Error(`Invalid duration format: ${input}. Use formats like: 5m, 30s, 1h, 2h30m, 1d`); + } + // Parse duration with units let totalMs = 0; - const regex = /(\d+)([smh])/g; + const regex = /(\d+)([smhd])/g; let match; - let hasMatch = false; while ((match = regex.exec(trimmed)) !== null) { - hasMatch = true; const value = parseInt(match[1], 10); const unit = match[2]; @@ -32,12 +34,11 @@ export function parseDuration(input: string): number { case "h": totalMs += value * 60 * 60 * 1000; break; + case "d": + totalMs += value * 24 * 60 * 60 * 1000; + break; } } - if (!hasMatch) { - throw new Error(`Invalid duration format: ${input}. Use formats like: 5m, 30s, 1h, 2h30m`); - } - return totalMs; } diff --git a/packages/cli/tests/05-agent-run.test.ts b/packages/cli/tests/05-agent-run.test.ts index 5b1fb97c4..1c2700a50 100644 --- a/packages/cli/tests/05-agent-run.test.ts +++ b/packages/cli/tests/05-agent-run.test.ts @@ -53,7 +53,8 @@ try { const result = await $`npx paseo run --help`.nothrow(); assert.strictEqual(result.exitCode, 0, "run --help should exit 0"); assert(result.stdout.includes("-d"), "help should mention -d flag"); - assert(result.stdout.includes("--detach"), "help should mention --detach flag"); + assert(result.stdout.includes("--background"), "help should mention --background flag"); + assert(!result.stdout.includes("--detach"), "help should hide legacy --detach syntax"); assert(result.stdout.includes("--title"), "help should mention --title option"); assert(result.stdout.includes("--provider"), "help should mention --provider option"); assert(result.stdout.includes("--mode"), "help should mention --mode option"); @@ -174,18 +175,18 @@ try { console.log("✓ run --output-schema flag is accepted\n"); } - // Test 10: run --output-schema cannot be used with --detach + // Test 10: run --output-schema cannot be used with --background { - console.log("Test 10: run --output-schema cannot be used with --detach"); + console.log("Test 10: run --output-schema cannot be used with --background"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run -d --output-schema ${schemaPath} "test prompt"`.nothrow(); - assert.notStrictEqual(result.exitCode, 0, "should fail with --detach and --output-schema"); + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --background --output-schema ${schemaPath} "test prompt"`.nothrow(); + assert.notStrictEqual(result.exitCode, 0, "should fail with --background and --output-schema"); const output = result.stdout + result.stderr; assert( - output.includes("--output-schema cannot be used with --detach"), - "error should explain detach incompatibility", + output.includes("--output-schema cannot be used with --background"), + "error should explain background incompatibility", ); - console.log("✓ run --output-schema cannot be used with --detach\n"); + console.log("✓ run --output-schema cannot be used with --background\n"); } // Test 11: -q (quiet) flag is accepted with run diff --git a/packages/cli/tests/14-worktree.test.ts b/packages/cli/tests/14-worktree.test.ts index 6e9289326..d1b30a541 100644 --- a/packages/cli/tests/14-worktree.test.ts +++ b/packages/cli/tests/14-worktree.test.ts @@ -153,13 +153,13 @@ try { console.log("✓ --json flag is accepted with worktree ls\n"); } - // Test 11: paseo --help shows worktree subcommand + // Test 11: paseo --help keeps the compatibility command hidden { - console.log("Test 11: paseo --help shows worktree subcommand"); + console.log("Test 11: paseo --help hides worktree compatibility command"); const result = await $`npx paseo --help`.nothrow(); assert.strictEqual(result.exitCode, 0, "paseo --help should exit 0"); - assert(result.stdout.includes("worktree"), "help should mention worktree subcommand"); - console.log("✓ paseo --help shows worktree subcommand\n"); + assert(!result.stdout.includes("worktree"), "help should not advertise worktree subcommand"); + console.log("✓ paseo --help hides worktree compatibility command\n"); } } finally { // Clean up temp directory diff --git a/packages/cli/tests/31-loop-schedule.test.ts b/packages/cli/tests/31-loop-schedule.test.ts index f5513bdcd..ae070f8fc 100644 --- a/packages/cli/tests/31-loop-schedule.test.ts +++ b/packages/cli/tests/31-loop-schedule.test.ts @@ -53,7 +53,7 @@ try { assert.strictEqual(created.exitCode, 0, created.stderr); const createdJson = JSON.parse(created.stdout); assert.strictEqual(createdJson.name, "review-prs"); - assert.strictEqual(createdJson.cadence, "every:5m"); + assert.strictEqual(createdJson.cadence, "cron:*/5 * * * *"); assert( typeof createdJson.target === "string" && (createdJson.target.startsWith("agent:") || createdJson.target === "new-agent:claude"), @@ -144,6 +144,31 @@ try { console.log("schedule rejects provider with self target\n"); } + { + console.log("Test 1d: compatibility agent-target schedules remain deletable"); + const created = await ctx.paseo( + [ + "schedule", + "create", + "Legacy heartbeat", + "--cron", + "0 0 1 1 *", + "--target", + "00000000-0000-4000-8000-000000000001", + "--json", + ], + { timeout: 30000 }, + ); + assert.strictEqual(created.exitCode, 0, created.stderr); + const createdJson = JSON.parse(created.stdout); + assert.strictEqual(createdJson.target, "agent:0000000"); + + const deleted = await ctx.paseo(["schedule", "delete", createdJson.id, "--json"]); + assert.strictEqual(deleted.exitCode, 0, deleted.stderr); + assert.strictEqual(JSON.parse(deleted.stdout).id, createdJson.id); + console.log("compatibility agent-target schedules remain deletable\n"); + } + { console.log("Test 2: loop run/ls/inspect/logs/stop work"); const run = await ctx.paseo( diff --git a/packages/client/package.json b/packages/client/package.json index 97e0ddb31..76795855d 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.1.110", + "version": "0.2.0-beta.1", "description": "Paseo client SDK package", "files": [ "dist", @@ -39,8 +39,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.1.110", - "@getpaseo/relay": "0.1.110", + "@getpaseo/protocol": "0.2.0-beta.1", + "@getpaseo/relay": "0.2.0-beta.1", "ws": "^8.20.0", "zod": "^4.4.3" }, diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 3b3183862..b1d6bfcf9 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -76,7 +76,7 @@ function createMockTransport() { return { transport, sent, - triggerOpen: (options?: { preserveSent?: boolean }) => { + triggerOpen: (options?: { preserveSent?: boolean; features?: Record<string, boolean> }) => { onOpen(); if (!options?.preserveSent) { // Ignore HELLO handshake payloads in assertions. @@ -92,6 +92,7 @@ function createMockTransport() { serverId: `srv_test_${serverInfoOrdinal++}`, hostname: null, version: null, + ...(options?.features ? { features: options.features } : {}), }, }, }), @@ -185,6 +186,7 @@ test("does not infer browser automation capabilities from Electron runtime", asy }) .parse(JSON.parse(assertStr(mock.sent[0]))); expect(hello.capabilities[CLIENT_CAPS.browserHost]).toBeUndefined(); + expect(hello.capabilities[CLIENT_CAPS.selectiveAgentTimeline]).toBeUndefined(); }); test("advertises consumer-provided browser automation capabilities", async () => { @@ -219,6 +221,114 @@ test("advertises consumer-provided browser automation capabilities", async () => }); }); +test("Hub management requires daemon support before dispatching requests", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "hub_feature_gate_unit_test", + transportFactory: () => mock.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + mock.triggerOpen(); + await connecting; + + await expect(client.getHubStatus()).rejects.toThrow( + "Update the host to use Hub relationship management.", + ); + expect(mock.sent).toEqual([]); +}); + +test("sets the complete viewed timeline subscription only when the daemon supports it", async () => { + const supportedTransport = createMockTransport(); + const supportedClient = new DaemonClient({ + url: "ws://test", + clientId: "timeline_supported", + transportFactory: () => supportedTransport.transport, + reconnect: { enabled: false }, + }); + const legacyTransport = createMockTransport(); + const legacyClient = new DaemonClient({ + url: "ws://test", + clientId: "timeline_legacy", + transportFactory: () => legacyTransport.transport, + reconnect: { enabled: false }, + }); + clients.push(supportedClient, legacyClient); + + const supportedConnect = supportedClient.connect(); + supportedTransport.triggerOpen({ features: { selectiveAgentTimeline: true } }); + await supportedConnect; + const legacyConnect = legacyClient.connect(); + legacyTransport.triggerOpen(); + await legacyConnect; + + expect(supportedClient.getLastServerInfoMessage()?.features).toEqual({ + selectiveAgentTimeline: true, + }); + + const setPromise = supportedClient.setAgentTimelineSubscription(["agent-b", "agent-a"]); + await Promise.resolve(); + const request = parseSentFrame(supportedTransport.sent[0]); + supportedTransport.triggerMessage( + wrapSessionMessage({ + type: "agent.timeline.set_subscription.response", + payload: { + requestId: request.requestId, + agentIds: ["agent-a", "agent-b"], + }, + }), + ); + await setPromise; + await legacyClient.setAgentTimelineSubscription(["agent-a"]); + + expect({ request, legacyFrames: legacyTransport.sent }).toEqual({ + request: { + type: "agent.timeline.set_subscription.request", + requestId: expect.any(String), + agentIds: ["agent-a", "agent-b"], + }, + legacyFrames: [], + }); +}); + +test("normalizes legacy and dedicated agent attention notifications", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "attention_normalization", + transportFactory: () => mock.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connect = client.connect(); + mock.triggerOpen(); + await connect; + const notifications: unknown[] = []; + client.onAgentAttentionRequired((notification) => notifications.push(notification)); + const payload = { + agentId: "agent-a", + reason: "finished", + timestamp: "2026-07-12T00:00:00.000Z", + shouldNotify: true, + } as const; + + mock.triggerMessage( + wrapSessionMessage({ + type: "agent_stream", + payload: { + agentId: payload.agentId, + timestamp: payload.timestamp, + event: { type: "attention_required", provider: "codex", ...payload }, + }, + }), + ); + mock.triggerMessage(wrapSessionMessage({ type: "agent_attention_required", payload })); + + expect(notifications).toEqual([payload, payload]); +}); + const noopLogger: Logger = { debug: () => {}, info: () => {}, @@ -555,6 +665,7 @@ test("advertises client capabilities in hello", async () => { protocolVersion: 1, capabilities: { custom_mode_icons: true, + project_updates: true, provider_subagents: true, reasoning_merge_enum: true, terminal_reflowable_snapshot: true, @@ -566,6 +677,32 @@ test("advertises client capabilities in hello", async () => { }); }); +test("allows callers to disable default client capabilities", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_capability_override_test", + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + capabilities: { + [CLIENT_CAPS.projectUpdates]: false, + }, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen({ preserveSent: true }); + await connectPromise; + + const hello = z + .object({ + type: z.literal("hello"), + capabilities: z.record(z.unknown()), + }) + .parse(JSON.parse(assertStr(mock.sent[0]))); + expect(hello.capabilities[CLIENT_CAPS.projectUpdates]).toBe(false); +}); + test("sends new-agent run options when creating schedules", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -586,7 +723,7 @@ test("sends new-agent run options when creating schedules", async () => { const createPromise = client.scheduleCreate({ requestId: "request-1", prompt: "Run the task", - cadence: { type: "every", everyMs: 60_000 }, + cadence: { type: "cron", expression: "* * * * *" }, target: { type: "new-agent", config: { @@ -604,7 +741,7 @@ test("sends new-agent run options when creating schedules", async () => { type: "schedule/create", requestId: "request-1", prompt: "Run the task", - cadence: { type: "every", everyMs: 60_000 }, + cadence: { type: "cron", expression: "* * * * *" }, target: { type: "new-agent", config: { @@ -1854,7 +1991,7 @@ test("normalizes workspace_setup_progress into a workspace-scoped daemon event", }); }); -test("sends create_agent_request with string workspace ids", async () => { +test("sends create_agent_request with workspace and caller identity", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -1875,6 +2012,7 @@ test("sends create_agent_request with string workspace ids", async () => { provider: "codex", cwd: "/tmp/project/.paseo/worktrees/feature-a", workspaceId: "ws-feature-a", + callerAgentId: "parent-agent", title: "Compat agent", modeId: "default", }); @@ -1885,6 +2023,7 @@ test("sends create_agent_request with string workspace ids", async () => { expect.objectContaining({ type: "create_agent_request", workspaceId: "ws-feature-a", + callerAgentId: "parent-agent", }), ); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 33a801b35..c0ecc10c4 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1,5 +1,6 @@ import type { z } from "zod"; import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; +import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification"; import { AgentCreateFailedStatusPayloadSchema, AgentCreatedStatusPayloadSchema, @@ -248,6 +249,10 @@ export type DaemonEvent = workspaceId: string; payload: Extract<SessionOutboundMessage, { type: "workspace_update" }>["payload"]; } + | { + type: "project.update"; + payload: Extract<SessionOutboundMessage, { type: "project.update" }>["payload"]; + } | { type: "workspace_setup_progress"; workspaceId: string; @@ -317,6 +322,14 @@ export interface SendMessageOptions { attachments?: SendAgentMessageRequest["attachments"]; } +export interface AgentAttentionRequiredNotification { + agentId: string; + reason: "finished" | "error" | "permission"; + timestamp: string; + shouldNotify: boolean; + notification?: AgentAttentionNotificationPayload; +} + type AgentConfigOverrides = Partial<Omit<AgentSessionConfig, "provider" | "cwd">>; export interface CreateAgentRequestOptions extends AgentConfigOverrides { @@ -325,6 +338,7 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides { cwd?: string; env?: CreateAgentRequestMessage["env"]; workspaceId?: string; + callerAgentId?: string; initialPrompt?: string; clientMessageId?: string; outputSchema?: Record<string, unknown>; @@ -333,6 +347,8 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides { git?: GitSetupOptions; worktree?: CreateAgentRequestMessage["worktree"]; autoArchive?: CreateAgentRequestMessage["autoArchive"]; + // COMPAT(createAgentWorktree): low-level old callers may still send the + // create-agent worktree field. Added in v0.2.0; remove after 2027-01-17. worktreeName?: string; requestId?: string; labels?: Record<string, string>; @@ -726,16 +742,11 @@ export interface StopLoopOptions { export interface CreateScheduleOptions { prompt: string; name?: string | null; - cadence: - | { - type: "every"; - everyMs: number; - } - | { - type: "cron"; - expression: string; - timezone?: string; - }; + cadence: { + type: "cron"; + expression: string; + timezone?: string; + }; target: | { type: "self"; @@ -787,16 +798,11 @@ export interface UpdateScheduleOptions { id: string; name?: string | null; prompt?: string; - cadence?: - | { - type: "every"; - everyMs: number; - } - | { - type: "cron"; - expression: string; - timezone?: string; - }; + cadence?: { + type: "cron"; + expression: string; + timezone?: string; + }; newAgentConfig?: UpdateScheduleNewAgentConfig; maxRuns?: number | null; expiresAt?: string | null; @@ -1478,6 +1484,31 @@ export class DaemonClient { }; } + onAgentAttentionRequired( + handler: (notification: AgentAttentionRequiredNotification) => void, + ): () => void { + const unsubscribeLegacy = this.on("agent_stream", (message) => { + if (message.payload.event.type !== "attention_required") { + return; + } + const event = message.payload.event; + handler({ + agentId: message.payload.agentId, + reason: event.reason, + timestamp: event.timestamp, + shouldNotify: event.shouldNotify, + ...(event.notification ? { notification: event.notification } : {}), + }); + }); + const unsubscribeDedicated = this.on("agent_attention_required", (message) => { + handler(message.payload); + }); + return () => { + unsubscribeLegacy(); + unsubscribeDedicated(); + }; + } + // ============================================================================ // Core Send Helpers // ============================================================================ @@ -2252,6 +2283,7 @@ export class DaemonClient { config, ...(options.env ? { env: options.env } : {}), ...(options.workspaceId !== undefined ? { workspaceId: options.workspaceId } : {}), + ...(options.callerAgentId !== undefined ? { callerAgentId: options.callerAgentId } : {}), ...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}), ...(options.clientMessageId ? { clientMessageId: options.clientMessageId } : {}), ...(options.outputSchema ? { outputSchema: options.outputSchema } : {}), @@ -2687,6 +2719,35 @@ export class DaemonClient { return payload; } + async setAgentTimelineSubscription(agentIds: string[]): Promise<void> { + // COMPAT(selectiveAgentTimeline): added in v0.1.106. Old daemons keep their + // legacy global stream and do not understand this RPC. Remove after + // 2027-01-12 once the supported daemon floor is >= v0.1.106. + if (!this.lastServerInfoMessage?.features?.selectiveAgentTimeline) { + return; + } + + const requestId = this.createRequestId(); + const normalizedAgentIds = [...new Set(agentIds)].sort(); + const message = SessionInboundMessageSchema.parse({ + type: "agent.timeline.set_subscription.request", + agentIds: normalizedAgentIds, + requestId, + }); + + await this.sendRequest({ + requestId, + message, + options: { skipQueue: true }, + select: (response) => { + if (response.type !== "agent.timeline.set_subscription.response") { + return null; + } + return response.payload.requestId === requestId ? response.payload : null; + }, + }); + } + async buildAgentForkContext( agentId: string, options: AgentForkContextOptions = {}, @@ -4202,6 +4263,33 @@ export class DaemonClient { }); } + async connectHub(hubUrl: string, token: string, requestId?: string) { + this.requireHubRelationshipSupport(); + return this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "hub.management.daemon.connect.request", hubUrl, token }, + responseType: "hub.management.daemon.connect.response", + }); + } + + async getHubStatus(requestId?: string) { + this.requireHubRelationshipSupport(); + return this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "hub.management.daemon.get_status.request" }, + responseType: "hub.management.daemon.get_status.response", + }); + } + + async disconnectHub(force = false, requestId?: string) { + this.requireHubRelationshipSupport(); + return this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "hub.management.daemon.disconnect.request", force }, + responseType: "hub.management.daemon.disconnect.response", + }); + } + async getDaemonPairingOffer( options?: DaemonPairingOfferOptions, ): Promise<DaemonPairingOfferPayload> { @@ -5022,6 +5110,13 @@ export class DaemonClient { return this.lastServerInfoMessage; } + private requireHubRelationshipSupport(): void { + // COMPAT(hubRelationship): added in v0.1.X, drop the gate when floor >= v0.1.X. + if (this.lastServerInfoMessage?.features?.hubRelationship !== true) { + throw new Error("Update the host to use Hub relationship management."); + } + } + private resolveTransportUrlForAttempt(): string { return this.config.url; } @@ -5048,6 +5143,7 @@ export class DaemonClient { [CLIENT_CAPS.reasoningMergeEnum]: true, [CLIENT_CAPS.terminalReflowableSnapshot]: true, [CLIENT_CAPS.providerSubagents]: true, + [CLIENT_CAPS.projectUpdates]: true, ...this.config.capabilities, }, ...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}), @@ -5518,6 +5614,8 @@ export class DaemonClient { workspaceId: msg.payload.kind === "upsert" ? msg.payload.workspace.id : msg.payload.id, payload: msg.payload, }; + case "project.update": + return { type: "project.update", payload: msg.payload }; case "workspace_setup_progress": return { type: "workspace_setup_progress", diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index fe9cc1998..d7f21f124 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -168,13 +168,13 @@ export interface PaseoAgentCreateOptions extends PaseoAgentConfigOverrides { provider?: CreateAgentRequestMessage["config"]["provider"]; cwd?: string; workspaceId?: string; + callerAgentId?: string; initialPrompt?: string; clientMessageId?: string; outputSchema?: Record<string, unknown>; images?: CreateAgentRequestMessage["images"]; attachments?: CreateAgentRequestMessage["attachments"]; git?: CreateAgentRequestMessage["git"]; - worktreeName?: string; requestId?: string; labels?: Record<string, string>; } diff --git a/packages/desktop/package.json b/packages/desktop/package.json index cd7026f35..4ad8c0f4d 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.1.110", + "version": "0.2.0-beta.1", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", @@ -21,6 +21,7 @@ "test:migration-electron": "npm --prefix ../.. run test:e2e:migration-electron --workspace=@getpaseo/app", "dev": "./scripts/dev.sh", "dev:win": "powershell ./scripts/dev.ps1", + "test:e2e:browser-tab-bridge": "npm run build:main && node ./scripts/browser-tab-bridge.e2e.mjs", "verify:electron-cdp": "node ./scripts/verify-electron-cdp.mjs", "test": "vitest run", "typecheck": "tsgo --noEmit -p tsconfig.json" diff --git a/packages/desktop/scripts/browser-tab-bridge.e2e.mjs b/packages/desktop/scripts/browser-tab-bridge.e2e.mjs new file mode 100644 index 000000000..139a186e6 --- /dev/null +++ b/packages/desktop/scripts/browser-tab-bridge.e2e.mjs @@ -0,0 +1,472 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import { createServer } from "node:http"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { setTimeout as delay } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; +import { experimental_createMCPClient } from "ai"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { chromium } from "playwright"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const desktopDir = path.resolve(scriptDir, ".."); +const rootDir = path.resolve(desktopDir, "../.."); +const devRunner = path.join(desktopDir, "scripts", "dev-runner.mjs"); +const workspaceIds = [ + "tab-bridge-original", + "tab-bridge-evict-one", + "tab-bridge-evict-two", + "tab-bridge-evict-three", +]; +const timeoutMs = 90_000; + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +async function reservePort() { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + server.close((error) => { + if (error) return reject(error); + if (!address || typeof address === "string") { + return reject(new Error("Failed to reserve a local port")); + } + resolve(address.port); + }); + }); + }); +} + +async function waitForPort(port, label, processInfo) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if ( + processInfo && + (processInfo.child.exitCode !== null || processInfo.child.signalCode !== null) + ) { + throw new Error( + `${label} process exited before opening its port; see ${processInfo.logPath}`, + ); + } + const connected = await new Promise((resolve) => { + const socket = net.createConnection({ host: "127.0.0.1", port }); + socket.setTimeout(500); + socket.once("connect", () => { + socket.destroy(); + resolve(true); + }); + socket.once("timeout", () => { + socket.destroy(); + resolve(false); + }); + socket.once("error", () => resolve(false)); + }); + if (connected) return; + await delay(200); + } + throw new Error(`Timed out waiting for ${label} on port ${port}`); +} + +function writeJson(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function seedPaseoHome(paseoHome, listen, workspaceRoot) { + const timestamp = "2026-01-01T00:00:00.000Z"; + const projects = workspaceIds.map((workspaceId, index) => { + const cwd = path.join(workspaceRoot, `workspace-${index + 1}`); + fs.mkdirSync(cwd, { recursive: true }); + return { + projectId: `project-${workspaceId}`, + rootPath: cwd, + kind: "non_git", + displayName: `Tab bridge project ${index + 1}`, + customName: null, + createdAt: timestamp, + updatedAt: timestamp, + archivedAt: null, + }; + }); + const workspaces = workspaceIds.map((workspaceId, index) => ({ + workspaceId, + projectId: projects[index].projectId, + cwd: projects[index].rootPath, + kind: "directory", + displayName: `Tab bridge workspace ${index + 1}`, + title: `Tab bridge workspace ${index + 1}`, + branch: null, + baseBranch: null, + createdAt: timestamp, + updatedAt: timestamp, + archivedAt: null, + pinnedAt: null, + })); + + writeJson(path.join(paseoHome, "config.json"), { + version: 1, + daemon: { + listen, + relay: { enabled: false }, + mcp: { enabled: true, injectIntoAgents: false }, + browserTools: { enabled: true }, + cors: { allowedOrigins: ["*"] }, + }, + }); + writeJson(path.join(paseoHome, "projects", "projects.json"), projects); + writeJson(path.join(paseoHome, "projects", "workspaces.json"), workspaces); +} + +function spawnLogged(name, command, args, options, logDir) { + const logPath = path.join(logDir, `${name}.log`); + const log = fs.createWriteStream(logPath, { flags: "a" }); + const child = spawn(command, args, { + ...options, + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"], + }); + let openStreams = 2; + const closeLogStream = () => { + openStreams -= 1; + if (openStreams === 0) log.end(); + }; + child.stdout.pipe(log, { end: false }); + child.stderr.pipe(log, { end: false }); + child.stdout.once("end", closeLogStream); + child.stderr.once("end", closeLogStream); + return { child, logPath }; +} + +function stopProcess(child) { + if (!child?.pid || child.exitCode !== null) return; + try { + if (process.platform === "win32") child.kill("SIGTERM"); + else process.kill(-child.pid, "SIGTERM"); + } catch { + // The process may exit between the liveness check and signal delivery. + } +} + +async function waitForAppPage(browser, expoPort) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + for (const context of browser.contexts()) { + for (const page of context.pages()) { + if (page.url().includes(`localhost:${expoPort}`)) return page; + } + } + await delay(250); + } + throw new Error("Timed out waiting for the real Electron app renderer"); +} + +async function waitForDesktopStatus(page) { + const deadline = Date.now() + timeoutMs; + let lastError; + while (Date.now() < deadline) { + try { + const status = await page.evaluate(async () => { + if (typeof window.paseoDesktop?.invoke !== "function") return null; + return await window.paseoDesktop.invoke("desktop_daemon_status"); + }); + if (typeof status?.serverId === "string") return status; + } catch (error) { + // Metro may replace the renderer execution context during its initial load. + lastError = error; + } + await delay(250); + } + throw new Error( + `Timed out waiting for the Electron desktop bridge${lastError ? `: ${String(lastError)}` : ""}`, + ); +} + +async function startTargetPage() { + const server = createServer((_request, response) => { + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + response.end(`<!doctype html> + <html> + <head><title>Tab bridge target + + + + `); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + assert(address && typeof address !== "string", "Target page did not bind a TCP port"); + return { server, url: `http://127.0.0.1:${address.port}/` }; +} + +async function closeServer(server) { + await new Promise((resolve) => server.close(resolve)); +} + +function mcpPayload(result, command) { + const payload = result.structuredContent; + assert( + payload && typeof payload === "object", + `${command} returned no structured payload: ${JSON.stringify(result)}`, + ); + assert(payload.ok === true, `${command} failed: ${JSON.stringify(payload)}`); + return payload.result; +} + +async function callBrowserTool(client, name, args = {}) { + return mcpPayload(await client.callTool({ name, args }), name); +} + +async function createCallerAgent(daemonPort) { + const transport = new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${daemonPort}/mcp/agents`), + ); + const client = await experimental_createMCPClient({ transport }); + try { + const response = await client.callTool({ + name: "create_agent", + args: { + relationship: { kind: "detached" }, + workspace: { kind: "existing", workspaceId: workspaceIds[0] }, + title: "Browser tab bridge E2E caller", + provider: "mock/ten-second-stream", + settings: { modeId: "load-test" }, + initialPrompt: "Remain available while the browser bridge regression runs.", + background: true, + }, + }); + const result = response.structuredContent; + assert( + result && typeof result === "object", + `create_agent returned no structured payload: ${JSON.stringify(response)}`, + ); + assert(typeof result.agentId === "string", "create_agent returned no caller agent id"); + assert( + result.workspaceId === workspaceIds[0], + `MCP caller attached to unexpected workspace ${result.workspaceId}`, + ); + return result.agentId; + } finally { + await client.close(); + } +} + +async function readGuest(page, browserId) { + return await page.evaluate((id) => { + const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`); + if (!(webview instanceof HTMLElement) || typeof webview.getWebContentsId !== "function") { + return null; + } + return { + webContentsId: webview.getWebContentsId(), + parentId: webview.parentElement?.id ?? null, + }; + }, browserId); +} + +async function runRegression({ page, client, serverId, targetUrl }) { + const originalWorkspaceId = workspaceIds[0]; + const originalWorkspaceRow = page.getByTestId( + `sidebar-workspace-row-${serverId}:${originalWorkspaceId}`, + ); + await originalWorkspaceRow.waitFor({ state: "visible", timeout: timeoutMs }); + await originalWorkspaceRow.click(); + + const created = await callBrowserTool(client, "browser_new_tab", { url: targetUrl }); + const browserId = created.browserId; + assert(typeof browserId === "string", "browser_new_tab returned no browserId"); + + const originalDeck = page.getByTestId(`workspace-deck-entry-${serverId}:${originalWorkspaceId}`); + await originalDeck.getByTestId(`workspace-tab-browser_${browserId}`).click(); + await page.waitForFunction( + (id) => { + const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`); + return webview && webview.parentElement?.id !== "paseo-browser-resident-webviews"; + }, + browserId, + { timeout: timeoutMs }, + ); + const firstGuest = await readGuest(page, browserId); + assert(firstGuest, "Original browser guest was not attached to its workspace pane"); + + for (const workspaceId of workspaceIds.slice(1)) { + await page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).click(); + await page + .getByTestId(`workspace-deck-entry-${serverId}:${workspaceId}`) + .waitFor({ state: "visible" }); + } + + await page.waitForFunction( + ({ id, previousWebContentsId }) => { + const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`); + return ( + webview?.parentElement?.id === "paseo-browser-resident-webviews" && + typeof webview.getWebContentsId === "function" && + webview.getWebContentsId() !== previousWebContentsId + ); + }, + { id: browserId, previousWebContentsId: firstGuest.webContentsId }, + { timeout: timeoutMs }, + ); + const replacementGuest = await readGuest(page, browserId); + assert(replacementGuest, "Replacement browser guest was not parked after workspace eviction"); + + const listed = await callBrowserTool(client, "browser_list_tabs"); + assert( + listed.tabs.some((tab) => tab.browserId === browserId), + "browser_list_tabs lost the original tab after guest replacement", + ); + + const snapshot = await callBrowserTool(client, "browser_snapshot", { browserId }); + const ref = snapshot.snapshot.match(/button "Bridge target" \[ref=(@e\d+)\]/)?.[1]; + assert(ref, `browser_snapshot did not expose the target button: ${snapshot.snapshot}`); + + const clicked = await callBrowserTool(client, "browser_click", { browserId, ref }); + assert( + clicked.browserId === browserId && clicked.ref === ref, + "browser_click targeted another tab", + ); + await callBrowserTool(client, "browser_wait", { + browserId, + text: "Clicked", + timeoutMs: 5_000, + }); + + return { + browserId, + originalWebContentsId: firstGuest.webContentsId, + replacementWebContentsId: replacementGuest.webContentsId, + list: "passed", + snapshot: "passed", + click: "passed", + }; +} + +async function main() { + const artifactDir = + process.env.PASEO_TAB_BRIDGE_E2E_ARTIFACT_DIR ?? + fs.mkdtempSync(path.join(os.tmpdir(), "paseo-tab-bridge-e2e-artifacts-")); + const runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), "paseo-tab-bridge-e2e-")); + fs.mkdirSync(artifactDir, { recursive: true }); + const paseoHome = path.join(runtimeDir, "paseo-home"); + const userData = path.join(runtimeDir, "electron-user-data"); + const workspaceRoot = path.join(runtimeDir, "workspaces"); + fs.mkdirSync(paseoHome, { recursive: true }); + + const [daemonPort, expoPort, cdpPort] = await Promise.all([ + reservePort(), + reservePort(), + reservePort(), + ]); + const listen = `127.0.0.1:${daemonPort}`; + seedPaseoHome(paseoHome, listen, workspaceRoot); + const target = await startTargetPage(); + const children = []; + let browser = null; + let client = null; + + try { + const commonEnv = { + ...process.env, + PASEO_HOME: paseoHome, + PASEO_LISTEN: listen, + PASEO_DAEMON_ENDPOINT: `localhost:${daemonPort}`, + PASEO_CORS_ORIGINS: "*", + PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0", + PASEO_DICTATION_ENABLED: "0", + PASEO_VOICE_MODE_ENABLED: "0", + 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, + ...(process.platform === "linux" ? ["--no-sandbox"] : []), + ]; + const desktopCommand = process.platform === "linux" ? "xvfb-run" : desktopArgs.shift(); + const desktopCommandArgs = + process.platform === "linux" + ? ["-a", "--server-args=-screen 0 1280x800x24", ...desktopArgs] + : desktopArgs; + const desktop = spawnLogged( + "desktop", + desktopCommand, + desktopCommandArgs, + { + cwd: rootDir, + env: { + ...commonEnv, + EXPO_PORT: String(expoPort), + EXPO_DEV_URL: `http://localhost:${expoPort}`, + PASEO_ELECTRON_REMOTE_DEBUGGING_PORT: String(cdpPort), + PASEO_ELECTRON_USER_DATA_DIR: userData, + PASEO_ELECTRON_FLAGS: `--remote-debugging-address=127.0.0.1 --remote-debugging-port=${cdpPort}`, + }, + }, + artifactDir, + ); + children.push(desktop.child); + await waitForPort(cdpPort, "Electron CDP", desktop); + + browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`); + const page = await waitForAppPage(browser, expoPort); + const status = await waitForDesktopStatus(page); + + const callerAgentId = await createCallerAgent(daemonPort); + const transport = new StreamableHTTPClientTransport( + new URL( + `http://127.0.0.1:${daemonPort}/mcp/agents?callerAgentId=${encodeURIComponent(callerAgentId)}`, + ), + ); + client = await experimental_createMCPClient({ transport }); + const report = await runRegression({ + page, + client, + serverId: status.serverId, + targetUrl: target.url, + }); + writeJson(path.join(artifactDir, "result.json"), report); + console.log( + `Browser tab bridge E2E passed: WebContents ${report.originalWebContentsId} -> ${report.replacementWebContentsId}; list, snapshot, click passed.`, + ); + } catch (error) { + console.error(`Browser tab bridge E2E failed. Artifacts: ${artifactDir}`); + console.error(error); + throw error; + } finally { + await client?.close().catch(() => undefined); + await browser?.close().catch(() => undefined); + for (const child of children.toReversed()) stopProcess(child); + await closeServer(target.server); + await delay(1_000); + try { + fs.rmSync(runtimeDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } catch (error) { + console.warn(`Failed to remove isolated E2E state ${runtimeDir}`, error); + } + } +} + +await main(); diff --git a/packages/desktop/scripts/dev-runner.mjs b/packages/desktop/scripts/dev-runner.mjs index 51ca14130..f2b16f822 100644 --- a/packages/desktop/scripts/dev-runner.mjs +++ b/packages/desktop/scripts/dev-runner.mjs @@ -19,6 +19,7 @@ if (!Number.isInteger(expoPort) || expoPort <= 0) { } const expoDevUrl = process.env.EXPO_DEV_URL || `http://localhost:${expoPort}`; +const electronArgs = process.argv.slice(2); const colorEnv = { FORCE_COLOR: process.env.FORCE_COLOR || "1", npm_config_color: process.env.npm_config_color || "always", @@ -175,7 +176,7 @@ try { } if (!stopping) { - spawnChild("electron", electron, [desktopDir], { + spawnChild("electron", electron, [...electronArgs, desktopDir], { detached: true, env: { ...process.env, diff --git a/packages/desktop/src/daemon/quit-lifecycle.test.ts b/packages/desktop/src/daemon/quit-lifecycle.test.ts index d9af82b65..1d636bd95 100644 --- a/packages/desktop/src/daemon/quit-lifecycle.test.ts +++ b/packages/desktop/src/daemon/quit-lifecycle.test.ts @@ -1,8 +1,8 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings"; import { - createBeforeQuitHandler, + createQuitLifecycle, shouldStopDesktopManagedDaemonOnQuit, stopDesktopManagedDaemonOnQuitIfNeeded, } from "./quit-lifecycle"; @@ -16,6 +16,18 @@ const SETTINGS_STOP_ON_QUIT = { }, }; +function deferred(): { promise: Promise; resolve(value: T): void } { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function waitForQuitLifecycle(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + describe("quit-lifecycle", () => { it("only stops when keepRunningAfterQuit is explicitly disabled", () => { expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_STOP_ON_QUIT)).toBe(true); @@ -23,96 +35,238 @@ describe("quit-lifecycle", () => { }); it("short-circuits without inspecting the daemon when keep-running is on", async () => { - const isDesktopManagedDaemonRunning = vi.fn(() => true); - const stopDaemon = vi.fn(async () => undefined); - const showShutdownFeedback = vi.fn(); + const events: string[] = []; const stopped = await stopDesktopManagedDaemonOnQuitIfNeeded({ settingsStore: { get: async () => SETTINGS_KEEP_RUNNING }, - isDesktopManagedDaemonRunning, - stopDaemon, - showShutdownFeedback, + isDesktopManagedDaemonRunning: () => { + events.push("inspect"); + return true; + }, + stopDaemon: async () => { + events.push("stop"); + }, + showShutdownFeedback: () => { + events.push("feedback"); + }, }); expect(stopped).toBe(false); - expect(isDesktopManagedDaemonRunning).not.toHaveBeenCalled(); - expect(stopDaemon).not.toHaveBeenCalled(); - expect(showShutdownFeedback).not.toHaveBeenCalled(); + expect(events).toEqual([]); }); it("does not stop a manually started daemon on quit", async () => { - const stopDaemon = vi.fn(async () => undefined); - const showShutdownFeedback = vi.fn(); + const events: string[] = []; const stopped = await stopDesktopManagedDaemonOnQuitIfNeeded({ settingsStore: { get: async () => SETTINGS_STOP_ON_QUIT }, isDesktopManagedDaemonRunning: () => false, - stopDaemon, - showShutdownFeedback, + stopDaemon: async () => { + events.push("stop"); + }, + showShutdownFeedback: () => { + events.push("feedback"); + }, }); expect(stopped).toBe(false); - expect(stopDaemon).not.toHaveBeenCalled(); - expect(showShutdownFeedback).not.toHaveBeenCalled(); + expect(events).toEqual([]); }); it("shows feedback then stops a desktop-managed daemon", async () => { - const stopDaemon = vi.fn(async () => undefined); - const showShutdownFeedback = vi.fn(); + const events: string[] = []; const stopped = await stopDesktopManagedDaemonOnQuitIfNeeded({ settingsStore: { get: async () => SETTINGS_STOP_ON_QUIT }, isDesktopManagedDaemonRunning: () => true, - stopDaemon, - showShutdownFeedback, + stopDaemon: async () => { + events.push("stop"); + }, + showShutdownFeedback: () => { + events.push("feedback"); + }, }); expect(stopped).toBe(true); - expect(showShutdownFeedback).toHaveBeenCalledTimes(1); - expect(stopDaemon).toHaveBeenCalledTimes(1); - expect(showShutdownFeedback.mock.invocationCallOrder[0]).toBeLessThan( - stopDaemon.mock.invocationCallOrder[0], - ); + expect(events).toEqual(["feedback", "stop"]); }); - it("preventDefaults the first quit, runs the async stop decision, then exits hard", async () => { - let resolveStopDecision: (() => void) | null = null; - const app = { exit: vi.fn() }; - const closeTransportSessions = vi.fn(); - const onStopError = vi.fn(); - const preventDefault = vi.fn(); - const secondPreventDefault = vi.fn(); + it("revalidates updates after daemon shutdown before exiting", async () => { + const stopDecision = deferred(); + const updateDecision = deferred(); + const events: string[] = []; - const handleBeforeQuit = createBeforeQuitHandler({ - app, - closeTransportSessions, - stopDesktopManagedDaemonIfNeeded: vi.fn( - () => - new Promise((resolve) => { - resolveStopDecision = () => resolve(false); - }), - ), - onStopError, + const quitLifecycle = createQuitLifecycle({ + app: { + exit: (code) => { + events.push(`exit:${code}`); + }, + }, + closeTransportSessions: () => { + events.push("close-transports"); + }, + stopDesktopManagedDaemonIfNeeded: () => stopDecision.promise, + installAppUpdateOnQuit: () => updateDecision.promise, + createUpdateDeadlineSignal: () => new AbortController().signal, + onStopError: () => { + events.push("stop-error"); + }, + onUpdateError: () => { + events.push("update-error"); + }, }); - handleBeforeQuit({ preventDefault }); + quitLifecycle.handleBeforeQuit({ + preventDefault: () => { + events.push("prevent-default"); + }, + }); - expect(preventDefault).toHaveBeenCalledTimes(1); - expect(closeTransportSessions).toHaveBeenCalledTimes(1); - expect(app.exit).not.toHaveBeenCalled(); - expect(resolveStopDecision).not.toBeNull(); + expect(events).toEqual(["close-transports", "prevent-default"]); - resolveStopDecision?.(); - await Promise.resolve(); - await Promise.resolve(); + events.push("daemon-stopped"); + stopDecision.resolve(false); + await waitForQuitLifecycle(); - expect(app.exit).toHaveBeenCalledWith(0); - expect(onStopError).not.toHaveBeenCalled(); + expect(events).toEqual(["close-transports", "prevent-default", "daemon-stopped"]); - handleBeforeQuit({ preventDefault: secondPreventDefault }); + events.push("update-checked"); + updateDecision.resolve(false); + await waitForQuitLifecycle(); - expect(secondPreventDefault).not.toHaveBeenCalled(); - expect(closeTransportSessions).toHaveBeenCalledTimes(2); - expect(app.exit).toHaveBeenCalledTimes(1); + expect(events).toEqual([ + "close-transports", + "prevent-default", + "daemon-stopped", + "update-checked", + "exit:0", + ]); + + quitLifecycle.handleBeforeQuit({ + preventDefault: () => { + events.push("second-prevent-default"); + }, + }); + + expect(events.at(-1)).toBe("close-transports"); + expect(events).not.toContain("second-prevent-default"); + }); + + it("lets the updater own process exit when a validated update is installing", async () => { + const exits: number[] = []; + const quitLifecycle = createQuitLifecycle({ + app: { exit: (code) => exits.push(code) }, + closeTransportSessions: () => {}, + stopDesktopManagedDaemonIfNeeded: async () => false, + installAppUpdateOnQuit: async () => true, + createUpdateDeadlineSignal: () => new AbortController().signal, + onStopError: () => {}, + onUpdateError: () => {}, + }); + + quitLifecycle.handleBeforeQuit({ preventDefault: () => {} }); + await waitForQuitLifecycle(); + quitLifecycle.handleBeforeQuitForUpdate(); + await waitForQuitLifecycle(); + + expect(exits).toEqual([]); + }); + + it("recognizes a repeated quit as updater handoff", async () => { + const exits: number[] = []; + let preventedQuitCount = 0; + const quitLifecycle = createQuitLifecycle({ + app: { exit: (code) => exits.push(code) }, + closeTransportSessions: () => {}, + stopDesktopManagedDaemonIfNeeded: async () => false, + installAppUpdateOnQuit: async () => true, + createUpdateDeadlineSignal: () => new AbortController().signal, + onStopError: () => {}, + onUpdateError: () => {}, + }); + + quitLifecycle.handleBeforeQuit({ preventDefault: () => preventedQuitCount++ }); + await waitForQuitLifecycle(); + quitLifecycle.handleBeforeQuit({ preventDefault: () => preventedQuitCount++ }); + await waitForQuitLifecycle(); + + expect(preventedQuitCount).toBe(1); + expect(exits).toEqual([]); + }); + + it("exits when the updater does not take ownership before its deadline", async () => { + const revalidationDeadline = new AbortController(); + const handoffDeadline = new AbortController(); + let deadlineCount = 0; + const exits: number[] = []; + const quitLifecycle = createQuitLifecycle({ + app: { exit: (code) => exits.push(code) }, + closeTransportSessions: () => {}, + stopDesktopManagedDaemonIfNeeded: async () => false, + installAppUpdateOnQuit: async () => true, + createUpdateDeadlineSignal: () => + deadlineCount++ === 0 ? revalidationDeadline.signal : handoffDeadline.signal, + onStopError: () => {}, + onUpdateError: () => {}, + }); + + quitLifecycle.handleBeforeQuit({ preventDefault: () => {} }); + await waitForQuitLifecycle(); + handoffDeadline.abort(); + await waitForQuitLifecycle(); + + expect(exits).toEqual([0]); + }); + + it("does not intercept a quit started by a manual update", () => { + const events: string[] = []; + const quitLifecycle = createQuitLifecycle({ + app: { exit: (code) => events.push(`exit:${code}`) }, + closeTransportSessions: () => events.push("close-transports"), + stopDesktopManagedDaemonIfNeeded: async () => { + events.push("stop-daemon"); + return false; + }, + installAppUpdateOnQuit: async () => { + events.push("revalidate-update"); + return false; + }, + createUpdateDeadlineSignal: () => new AbortController().signal, + onStopError: () => events.push("stop-error"), + onUpdateError: () => events.push("update-error"), + }); + + quitLifecycle.handleBeforeQuitForUpdate(); + quitLifecycle.handleBeforeQuit({ + preventDefault: () => events.push("prevent-default"), + }); + + expect(events).toEqual(["close-transports"]); + }); + + it("exits when update revalidation reaches its deadline", async () => { + const deadline = new AbortController(); + const updateDecision = deferred(); + const exits: number[] = []; + const quitLifecycle = createQuitLifecycle({ + app: { exit: (code) => exits.push(code) }, + closeTransportSessions: () => {}, + stopDesktopManagedDaemonIfNeeded: async () => false, + installAppUpdateOnQuit: () => updateDecision.promise, + createUpdateDeadlineSignal: () => deadline.signal, + onStopError: () => {}, + onUpdateError: () => {}, + }); + + quitLifecycle.handleBeforeQuit({ preventDefault: () => {} }); + await waitForQuitLifecycle(); + deadline.abort(); + await waitForQuitLifecycle(); + + expect(exits).toEqual([0]); + + updateDecision.resolve(true); + await waitForQuitLifecycle(); + expect(exits).toEqual([0]); }); }); diff --git a/packages/desktop/src/daemon/quit-lifecycle.ts b/packages/desktop/src/daemon/quit-lifecycle.ts index 879f59778..1998985f5 100644 --- a/packages/desktop/src/daemon/quit-lifecycle.ts +++ b/packages/desktop/src/daemon/quit-lifecycle.ts @@ -14,6 +14,16 @@ interface BeforeQuitApp { exit(code: number): void; } +interface QuitLifecycle { + handleBeforeQuit(event: BeforeQuitEvent): void; + handleBeforeQuitForUpdate(): void; +} + +interface DeferredUpdateQuit { + promise: Promise; + resolve(): void; +} + export interface StopOnQuitDeps { settingsStore: Pick; isDesktopManagedDaemonRunning: () => boolean; @@ -42,36 +52,95 @@ export async function stopDesktopManagedDaemonOnQuitIfNeeded( return true; } -export function createBeforeQuitHandler({ +function waitForUpdateDeadline(signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.resolve(false); + } + + return new Promise((resolve) => { + signal.addEventListener("abort", () => resolve(false), { once: true }); + }); +} + +function createDeferredUpdateQuit(): DeferredUpdateQuit { + let resolvePromise!: (started: boolean) => void; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { promise, resolve: () => resolvePromise(true) }; +} + +export function createQuitLifecycle({ app, closeTransportSessions, stopDesktopManagedDaemonIfNeeded, + installAppUpdateOnQuit, + createUpdateDeadlineSignal, onStopError, + onUpdateError, }: { app: BeforeQuitApp; closeTransportSessions: () => void; stopDesktopManagedDaemonIfNeeded: () => Promise; + installAppUpdateOnQuit: (signal: AbortSignal) => Promise; + createUpdateDeadlineSignal: () => AbortSignal; onStopError: (error: unknown) => void; -}): (event: BeforeQuitEvent) => void { - // We always preventDefault on first quit so we can run the async stop - // decision, then call app.exit(0) — which bypasses Electron's - // close → window-all-closed → will-quit chain. The window-all-closed - // listener is a darwin no-op (macOS convention) and would otherwise - // veto a re-fired app.quit(). + onUpdateError: (error: unknown) => void; +}): QuitLifecycle { + // The first quit waits for daemon shutdown and update revalidation. A validated + // update re-fires app.quit(); otherwise app.exit(0) bypasses Electron's macOS + // window-all-closed handler, which would veto that second quit. let quitting = false; + let quittingForUpdate = false; + const updateQuit = createDeferredUpdateQuit(); - return (event) => { + function handleBeforeQuit(event: BeforeQuitEvent): void { closeTransportSessions(); - if (quitting) return; + if (quittingForUpdate) return; + if (quitting) { + // MacUpdater's no-relaunch path calls app.quit() without emitting + // before-quit-for-update. A second quit is equivalent handoff evidence. + updateQuit.resolve(); + return; + } quitting = true; event.preventDefault(); - void stopDesktopManagedDaemonIfNeeded() - .catch((error) => { + void (async () => { + try { + await stopDesktopManagedDaemonIfNeeded(); + } catch (error) { onStopError(error); - }) - .finally(() => { - app.exit(0); + } + + const signal = createUpdateDeadlineSignal(); + const updateInstallation = installAppUpdateOnQuit(signal).catch((error) => { + onUpdateError(error); + return false; }); + const installingUpdate = await Promise.race([ + updateInstallation, + waitForUpdateDeadline(signal), + ]); + if (installingUpdate) { + const handoffStarted = await Promise.race([ + updateQuit.promise, + waitForUpdateDeadline(createUpdateDeadlineSignal()), + ]); + if (handoffStarted) { + return; + } + } + + app.exit(0); + })(); + } + + return { + handleBeforeQuit, + handleBeforeQuitForUpdate() { + quittingForUpdate = true; + updateQuit.resolve(); + }, }; } diff --git a/packages/desktop/src/features/app-update-service.test.ts b/packages/desktop/src/features/app-update-service.test.ts index f243a7c28..1e8154602 100644 --- a/packages/desktop/src/features/app-update-service.test.ts +++ b/packages/desktop/src/features/app-update-service.test.ts @@ -17,7 +17,19 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime { > = []; private gate: ((info: RuntimeUpdateInfo) => boolean | Promise) | null = null; private configuration: AppUpdateRuntimeConfiguration | null = null; + private downloadableUpdate: RuntimeUpdateInfo | null = null; + private downloadedUpdate: RuntimeUpdateInfo | null = null; + private activeDownload: { + info: RuntimeUpdateInfo; + promise: Promise; + resolve(): void; + reject(error: Error): void; + } | null = null; checkCount = 0; + downloadCallCount = 0; + downloadedVersions: string[] = []; + installedVersions: string[] = []; + installModes: Array<{ isSilent: boolean; isForceRunAfter: boolean }> = []; configure(input: AppUpdateRuntimeConfiguration): void { this.configuration = input; @@ -59,9 +71,42 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime { } finishUpdateDownload(info: RuntimeUpdateInfo): void { + this.downloadedUpdate = info; + this.downloadedVersions.push(info.version); this.configuration?.onUpdateDownloaded(info); } + beginUpdateDownload(info: RuntimeUpdateInfo): { + resolve(): void; + reject(error: Error): void; + } { + this.downloadableUpdate = info; + this.prepareUpdate(info); + let resolvePromise!: () => void; + let rejectPromise!: (error: Error) => void; + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + void promise.catch(() => undefined); + const activeDownload = { + info, + promise, + resolve: () => { + this.finishUpdateDownload(info); + this.activeDownload = null; + resolvePromise(); + }, + reject: (error: Error) => { + this.configuration?.onError(error); + this.activeDownload = null; + rejectPromise(error); + }, + }; + this.activeDownload = activeDownload; + return { resolve: activeDownload.resolve, reject: activeDownload.reject }; + } + async checkForUpdates(): Promise<{ isUpdateAvailable: boolean; updateInfo: RuntimeUpdateInfo; @@ -80,12 +125,27 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime { } if (!result || !this.gate) return result; const admitted = await this.gate(result.updateInfo); - return { ...result, isUpdateAvailable: result.isUpdateAvailable && admitted }; + const isUpdateAvailable = result.isUpdateAvailable && admitted; + this.downloadableUpdate = isUpdateAvailable ? result.updateInfo : null; + return { ...result, isUpdateAvailable }; } - async downloadUpdate(): Promise {} + async downloadUpdate(): Promise { + this.downloadCallCount += 1; + if (this.activeDownload) { + return this.activeDownload.promise; + } + if (this.downloadableUpdate) { + this.finishUpdateDownload(this.downloadableUpdate); + } + } - quitAndInstall(): void {} + quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void { + if (this.downloadedUpdate) { + this.installedVersions.push(this.downloadedUpdate.version); + this.installModes.push({ isSilent, isForceRunAfter }); + } + } } function createService(input?: { now?: () => number; bucket?: () => Promise }) { @@ -148,6 +208,33 @@ describe("app update service", () => { }); }); + it("waits for an automatic poll before starting a manual rollout-bypassing check", async () => { + const { runtime, service } = createService(); + const automaticCheck = runtime.deferNextCheck(); + const automaticPending = service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); + const manualPending = service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "manual", + }); + + await Promise.resolve(); + expect(runtime.checkCount).toBe(1); + + automaticCheck.resolve({ isUpdateAvailable: false, updateInfo: rolledOutUpdate }); + await automaticPending; + const manualResult = await manualPending; + + expect(runtime.checkCount).toBe(2); + expect(manualResult.hasUpdate).toBe(true); + expect(manualResult.latestVersion).toBe("1.2.4"); + }); + it("performs a fresh manual check when an update is already cached", async () => { const { runtime, service } = createService({ bucket: async () => 0 }); runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); @@ -179,6 +266,218 @@ describe("app update service", () => { }); }); + it("replaces a downloaded update when a newer release is admitted", async () => { + const { runtime, service } = createService({ bucket: async () => 0 }); + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); + + await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + runtime.finishUpdateDownload(rolledOutUpdate); + + const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" }; + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate }); + const result = await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + + expect(result).toEqual({ + hasUpdate: true, + readyToInstall: false, + currentVersion: "1.2.3", + latestVersion: "1.2.5", + body: null, + date: "2026-04-28T00:00:00.000Z", + errorMessage: null, + }); + }); + + it("installs the newest admitted release when quitting with an older download", async () => { + const { runtime, service } = createService({ bucket: async () => 0 }); + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); + + await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + runtime.finishUpdateDownload(rolledOutUpdate); + + const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" }; + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate }); + const installed = await service.installUpdateOnQuit({ + currentVersion: "1.2.3", + releaseChannel: "stable", + signal: new AbortController().signal, + }); + + expect(installed).toBe(true); + expect(runtime.installedVersions).toEqual(["1.2.5"]); + expect(runtime.installModes).toEqual([{ isSilent: true, isForceRunAfter: false }]); + }); + + it("does not install an older download while its replacement is still rolling out", async () => { + const now = Date.parse("2026-04-28T12:00:00.000Z"); + const { runtime, service } = createService({ now: () => now, bucket: async () => 0.4 }); + const olderUpdate = { + ...rolledOutUpdate, + releaseDate: "2026-04-27T00:00:00.000Z", + }; + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: olderUpdate }); + + await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + runtime.finishUpdateDownload(olderUpdate); + + const newerUpdate = { + ...rolledOutUpdate, + version: "1.2.5", + releaseDate: "2026-04-28T12:00:00.000Z", + }; + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate }); + const installed = await service.installUpdateOnQuit({ + currentVersion: "1.2.3", + releaseChannel: "stable", + signal: new AbortController().signal, + }); + + expect(installed).toBe(false); + expect(runtime.installedVersions).toEqual([]); + }); + + it("does not install after quit-time revalidation expires", async () => { + const { runtime, service } = createService({ bucket: async () => 0 }); + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); + + await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + runtime.finishUpdateDownload(rolledOutUpdate); + + const deadline = new AbortController(); + deadline.abort(); + runtime.nextCheck({ + isUpdateAvailable: true, + updateInfo: { ...rolledOutUpdate, version: "1.2.5" }, + }); + const installed = await service.installUpdateOnQuit({ + currentVersion: "1.2.3", + releaseChannel: "stable", + signal: deadline.signal, + }); + + expect(installed).toBe(false); + expect(runtime.installedVersions).toEqual([]); + }); + + it("does not install an unvalidated download when the quit-time check fails", async () => { + const { runtime, service } = createService({ bucket: async () => 0 }); + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); + + await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + runtime.finishUpdateDownload(rolledOutUpdate); + + runtime.failNextCheck(new Error("offline")); + const installed = await service.installUpdateOnQuit({ + currentVersion: "1.2.3", + releaseChannel: "stable", + signal: new AbortController().signal, + }); + + expect(installed).toBe(false); + expect(runtime.installedVersions).toEqual([]); + }); + + it("rechecks for the newest release before a manual install", async () => { + const { runtime, service } = createService({ bucket: async () => 0.99 }); + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); + + await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "manual", + }); + runtime.finishUpdateDownload(rolledOutUpdate); + + const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" }; + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate }); + const result = await service.downloadAndInstallUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + }); + + expect(result.installed).toBe(true); + expect(runtime.installedVersions).toEqual(["1.2.5"]); + expect(runtime.installModes).toEqual([{ isSilent: false, isForceRunAfter: true }]); + }); + + it("waits for a stale active download before downloading and installing the rechecked version", async () => { + const { runtime, service } = createService({ bucket: async () => 0 }); + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); + await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + const staleDownload = runtime.beginUpdateDownload(rolledOutUpdate); + + const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" }; + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate }); + const installPending = service.downloadAndInstallUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + }); + await Promise.resolve(); + expect(runtime.installedVersions).toEqual([]); + + staleDownload.resolve(); + const result = await installPending; + + expect(result.installed).toBe(true); + expect(runtime.downloadedVersions).toEqual(["1.2.4", "1.2.5"]); + expect(runtime.installedVersions).toEqual(["1.2.5"]); + }); + + it("installs the rechecked version when the stale active download fails", async () => { + const { runtime, service } = createService({ bucket: async () => 0 }); + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); + await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + const staleDownload = runtime.beginUpdateDownload(rolledOutUpdate); + + const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" }; + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate }); + const installPending = service.downloadAndInstallUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(runtime.downloadCallCount).toBe(1); + + staleDownload.reject(new Error("old download failed")); + const result = await installPending; + + expect(result.installed).toBe(true); + expect(runtime.downloadedVersions).toEqual(["1.2.5"]); + expect(runtime.installedVersions).toEqual(["1.2.5"]); + }); + it("trusts the runtime availability decision before comparing versions", async () => { const { runtime, service } = createService({ bucket: async () => 0 }); runtime.nextCheck({ isUpdateAvailable: false, updateInfo: rolledOutUpdate }); @@ -322,28 +621,26 @@ describe("app update service", () => { }); }); - it("keeps preparation errors emitted before the update check rejects", async () => { - const { runtime, service } = createService(); - const deferredCheck = runtime.deferNextCheck(); - const pending = service.checkForAppUpdate({ + it("surfaces preparation errors without blocking newer releases", async () => { + const { runtime, service } = createService({ bucket: async () => 0 }); + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); + + await service.checkForAppUpdate({ currentVersion: "1.2.3", releaseChannel: "stable", - intent: "automatic", + intent: "manual", }); - runtime.prepareUpdate(rolledOutUpdate); runtime.failRuntime(new Error("sha512 checksum mismatch")); - deferredCheck.reject(new Error("sha512 checksum mismatch")); - const checkResult = await pending; - expect(checkResult.errorMessage).toBe("sha512 checksum mismatch"); - const automaticResult = await service.checkForAppUpdate({ + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); + const failedPreparation = await service.checkForAppUpdate({ currentVersion: "1.2.3", releaseChannel: "stable", intent: "automatic", }); - expect(automaticResult).toEqual({ + expect(failedPreparation).toEqual({ hasUpdate: true, readyToInstall: false, currentVersion: "1.2.3", @@ -352,19 +649,9 @@ describe("app update service", () => { date: "2026-04-28T00:00:00.000Z", errorMessage: "sha512 checksum mismatch", }); - }); - - it("returns runtime update errors after an update fails to prepare", async () => { - const { runtime, service } = createService(); - runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); - - await service.checkForAppUpdate({ - currentVersion: "1.2.3", - releaseChannel: "stable", - intent: "manual", - }); - runtime.failRuntime(new Error("sha512 checksum mismatch")); + const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" }; + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate }); const result = await service.checkForAppUpdate({ currentVersion: "1.2.3", releaseChannel: "stable", @@ -375,13 +662,43 @@ describe("app update service", () => { hasUpdate: true, readyToInstall: false, currentVersion: "1.2.3", - latestVersion: "1.2.4", + latestVersion: "1.2.5", body: null, date: "2026-04-28T00:00:00.000Z", - errorMessage: "sha512 checksum mismatch", + errorMessage: null, }); }); + it("attributes a late preparation failure to the download that started it", async () => { + const { runtime, service } = createService({ bucket: async () => 0 }); + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); + await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + runtime.prepareUpdate(rolledOutUpdate); + + const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" }; + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate }); + await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + runtime.failRuntime(new Error("old download failed")); + + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate }); + const result = await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + + expect(result.latestVersion).toBe("1.2.5"); + expect(result.errorMessage).toBeNull(); + }); + it("performs a fresh manual check after an update preparation error", async () => { const { runtime, service } = createService(); runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); @@ -391,6 +708,7 @@ describe("app update service", () => { releaseChannel: "stable", intent: "manual", }); + runtime.prepareUpdate(rolledOutUpdate); runtime.failRuntime(new Error("sha512 checksum mismatch")); runtime.nextCheck({ @@ -445,91 +763,4 @@ describe("app update service", () => { errorMessage: null, }); }); - - it("returns runtime update errors to multiple automatic checks before a manual retry clears them", async () => { - const { runtime, service } = createService(); - runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); - - await service.checkForAppUpdate({ - currentVersion: "1.2.3", - releaseChannel: "stable", - intent: "manual", - }); - runtime.failRuntime(new Error("sha512 checksum mismatch")); - - const firstAutomaticResult = await service.checkForAppUpdate({ - currentVersion: "1.2.3", - releaseChannel: "stable", - intent: "automatic", - }); - const secondAutomaticResult = await service.checkForAppUpdate({ - currentVersion: "1.2.3", - releaseChannel: "stable", - intent: "automatic", - }); - - expect(firstAutomaticResult).toEqual({ - hasUpdate: true, - readyToInstall: false, - currentVersion: "1.2.3", - latestVersion: "1.2.4", - body: null, - date: "2026-04-28T00:00:00.000Z", - errorMessage: "sha512 checksum mismatch", - }); - expect(secondAutomaticResult).toEqual(firstAutomaticResult); - - runtime.nextCheck(null); - const retryResult = await service.checkForAppUpdate({ - currentVersion: "1.2.3", - releaseChannel: "stable", - intent: "manual", - }); - - expect(runtime.checkCount).toBe(2); - expect(retryResult).toEqual({ - hasUpdate: false, - readyToInstall: false, - currentVersion: "1.2.3", - latestVersion: "1.2.3", - body: null, - date: null, - errorMessage: null, - }); - }); - - it("keeps runtime update errors visible after a manual retry fails", async () => { - const { runtime, service } = createService(); - runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); - - await service.checkForAppUpdate({ - currentVersion: "1.2.3", - releaseChannel: "stable", - intent: "manual", - }); - runtime.failRuntime(new Error("sha512 checksum mismatch")); - - runtime.failNextCheck(new Error("network down")); - const retryResult = await service.checkForAppUpdate({ - currentVersion: "1.2.3", - releaseChannel: "stable", - intent: "manual", - }); - const automaticResult = await service.checkForAppUpdate({ - currentVersion: "1.2.3", - releaseChannel: "stable", - intent: "automatic", - }); - - expect(retryResult.errorMessage).toBe("network down"); - expect(automaticResult).toEqual({ - hasUpdate: true, - readyToInstall: false, - currentVersion: "1.2.3", - latestVersion: "1.2.4", - body: null, - date: "2026-04-28T00:00:00.000Z", - errorMessage: "sha512 checksum mismatch", - }); - }); }); diff --git a/packages/desktop/src/features/app-update-service.ts b/packages/desktop/src/features/app-update-service.ts index f62dd8167..d5dc513ef 100644 --- a/packages/desktop/src/features/app-update-service.ts +++ b/packages/desktop/src/features/app-update-service.ts @@ -62,6 +62,11 @@ export interface AppUpdateService { }, onBeforeQuit?: () => Promise, ): Promise; + installUpdateOnQuit(input: { + currentVersion: string; + releaseChannel: AppReleaseChannel; + signal: AbortSignal; + }): Promise; } export interface AppUpdateServiceDeps { @@ -96,10 +101,16 @@ function buildCheckResult(input: { async function performQuitAndInstall( runtime: AppUpdateRuntime, - onBeforeQuit?: () => Promise, + { + onBeforeQuit, + restart, + }: { + onBeforeQuit?: () => Promise; + restart: boolean; + }, ): Promise { if (onBeforeQuit) await onBeforeQuit(); - runtime.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true); + runtime.quitAndInstall(/* isSilent */ !restart, /* isForceRunAfter */ restart); } function getErrorMessage(error: unknown): string { @@ -109,13 +120,21 @@ function getErrorMessage(error: unknown): string { return String(error); } +function buildDeferredInstallResult(currentVersion: string): AppUpdateInstallResult { + return { + installed: false, + version: currentVersion, + message: "Update validation timed out. The update will be installed later.", + }; +} + export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateService { let cachedUpdateInfo: RuntimeUpdateInfo | null = null; let downloadedUpdateVersion: string | null = null; - let downloading = false; let configuredReleaseChannel: AppReleaseChannel | null = null; - let runtimeErrorMessage: string | null = null; - let inFlightUpdateCheckCount = 0; + let preparationError: { version: string; message: string } | null = null; + let preparingUpdateVersion: string | null = null; + let checkQueue: Promise = Promise.resolve(); function isReadyToInstallVersion(version: string): boolean { return downloadedUpdateVersion === version; @@ -124,23 +143,8 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer function clearUpdateState(): void { cachedUpdateInfo = null; downloadedUpdateVersion = null; - downloading = false; - runtimeErrorMessage = null; - } - - function buildRuntimeErrorResult(currentVersion: string): AppUpdateCheckResult | null { - if (!runtimeErrorMessage) { - return null; - } - - const info = cachedUpdateInfo; - return buildCheckResult({ - currentVersion, - hasUpdate: info?.version !== undefined && info.version !== currentVersion, - readyToInstall: false, - info, - errorMessage: runtimeErrorMessage, - }); + preparationError = null; + preparingUpdateVersion = null; } function configureRuntime(releaseChannel: AppReleaseChannel, intent: AppUpdateCheckIntent): void { @@ -166,28 +170,47 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer const alreadyReady = downloadedUpdateVersion === info.version; cachedUpdateInfo = info; downloadedUpdateVersion = alreadyReady ? info.version : null; - downloading = !alreadyReady; - runtimeErrorMessage = null; + if (!alreadyReady && preparingUpdateVersion === null) { + preparingUpdateVersion = info.version; + } }, onUpdateDownloaded(info) { - cachedUpdateInfo = info; + // A superseded download can finish after a newer manifest check. Keep + // the validated manifest as the install target in that case. + cachedUpdateInfo ??= info; downloadedUpdateVersion = info.version; - downloading = false; - runtimeErrorMessage = null; + if (preparingUpdateVersion === info.version) { + preparingUpdateVersion = null; + } + if (preparationError?.version === info.version) { + preparationError = null; + } }, onUpdateNotAvailable() { clearUpdateState(); }, onError(error) { - downloading = false; - if (inFlightUpdateCheckCount === 0 || cachedUpdateInfo) { - runtimeErrorMessage = getErrorMessage(error); + if (preparingUpdateVersion) { + preparationError = { + version: preparingUpdateVersion, + message: getErrorMessage(error), + }; + preparingUpdateVersion = null; } deps.reportRuntimeError?.(error); }, }); } + function runCheckExclusively(check: () => Promise): Promise { + const result = checkQueue.then(check, check); + checkQueue = result.then( + () => undefined, + () => undefined, + ); + return result; + } + async function checkForAppUpdate({ currentVersion, releaseChannel, @@ -205,73 +228,56 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer }); } - configureRuntime(releaseChannel, intent); + return runCheckExclusively(async () => { + configureRuntime(releaseChannel, intent); - const runtimeErrorResult = buildRuntimeErrorResult(currentVersion); - if (runtimeErrorResult && intent === "automatic") { - return runtimeErrorResult; - } + try { + const result = await deps.runtime.checkForUpdates(); + if (!result || !result.updateInfo || !result.isUpdateAvailable) { + clearUpdateState(); + return buildCheckResult({ + currentVersion, + hasUpdate: false, + readyToInstall: false, + }); + } - const cachedVersion = cachedUpdateInfo?.version ?? null; - if ( - !runtimeErrorResult && - intent === "automatic" && - cachedVersion && - cachedVersion !== currentVersion - ) { - return buildCheckResult({ - currentVersion, - hasUpdate: true, - readyToInstall: isReadyToInstallVersion(cachedVersion), - info: cachedUpdateInfo, - }); - } + const info = result.updateInfo; + const latestVersion = info.version; + const hasUpdate = latestVersion !== currentVersion; + + if (hasUpdate) { + cachedUpdateInfo = info; + const errorMessage = + preparationError?.version === latestVersion ? preparationError.message : null; + if (!errorMessage) { + preparationError = null; + } + return buildCheckResult({ + currentVersion, + hasUpdate: true, + readyToInstall: isReadyToInstallVersion(latestVersion), + info, + errorMessage, + }); + } - try { - inFlightUpdateCheckCount += 1; - const result = await deps.runtime.checkForUpdates(); - if (!result || !result.updateInfo || !result.isUpdateAvailable) { clearUpdateState(); return buildCheckResult({ currentVersion, hasUpdate: false, readyToInstall: false, }); - } - - const info = result.updateInfo; - const latestVersion = info.version; - const hasUpdate = latestVersion !== currentVersion; - - if (hasUpdate) { - cachedUpdateInfo = info; - downloading = !isReadyToInstallVersion(latestVersion); - runtimeErrorMessage = null; + } catch (error) { + deps.reportCheckError?.(error); return buildCheckResult({ currentVersion, - hasUpdate: true, - readyToInstall: isReadyToInstallVersion(latestVersion), - info, + hasUpdate: false, + readyToInstall: false, + errorMessage: getErrorMessage(error), }); } - - clearUpdateState(); - return buildCheckResult({ - currentVersion, - hasUpdate: false, - readyToInstall: false, - }); - } catch (error) { - deps.reportCheckError?.(error); - return buildCheckResult({ - currentVersion, - hasUpdate: false, - readyToInstall: false, - errorMessage: getErrorMessage(error), - }); - } finally { - inFlightUpdateCheckCount -= 1; - } + }); } async function downloadAndInstallUpdate( @@ -292,6 +298,69 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer }; } + const check = await checkForAppUpdate({ + currentVersion, + releaseChannel, + intent: "manual", + }); + if (!check.hasUpdate) { + return { + installed: false, + version: currentVersion, + message: check.errorMessage ?? "No update available.", + }; + } + + return installCachedUpdate(currentVersion, { onBeforeQuit, restart: true }); + } + + async function ensureUpdateDownloaded( + readyVersion: string, + signal?: AbortSignal, + ): Promise<"ready" | "aborted" | "superseded"> { + while (!isReadyToInstallVersion(readyVersion)) { + if (signal?.aborted) return "aborted"; + if (cachedUpdateInfo?.version !== readyVersion) return "superseded"; + + const attemptedVersion: string = preparingUpdateVersion ?? readyVersion; + preparingUpdateVersion ??= readyVersion; + try { + await deps.runtime.downloadUpdate(); + } catch (error) { + if ( + attemptedVersion !== readyVersion && + cachedUpdateInfo?.version === readyVersion && + !signal?.aborted + ) { + continue; + } + throw error; + } + + // electron-updater can return an older, already-running download. Its + // event clears that version, then the next iteration starts the newly + // validated release instead of treating the stale artifact as ready. + if (attemptedVersion === readyVersion && !isReadyToInstallVersion(readyVersion)) { + downloadedUpdateVersion = readyVersion; + preparingUpdateVersion = null; + } + } + + return signal?.aborted ? "aborted" : "ready"; + } + + async function installCachedUpdate( + currentVersion: string, + { + onBeforeQuit, + signal, + restart, + }: { + onBeforeQuit?: () => Promise; + signal?: AbortSignal; + restart: boolean; + }, + ): Promise { if (!cachedUpdateInfo) { return { installed: false, @@ -300,11 +369,13 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer }; } - configureRuntime(releaseChannel, "manual"); - const readyVersion = cachedUpdateInfo.version; + if (signal?.aborted) { + return buildDeferredInstallResult(currentVersion); + } + if (isReadyToInstallVersion(readyVersion)) { - await performQuitAndInstall(deps.runtime, onBeforeQuit); + await performQuitAndInstall(deps.runtime, { onBeforeQuit, restart }); return { installed: true, version: readyVersion, @@ -312,21 +383,19 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer }; } - if (downloading) { - return { - installed: false, - version: currentVersion, - message: "Update is still being prepared. Try again in a moment.", - }; - } - - downloading = true; - try { - await deps.runtime.downloadUpdate(); - downloadedUpdateVersion = readyVersion; - downloading = false; - await performQuitAndInstall(deps.runtime, onBeforeQuit); + const preparation = await ensureUpdateDownloaded(readyVersion, signal); + if (preparation === "aborted") { + return buildDeferredInstallResult(currentVersion); + } + if (preparation === "superseded") { + return { + installed: false, + version: currentVersion, + message: "A newer update was found and will be installed later.", + }; + } + await performQuitAndInstall(deps.runtime, { onBeforeQuit, restart }); return { installed: true, @@ -334,7 +403,6 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer message: "Update downloaded. The app will restart shortly.", }; } catch (error) { - downloading = false; const message = error instanceof Error ? error.message : String(error); deps.reportInstallError?.(message); return { @@ -345,8 +413,35 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer } } + async function installUpdateOnQuit({ + currentVersion, + releaseChannel, + signal, + }: { + currentVersion: string; + releaseChannel: AppReleaseChannel; + signal: AbortSignal; + }): Promise { + if (!deps.isPackaged() || !downloadedUpdateVersion) { + return false; + } + + const check = await checkForAppUpdate({ + currentVersion, + releaseChannel, + intent: "automatic", + }); + if (signal.aborted || !check.hasUpdate) { + return false; + } + + const result = await installCachedUpdate(currentVersion, { signal, restart: false }); + return result.installed; + } + return { checkForAppUpdate, downloadAndInstallUpdate, + installUpdateOnQuit, }; } diff --git a/packages/desktop/src/features/auto-updater.test.ts b/packages/desktop/src/features/auto-updater.test.ts index 4de12d87d..3c28e61c1 100644 --- a/packages/desktop/src/features/auto-updater.test.ts +++ b/packages/desktop/src/features/auto-updater.test.ts @@ -19,15 +19,15 @@ import { resolveStagingUserId, rolloutManifestSchema, shouldAdmitToRollout, - shouldAutoInstallOnQuit, + shouldInstallAppUpdateOnQuit, } from "./auto-updater"; -describe("shouldAutoInstallOnQuit", () => { - it("auto-installs on quit everywhere except Linux AppImage", () => { - expect(shouldAutoInstallOnQuit({ platform: "linux", isAppImage: true })).toBe(false); - expect(shouldAutoInstallOnQuit({ platform: "linux", isAppImage: false })).toBe(true); - expect(shouldAutoInstallOnQuit({ platform: "darwin", isAppImage: false })).toBe(true); - expect(shouldAutoInstallOnQuit({ platform: "win32", isAppImage: false })).toBe(true); +describe("shouldInstallAppUpdateOnQuit", () => { + it("keeps Linux AppImage updates on the manual install path", () => { + expect(shouldInstallAppUpdateOnQuit({ platform: "linux", isAppImage: true })).toBe(false); + expect(shouldInstallAppUpdateOnQuit({ platform: "linux", isAppImage: false })).toBe(true); + expect(shouldInstallAppUpdateOnQuit({ platform: "darwin", isAppImage: false })).toBe(true); + expect(shouldInstallAppUpdateOnQuit({ platform: "win32", isAppImage: false })).toBe(true); }); }); diff --git a/packages/desktop/src/features/auto-updater.ts b/packages/desktop/src/features/auto-updater.ts index 876114585..c9b9a983b 100644 --- a/packages/desktop/src/features/auto-updater.ts +++ b/packages/desktop/src/features/auto-updater.ts @@ -76,18 +76,12 @@ export function getStagingUserId(): Promise { return cachedStagingUserIdPromise; } -// AppImages have no install step. electron-updater "installs" by unlinking the -// running file and mv-ing the downloaded one into place; on app quit it does this -// via a *blocking* execFileSync(newAppImage, { APPIMAGE_EXIT_AFTER_INSTALL: "true" }). -// That env var is only honored by AppImageLauncher, so without it the freshly -// launched process boots the full app and never exits — the quit hangs forever, -// with the old binary already deleted. We therefore install AppImages only on -// explicit quitAndInstall (the "Update now" button), which takes the non-blocking -// spawn path. Every other target keeps auto-install-on-quit, which works there. -export function shouldAutoInstallOnQuit(input: { +export function shouldInstallAppUpdateOnQuit(input: { platform: NodeJS.Platform; isAppImage: boolean; }): boolean { + // AppImage's no-relaunch install path blocks while launching the replacement + // binary, which can hang after the running file has already been replaced. return !(input.platform === "linux" && input.isAppImage); } @@ -97,10 +91,10 @@ class ElectronAppUpdateRuntime implements AppUpdateRuntime { configure(input: AppUpdateRuntimeConfiguration): void { autoUpdater.autoDownload = true; autoUpdater.autoRunAppAfterInstall = true; - autoUpdater.autoInstallOnAppQuit = shouldAutoInstallOnQuit({ - platform: process.platform, - isAppImage: Boolean(process.env.APPIMAGE), - }); + // Paseo revalidates the current manifest before explicitly installing on quit. + // Electron's built-in handler would install an older download without checking + // whether a newer release has superseded it. + autoUpdater.autoInstallOnAppQuit = false; autoUpdater.allowPrerelease = input.releaseChannel === "beta"; autoUpdater.channel = input.releaseChannel === "beta" ? "beta" : "latest"; autoUpdater.allowDowngrade = false; @@ -143,6 +137,7 @@ class ElectronAppUpdateRuntime implements AppUpdateRuntime { } quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void { + autoUpdater.autoRunAppAfterInstall = isForceRunAfter; autoUpdater.quitAndInstall(isSilent, isForceRunAfter); } } @@ -194,3 +189,24 @@ export async function downloadAndInstallUpdate( onBeforeQuit, ); } + +export async function installAppUpdateOnQuit({ + currentVersion, + releaseChannel, + signal, +}: { + currentVersion: string; + releaseChannel: AppReleaseChannel; + signal: AbortSignal; +}): Promise { + if ( + !shouldInstallAppUpdateOnQuit({ + platform: process.platform, + isAppImage: Boolean(process.env.APPIMAGE), + }) + ) { + return false; + } + + return appUpdateService.installUpdateOnQuit({ currentVersion, releaseChannel, signal }); +} diff --git a/packages/desktop/src/features/browser-automation/actionability.ts b/packages/desktop/src/features/browser-automation/actionability.ts index b5e48db76..0d2039904 100644 --- a/packages/desktop/src/features/browser-automation/actionability.ts +++ b/packages/desktop/src/features/browser-automation/actionability.ts @@ -100,8 +100,11 @@ function buildActionabilityScript(input: { const deadline = performance.now() + ${JSON.stringify(input.timeoutMs)}; const requiresEditable = ${JSON.stringify(input.editable)}; - const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve)); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + // Electron can suspend requestAnimationFrame while a guest is parked after + // workspace LRU eviction even though the guest remains CDP-controllable. + // Timed layout samples keep background browser automation live. + const waitForLayout = () => sleep(16); const nearlyEqual = (a, b) => Math.abs(a - b) < 0.25; const sameRect = (a, b) => nearlyEqual(a.x, b.x) && @@ -174,9 +177,9 @@ function buildActionabilityScript(input: { } element.scrollIntoView?.({ block: 'center', inline: 'center' }); - await nextFrame(); + await waitForLayout(); const firstRect = element.getBoundingClientRect(); - await nextFrame(); + await waitForLayout(); const secondRect = element.getBoundingClientRect(); if (!sameRect(firstRect, secondRect)) { detail = 'moving'; diff --git a/packages/desktop/src/features/browser-keyboard/index.test.ts b/packages/desktop/src/features/browser-keyboard/index.test.ts index 695750d21..2c792266b 100644 --- a/packages/desktop/src/features/browser-keyboard/index.test.ts +++ b/packages/desktop/src/features/browser-keyboard/index.test.ts @@ -21,11 +21,11 @@ class FakeBrowserContents { public readonly sent: SentMessage[] = []; private destroyed = false; private readonly destroyedListeners: Array<() => void> = []; - private domReadyListener: (() => void) | null = null; + private readonly domReadyListeners: Array<() => void> = []; private finishLoadListener: (() => void) | null = null; - private inputListener: - | ((event: { preventDefault(): void }, input: Electron.Input) => void) - | null = null; + private readonly inputListeners: Array< + (event: { preventDefault(): void }, input: Electron.Input) => void + > = []; public constructor(private readonly webContentsId: number) {} @@ -64,13 +64,12 @@ class FakeBrowserContents { return; } if (event === "dom-ready") { - this.domReadyListener = listener as () => void; + this.domReadyListeners.push(listener as () => void); return; } - this.inputListener = listener as ( - event: { preventDefault(): void }, - input: Electron.Input, - ) => void; + this.inputListeners.push( + listener as (event: { preventDefault(): void }, input: Electron.Input) => void, + ); } public send(channel: string, payload: unknown): void { @@ -105,19 +104,23 @@ class FakeBrowserContents { } public domReady(): void { - this.domReadyListener?.(); + for (const listener of this.domReadyListeners) { + listener(); + } } public input(input: Electron.Input): boolean { let wasPrevented = false; - this.inputListener?.( - { - preventDefault: () => { - wasPrevented = true; + for (const listener of this.inputListeners) { + listener( + { + preventDefault: () => { + wasPrevented = true; + }, }, - }, - input, - ); + input, + ); + } return wasPrevented; } } @@ -196,6 +199,20 @@ describe("BrowserKeyboard", () => { ]); }); + test("handles a reserved shortcut once when the same guest attaches again", () => { + const { attach } = createBrowserKeyboard(); + const guest = new FakeBrowserContents(53); + const host = new FakeBrowserContents(54); + attach({ browserId: "browser-a", contents: guest, hostContents: host }); + attach({ browserId: "browser-a", contents: guest, hostContents: host }); + const command = process.platform === "darwin" ? { meta: true } : { control: true }; + + const wasPrevented = guest.input(electronInput({ ...command, code: "KeyR", key: "r" })); + + expect(wasPrevented).toBe(true); + expect(guest.reloads).toEqual(["reload"]); + }); + test("republishes the latest shortcut policy when the next guest document is ready", () => { const { attach, keyboard } = createBrowserKeyboard(); const guest = new FakeBrowserContents(61); diff --git a/packages/desktop/src/features/browser-keyboard/index.ts b/packages/desktop/src/features/browser-keyboard/index.ts index 8084ddb97..0d90f019f 100644 --- a/packages/desktop/src/features/browser-keyboard/index.ts +++ b/packages/desktop/src/features/browser-keyboard/index.ts @@ -87,6 +87,13 @@ export class BrowserKeyboard { if (!registration || registration.hostWebContentsId !== input.hostContents.id) { return; } + const attachedGuest = this.attachedGuestsByWebContentsId.get(webContentsId); + if ( + attachedGuest?.contents === input.contents && + attachedGuest.hostContents === input.hostContents + ) { + return; + } const guest: BrowserKeyboardGuest = input; this.attachedGuestsByWebContentsId.set(webContentsId, guest); diff --git a/packages/desktop/src/features/browser-webviews/registry.test.ts b/packages/desktop/src/features/browser-webviews/registry.test.ts index 93b8c353b..5ef7a6cf5 100644 --- a/packages/desktop/src/features/browser-webviews/registry.test.ts +++ b/packages/desktop/src/features/browser-webviews/registry.test.ts @@ -33,6 +33,32 @@ describe("PaseoBrowserWebviewRegistry", () => { expect(registry.getActiveBrowserIdForHostWindow(101)).toBe("browser-a"); }); + it("keeps the active browser when the same guest registers again", () => { + const registry = new PaseoBrowserWebviewRegistry(); + + registry.registerWebContents({ + webContentsId: 1, + browserId: "browser-a", + hostWebContentsId: 101, + }); + registry.setWorkspaceActiveBrowser({ + hostWebContentsId: 101, + workspaceId: "workspace-a", + browserId: "browser-a", + }); + + registry.registerWebContents({ + webContentsId: 1, + browserId: "browser-a", + hostWebContentsId: 101, + }); + + expect(registry.getActiveBrowserIdForWorkspaceInHostWindow(101, "workspace-a")).toBe( + "browser-a", + ); + expect(registry.getWebContentsIdForBrowserInHostWindow(101, "browser-a")).toBe(1); + }); + it("ignores stale destroy events after a duplicate browserId moved", () => { const registry = new PaseoBrowserWebviewRegistry(); diff --git a/packages/desktop/src/features/browser-webviews/registry.ts b/packages/desktop/src/features/browser-webviews/registry.ts index 4e49fdf6e..4fb8381f3 100644 --- a/packages/desktop/src/features/browser-webviews/registry.ts +++ b/packages/desktop/src/features/browser-webviews/registry.ts @@ -21,6 +21,14 @@ export class PaseoBrowserWebviewRegistry { }): void { const hostBrowserKey = this.hostBrowserKey(input.hostWebContentsId, input.browserId); const replacedWebContentsId = this.webContentsIdsByHostAndBrowserId.get(hostBrowserKey); + const existingRegistration = this.registrationsByWebContentsId.get(input.webContentsId); + if ( + replacedWebContentsId === input.webContentsId && + existingRegistration?.browserId === input.browserId && + existingRegistration.hostWebContentsId === input.hostWebContentsId + ) { + return; + } if (replacedWebContentsId !== undefined && replacedWebContentsId !== input.webContentsId) { this.removeWebContents(replacedWebContentsId, { preserveActiveBrowser: true }); } diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index f2df72603..275f1452b 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -12,6 +12,7 @@ import { existsSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { app, + autoUpdater as electronAutoUpdater, BrowserWindow, clipboard, Menu, @@ -81,7 +82,7 @@ import { stopDesktopDaemonViaCli, } from "./daemon/daemon-manager.js"; import { - createBeforeQuitHandler, + createQuitLifecycle, stopDesktopManagedDaemonOnQuitIfNeeded, } from "./daemon/quit-lifecycle.js"; import { runDesktopStartup } from "./desktop-startup.js"; @@ -89,12 +90,14 @@ import { autoUpdateInstalledSkills } from "./integrations/skills/index.js"; import { registerBrowserAutomationIpc } from "./features/browser-automation/ipc.js"; import { BrowserKeyboard } from "./features/browser-keyboard/index.js"; import { registerMigrationIpc } from "./integrations/migrations/ipc.js"; +import { installAppUpdateOnQuit } from "./features/auto-updater.js"; const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081"; const APP_SCHEME = "paseo"; const PASEO_DEBUG = process.env.PASEO_DEBUG === "1"; const DISABLE_SINGLE_INSTANCE_LOCK = process.env.PASEO_DISABLE_SINGLE_INSTANCE_LOCK === "1"; const APP_NAME = process.env.PASEO_TEST_APP_NAME?.trim() || "Paseo"; +const UPDATE_QUIT_DEADLINE_MS = 5_000; const pendingBrowserWindowOpenRequests = new PendingBrowserWindowOpenRequests(); app.setName(APP_NAME); @@ -930,23 +933,36 @@ function showDaemonShutdownDialog(): void { } } -app.on( - "before-quit", - createBeforeQuitHandler({ - app, - closeTransportSessions: closeAllTransportSessions, - stopDesktopManagedDaemonIfNeeded: () => - stopDesktopManagedDaemonOnQuitIfNeeded({ - settingsStore: getDesktopSettingsStore(), - isDesktopManagedDaemonRunning: isDesktopManagedDaemonRunningSync, - stopDaemon: () => stopDesktopDaemonViaCli("quit"), - showShutdownFeedback: showDaemonShutdownDialog, - }), - onStopError: (error) => { - log.error("[desktop daemon] failed to stop managed daemon on quit", error); - }, - }), -); +const quitLifecycle = createQuitLifecycle({ + app, + closeTransportSessions: closeAllTransportSessions, + stopDesktopManagedDaemonIfNeeded: () => + stopDesktopManagedDaemonOnQuitIfNeeded({ + settingsStore: getDesktopSettingsStore(), + isDesktopManagedDaemonRunning: isDesktopManagedDaemonRunningSync, + stopDaemon: () => stopDesktopDaemonViaCli("quit"), + showShutdownFeedback: showDaemonShutdownDialog, + }), + installAppUpdateOnQuit: async (signal) => { + const settings = await getDesktopSettingsStore().get(); + return installAppUpdateOnQuit({ + currentVersion: app.getVersion(), + releaseChannel: settings.releaseChannel, + signal, + }); + }, + createUpdateDeadlineSignal: () => AbortSignal.timeout(UPDATE_QUIT_DEADLINE_MS), + onStopError: (error) => { + log.error("[desktop daemon] failed to stop managed daemon on quit", error); + }, + onUpdateError: (error) => { + log.error("[auto-updater] failed to validate downloaded update on quit", error); + }, +}); + +// electron-updater forwards this event through Electron's built-in autoUpdater. +electronAutoUpdater.on("before-quit-for-update", quitLifecycle.handleBeforeQuitForUpdate); +app.on("before-quit", quitLifecycle.handleBeforeQuit); app.on("window-all-closed", () => { if (process.platform !== "darwin") { diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 04bded4ad..ef9b00bf0 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.1.110", + "version": "0.2.0-beta.1", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index c5c00b134..ed86dd816 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.1.110", + "version": "0.2.0-beta.1", "files": [ "dist", "!dist/**/*.map" diff --git a/packages/migrate/package.json b/packages/migrate/package.json index efa7787d2..056e3711e 100644 --- a/packages/migrate/package.json +++ b/packages/migrate/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/migrate", - "version": "0.1.110", + "version": "0.2.0-beta.1", "description": "Import projects from other development environments into Paseo", "bin": { "paseo-migrate": "dist/cli.js" @@ -34,8 +34,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/client": "0.1.110", - "@getpaseo/protocol": "0.1.110", + "@getpaseo/client": "0.2.0-beta.1", + "@getpaseo/protocol": "0.2.0-beta.1", "smol-toml": "^1.6.0", "sql.js": "^1.14.1" }, diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 8627cba16..510625f6d 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.1.110", + "version": "0.2.0-beta.1", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/protocol/src/client-capabilities.ts b/packages/protocol/src/client-capabilities.ts index 63e01a5e4..f9ccbeec9 100644 --- a/packages/protocol/src/client-capabilities.ts +++ b/packages/protocol/src/client-capabilities.ts @@ -1,4 +1,8 @@ export const CLIENT_CAPS = { + // COMPAT(selectiveAgentTimeline): added in v0.1.106. Capable clients receive + // agent streams only for their explicit viewed set. Remove after 2027-01-12 + // once the supported client floor is >= v0.1.106. + selectiveAgentTimeline: "selective_agent_timeline", reasoningMergeEnum: "reasoning_merge_enum", // COMPAT(customModeIcons): added in v0.1.84. Old clients pin AgentModeIcon to // a closed enum and crash rendering unknown values; daemon downgrades icons @@ -14,6 +18,8 @@ export const CLIENT_CAPS = { // COMPAT(providerSubagents): added in v0.1.107. The daemon emits provider-owned // child descriptors and timelines only to clients that understand the new messages. providerSubagents: "provider_subagents", + // COMPAT(projectUpdates): added in v0.1.109, remove gate after 2027-01-15. + projectUpdates: "project_updates", browserHost: "browser_host", } as const; diff --git a/packages/protocol/src/messages.hub.test.ts b/packages/protocol/src/messages.hub.test.ts new file mode 100644 index 000000000..ae63b0dc6 --- /dev/null +++ b/packages/protocol/src/messages.hub.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, test } from "vitest"; +import { z } from "zod"; + +import { + HubMessageCorrelationError, + SessionInboundMessageSchema, + SessionOutboundMessageSchema, + parseHubExecutionOutboundMessage, +} from "./messages.js"; + +const agent = { + id: "agent-1", + provider: "codex", + cwd: "/workspace", + model: null, + createdAt: "2026-07-13T00:00:00.000Z", + updatedAt: "2026-07-13T00:00:00.000Z", + lastUserMessageAt: null, + status: "idle", + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: false, + supportsReasoningStream: true, + supportsToolInvocations: true, + supportsRewindConversation: false, + supportsRewindFiles: false, + supportsRewindBoth: false, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: null, + labels: {}, +}; + +// Frozen at the Hub create request shape shipped before worktree and autoArchive. +const PreviousHubAgentCreateRequestSchema = z.object({ + type: z.literal("hub.execution.agent.create.request"), + requestId: z.string(), + executionId: z.string(), + provider: z.string(), + cwd: z.string(), + workspaceId: z.string().optional(), + prompt: z.string(), + model: z.string().optional(), + modeId: z.string().optional(), + thinkingOptionId: z.string().optional(), + featureValues: z.record(z.string(), z.unknown()).optional(), + env: z.record(z.string(), z.string()).optional(), +}); + +describe("Hub session protocol", () => { + test("accepts the Hub execution create request", () => { + const message = { + type: "hub.execution.agent.create.request", + requestId: "request-1", + executionId: "execution-1", + provider: "codex", + cwd: "/workspace", + prompt: "Implement the requested change", + modeId: "code", + }; + + expect(SessionInboundMessageSchema.parse(message)).toEqual(message); + }); + + test.each([ + undefined, + { mode: "branch-off", newBranch: "hub-work", base: "main" }, + { mode: "checkout-branch", branch: "existing-work" }, + { mode: "checkout-pr", prNumber: 42 }, + ])("accepts Hub create worktree target %#", (worktree) => { + const message = { + type: "hub.execution.agent.create.request", + requestId: "hub-worktree", + executionId: "execution-worktree", + provider: "codex", + cwd: "/repo", + prompt: "Work in the requested target", + ...(worktree ? { worktree, autoArchive: true } : {}), + }; + + expect(SessionInboundMessageSchema.parse(message)).toEqual(message); + }); + + test("the previous Hub create parser ignores additive worktree and auto-archive fields", () => { + const newRequest = { + type: "hub.execution.agent.create.request" as const, + requestId: "hub-worktree", + executionId: "execution-worktree", + provider: "codex", + cwd: "/repo", + prompt: "Work in the requested target", + worktree: { mode: "branch-off", newBranch: "hub-work", base: "main" }, + autoArchive: true, + }; + + expect(PreviousHubAgentCreateRequestSchema.parse(newRequest)).toEqual({ + type: "hub.execution.agent.create.request", + requestId: "hub-worktree", + executionId: "execution-worktree", + provider: "codex", + cwd: "/repo", + prompt: "Work in the requested target", + }); + }); + + test.each([ + { + type: "hub.execution.agent.create.response", + payload: { + requestId: "request-1", + executionId: "execution-1", + agentId: "agent-1", + agent, + success: true, + error: null, + }, + }, + { + type: "hub.execution.agent.update", + payload: { executionId: "execution-1", agentId: "agent-1", agent }, + }, + { + type: "hub.execution.agent.stream", + payload: { + executionId: "execution-1", + agentId: "agent-1", + event: { type: "turn_started", provider: "codex" }, + }, + }, + ])("accepts outbound variant $type", (message) => { + expect(SessionOutboundMessageSchema.parse(message)).toEqual(message); + expect(parseHubExecutionOutboundMessage(message)).toEqual(message); + }); + + test("rejects a Hub update whose correlated agent ids disagree", () => { + const malformed = { + type: "hub.execution.agent.update", + payload: { executionId: "execution-1", agentId: "agent-2", agent }, + }; + + expect(SessionOutboundMessageSchema.safeParse(malformed).success).toBe(true); + expect(() => parseHubExecutionOutboundMessage(malformed)).toThrow(HubMessageCorrelationError); + }); + + test.each([ + { + type: "hub.management.daemon.connect.request", + requestId: "r1", + hubUrl: "https://hub.example", + token: "token", + }, + { type: "hub.management.daemon.get_status.request", requestId: "r2" }, + { type: "hub.management.daemon.disconnect.request", requestId: "r3", force: true }, + ])("accepts trusted management request $type", (message) => { + expect(SessionInboundMessageSchema.parse(message)).toEqual(message); + }); + + test.each([ + { + type: "hub.management.daemon.connect.response", + payload: { + requestId: "r1", + status: { + state: "connected", + daemonId: "daemon-1", + hubOrigin: "https://hub.example", + scopes: ["hub.execution.*"], + connectedAt: "2026-07-13T00:00:00.000Z", + lastError: null, + }, + }, + }, + { + type: "hub.management.daemon.get_status.response", + payload: { + requestId: "r2", + status: { + state: "not_connected", + daemonId: null, + hubOrigin: null, + scopes: [], + connectedAt: null, + lastError: null, + }, + }, + }, + { + type: "hub.management.daemon.disconnect.response", + payload: { + requestId: "r3", + status: { + state: "disconnecting", + daemonId: "daemon-1", + hubOrigin: "https://hub.example", + scopes: ["hub.execution.*"], + connectedAt: null, + lastError: "offline", + }, + warning: "pending", + }, + }, + ])("accepts trusted management response $type", (message) => { + expect(SessionOutboundMessageSchema.parse(message)).toEqual(message); + }); +}); diff --git a/packages/protocol/src/messages.test.ts b/packages/protocol/src/messages.test.ts index dec27d458..29dd7d550 100644 --- a/packages/protocol/src/messages.test.ts +++ b/packages/protocol/src/messages.test.ts @@ -437,3 +437,35 @@ describe("daemon update messages", () => { }); }); }); + +describe("viewed timeline subscription messages", () => { + test("parses a complete viewed-agent set and its acknowledgement", () => { + const request = SessionInboundMessageSchema.parse({ + type: "agent.timeline.set_subscription.request", + agentIds: ["agent-a", "agent-b"], + requestId: "timeline-subscription-1", + }); + const response = SessionOutboundMessageSchema.parse({ + type: "agent.timeline.set_subscription.response", + payload: { + agentIds: ["agent-a", "agent-b"], + requestId: "timeline-subscription-1", + }, + }); + + expect({ request, response }).toEqual({ + request: { + type: "agent.timeline.set_subscription.request", + agentIds: ["agent-a", "agent-b"], + requestId: "timeline-subscription-1", + }, + response: { + type: "agent.timeline.set_subscription.response", + payload: { + agentIds: ["agent-a", "agent-b"], + requestId: "timeline-subscription-1", + }, + }, + }); + }); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 0dc878bc1..2063abfc1 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -981,7 +981,7 @@ const AgentAttachmentsSchema = z.unknown().transform(normalizeAgentAttachments). export const ChangeRequestCheckoutSourceSchema = z.object({ kind: z.literal("change_request"), - forge: z.string().optional().default("github"), + forge: z.string().optional(), number: z.number().int().positive(), projectPath: z.string().optional(), }); @@ -1135,6 +1135,22 @@ export const DaemonGetPairingOfferRequestSchema = z.object({ requestId: z.string(), }); +export const HubManagementDaemonConnectRequestSchema = z.object({ + type: z.literal("hub.management.daemon.connect.request"), + requestId: z.string(), + hubUrl: z.string(), + token: z.string(), +}); +export const HubManagementDaemonGetStatusRequestSchema = z.object({ + type: z.literal("hub.management.daemon.get_status.request"), + requestId: z.string(), +}); +export const HubManagementDaemonDisconnectRequestSchema = z.object({ + type: z.literal("hub.management.daemon.disconnect.request"), + requestId: z.string(), + force: z.boolean().optional(), +}); + export const DiagnosticsRequestSchema = z.object({ type: z.literal("diagnostics.request"), requestId: z.string(), @@ -1233,6 +1249,9 @@ export const CreateAgentRequestMessageSchema = z.object({ config: AgentSessionConfigSchema, env: z.record(z.string(), z.string()).optional(), workspaceId: z.string().optional(), + // Optional caller context lets managed CLI invocations use the same daemon-owned + // workspace and parentage policy as agent-scoped MCP creation. + callerAgentId: z.string().optional(), worktreeName: z.string().optional(), initialPrompt: z.string().optional(), clientMessageId: z.string().optional(), @@ -1369,6 +1388,12 @@ export const ProviderSubagentTimelineRequestMessageSchema = z.object({ limit: z.number().int().nonnegative().optional(), }); +export const SetAgentTimelineSubscriptionRequestMessageSchema = z.object({ + type: z.literal("agent.timeline.set_subscription.request"), + agentIds: z.array(z.string()), + requestId: z.string(), +}); + export const AgentForkContextRequestMessageSchema = z.object({ type: z.literal("agent.fork_context.request"), agentId: z.string(), @@ -1737,9 +1762,9 @@ const CheckoutCheckDetailsRequestPayloadSchema = z.object({ checkRunId: z.number().int().positive().optional(), workflowRunId: z.number().int().positive().optional(), // Permanent forge-routing field, optional because only some forges need it: - // GitLab routes the check-details fetch to the change request's head pipeline - // by iid, so a fork/detached MR pipeline (which lives in the source project, - // not the checkout's target project) resolves correctly. GitHub ignores it. + // GitLab routes check details to the MR's head pipeline; Gitea-family adapters + // resolve the PR head SHA by number, including after merge/close. GitHub + // ignores it. changeRequestNumber: z.number().int().positive().optional(), requestId: z.string(), }); @@ -2039,9 +2064,11 @@ export const WorkspaceCreateRequestSchema = z.object({ cwd: z.string().optional(), projectId: z.string().optional(), action: z.enum(["branch-off", "checkout"]).optional(), - // Target branch name for checkout, or new branch name for branch-off. + // Target branch for checkout, or base ref for branch-off. refName: z.string().min(1).optional(), baseBranch: z.string().optional(), + // New branch name for branch-off. The worktree path may use a different slug. + branchName: z.string().min(1).optional(), checkoutSource: ChangeRequestCheckoutSourceSchema.optional(), // COMPAT(githubPrNumber): added in v0.1.106, remove after 2026-12-28 once // clients send checkoutSource. @@ -2306,7 +2333,27 @@ export const CaptureTerminalRequestSchema = z.object({ requestId: z.string(), }); +export const HubExecutionAgentCreateRequestSchema = z.object({ + type: z.literal("hub.execution.agent.create.request"), + requestId: z.string(), + executionId: z.string(), + provider: z.string(), + cwd: z.string(), + prompt: z.string(), + workspaceId: z.string().optional(), + model: z.string().optional(), + modeId: z.string().optional(), + thinkingOptionId: z.string().optional(), + featureValues: z.record(z.string(), z.unknown()).optional(), + env: z.record(z.string(), z.string()).optional(), + worktree: CreateAgentWorktreeTargetSchema.optional(), + autoArchive: z.boolean().optional(), +}); + +export type HubExecutionAgentCreateRequest = z.infer; + export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ + HubExecutionAgentCreateRequestSchema, BrowserAutomationExecuteResponseSchema, VoiceAudioChunkMessageSchema, AbortRequestMessageSchema, @@ -2331,6 +2378,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ WaitForFinishRequestSchema, DaemonGetStatusRequestSchema, DaemonGetPairingOfferRequestSchema, + HubManagementDaemonConnectRequestSchema, + HubManagementDaemonGetStatusRequestSchema, + HubManagementDaemonDisconnectRequestSchema, DiagnosticsRequestSchema, GetDaemonConfigRequestMessageSchema, SetDaemonConfigRequestMessageSchema, @@ -2359,6 +2409,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ FetchAgentTimelineRequestMessageSchema, ProviderSubagentListRequestMessageSchema, ProviderSubagentTimelineRequestMessageSchema, + SetAgentTimelineSubscriptionRequestMessageSchema, AgentForkContextRequestMessageSchema, SetAgentModeRequestMessageSchema, SetAgentModelRequestMessageSchema, @@ -2662,6 +2713,8 @@ export const ServerInfoStatusPayloadSchema = z providerSubagents: z.boolean().optional(), // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. workspacePinning: z.boolean().optional(), + // COMPAT(hubRelationship): added in v0.1.X, drop the gate when floor >= v0.1.X. + hubRelationship: z.boolean().optional(), // COMPAT(projectGithubClone): added in v0.1.108, remove gate after 2027-01-15. projectGithubClone: z.boolean().optional(), // COMPAT(workspaceGithubRepositorySearch): added in v0.1.108, remove gate after 2027-01-15. @@ -2680,6 +2733,10 @@ export const ServerInfoStatusPayloadSchema = z // Daemon advertises pluggable non-GitHub forge support (the forge registry); // the client gates non-GitHub setup UI on it. forgeProviders: z.boolean().optional(), + // COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12. + selectiveAgentTimeline: z.boolean().optional(), + // COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15. + stableProjectIdentity: z.boolean().optional(), }) .optional(), }) @@ -3128,6 +3185,14 @@ export const WorkspaceUpdateMessageSchema = z.object({ ]), }); +export const ProjectUpdateMessageSchema = z.object({ + type: z.literal("project.update"), + payload: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("upsert"), project: WorkspaceProjectDescriptorPayloadSchema }), + z.object({ kind: z.literal("remove"), projectId: z.string() }), + ]), +}); + export const ScriptStatusUpdateMessageSchema = z.object({ type: z.literal("script_status_update"), payload: z.object({ @@ -3427,6 +3492,35 @@ export const ProviderSubagentUpdateMessageSchema = z.object({ ]), }); +export const SetAgentTimelineSubscriptionResponseMessageSchema = z.object({ + type: z.literal("agent.timeline.set_subscription.response"), + payload: z.object({ + agentIds: z.array(z.string()), + requestId: z.string(), + }), +}); + +export const AgentAttentionRequiredMessageSchema = z.object({ + type: z.literal("agent_attention_required"), + payload: z.object({ + agentId: z.string(), + reason: z.enum(["finished", "error", "permission"]), + timestamp: z.string(), + shouldNotify: z.boolean(), + notification: z + .object({ + title: z.string(), + body: z.string(), + data: z.object({ + serverId: z.string(), + agentId: z.string(), + reason: z.enum(["finished", "error", "permission"]), + }), + }) + .optional(), + }), +}); + export const AgentForkContextResponseMessageSchema = z.object({ type: z.literal("agent.fork_context.response"), payload: z.object({ @@ -3552,6 +3646,38 @@ export const DaemonGetStatusResponseSchema = z.object({ .passthrough(), }); +export const HubRelationshipStatusSchema = z.object({ + state: z.enum([ + "not_connected", + "connecting", + "connected", + "reconnecting", + "disconnecting", + "revoked", + ]), + daemonId: z.string().nullable(), + hubOrigin: z.string().nullable(), + scopes: z.array(z.string()), + connectedAt: z.string().nullable(), + lastError: z.string().nullable(), +}); +export const HubManagementDaemonConnectResponseSchema = z.object({ + type: z.literal("hub.management.daemon.connect.response"), + payload: z.object({ requestId: z.string(), status: HubRelationshipStatusSchema }), +}); +export const HubManagementDaemonGetStatusResponseSchema = z.object({ + type: z.literal("hub.management.daemon.get_status.response"), + payload: z.object({ requestId: z.string(), status: HubRelationshipStatusSchema }), +}); +export const HubManagementDaemonDisconnectResponseSchema = z.object({ + type: z.literal("hub.management.daemon.disconnect.response"), + payload: z.object({ + requestId: z.string(), + status: HubRelationshipStatusSchema, + warning: z.string().optional(), + }), +}); + export const DaemonGetPairingOfferResponseSchema = z.object({ type: z.literal("daemon.get_pairing_offer.response"), payload: z @@ -4791,9 +4917,76 @@ export const DaemonUpdateProgressMessageSchema = z.object({ }), }); +export const HubExecutionAgentCreateResponseSchema = z.object({ + type: z.literal("hub.execution.agent.create.response"), + payload: z.object({ + requestId: z.string(), + executionId: z.string(), + agentId: z.string().nullable(), + agent: AgentSnapshotPayloadSchema.nullable(), + success: z.boolean(), + error: z.string().nullable(), + }), +}); + +export const HubExecutionAgentUpdateSchema = z.object({ + type: z.literal("hub.execution.agent.update"), + payload: z.object({ + executionId: z.string(), + agentId: z.string(), + agent: AgentSnapshotPayloadSchema, + }), +}); + +export const HubExecutionAgentStreamSchema = z.object({ + type: z.literal("hub.execution.agent.stream"), + payload: z.object({ + executionId: z.string(), + agentId: z.string(), + event: AgentStreamEventPayloadSchema, + }), +}); + +export type HubExecutionAgentCreateResponse = z.infer; +export type HubExecutionAgentUpdate = z.infer; +export type HubExecutionAgentStream = z.infer; + +export const HubExecutionOutboundMessageSchema = z.discriminatedUnion("type", [ + HubExecutionAgentCreateResponseSchema, + HubExecutionAgentUpdateSchema, + HubExecutionAgentStreamSchema, +]); + +export type HubExecutionOutboundMessage = z.infer; + +export class HubMessageCorrelationError extends Error { + constructor(messageType: HubExecutionOutboundMessage["type"]) { + super(`Hub message ${messageType} has mismatched agent correlation`); + this.name = "HubMessageCorrelationError"; + } +} + +export function parseHubExecutionOutboundMessage(value: unknown): HubExecutionOutboundMessage { + const message = HubExecutionOutboundMessageSchema.parse(value); + const payload = message.payload; + if ( + "agent" in payload && + payload.agent !== null && + "agentId" in payload && + payload.agentId !== null && + payload.agent.id !== payload.agentId + ) { + throw new HubMessageCorrelationError(message.type); + } + return message; +} + export type DaemonUpdateProgressMessage = z.infer; export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ + HubExecutionAgentCreateResponseSchema, + HubExecutionAgentUpdateSchema, + HubExecutionAgentStreamSchema, BrowserAutomationExecuteRequestSchema, ActivityLogMessageSchema, AssistantChunkMessageSchema, @@ -4811,6 +5004,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ArtifactMessageSchema, AgentUpdateMessageSchema, WorkspaceUpdateMessageSchema, + ProjectUpdateMessageSchema, ScriptStatusUpdateMessageSchema, WorkspaceSetupProgressMessageSchema, WorkspaceSetupStatusResponseMessageSchema, @@ -4834,6 +5028,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ProviderSubagentListResponseMessageSchema, ProviderSubagentTimelineResponseMessageSchema, ProviderSubagentUpdateMessageSchema, + SetAgentTimelineSubscriptionResponseMessageSchema, + AgentAttentionRequiredMessageSchema, AgentForkContextResponseMessageSchema, CancelAgentResponseMessageSchema, ClearAgentAttentionResponseMessageSchema, @@ -4843,6 +5039,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ SetVoiceModeResponseMessageSchema, DaemonGetStatusResponseSchema, DaemonGetPairingOfferResponseSchema, + HubManagementDaemonConnectResponseSchema, + HubManagementDaemonGetStatusResponseSchema, + HubManagementDaemonDisconnectResponseSchema, DiagnosticsResponseSchema, GetDaemonConfigResponseMessageSchema, SetDaemonConfigResponseMessageSchema, @@ -5367,9 +5566,11 @@ export const WSHelloMessageSchema = z.object({ voice: z.boolean().optional(), pushNotifications: z.boolean().optional(), [CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(), + [CLIENT_CAPS.selectiveAgentTimeline]: z.boolean().optional(), [CLIENT_CAPS.customModeIcons]: z.boolean().optional(), [CLIENT_CAPS.terminalReflowableSnapshot]: z.boolean().optional(), [CLIENT_CAPS.providerSubagents]: z.boolean().optional(), + [CLIENT_CAPS.projectUpdates]: z.boolean().optional(), [CLIENT_CAPS.browserHost]: BrowserAutomationHostCapabilitySchema.optional(), }) .passthrough() diff --git a/packages/protocol/src/messages.workspaces.test.ts b/packages/protocol/src/messages.workspaces.test.ts index 59c772874..a1c07a5ca 100644 --- a/packages/protocol/src/messages.workspaces.test.ts +++ b/packages/protocol/src/messages.workspaces.test.ts @@ -1031,6 +1031,25 @@ describe("workspace message schemas", () => { expect(newWorktree.type).toBe("workspace.create.request"); expect(newWorktree.source.kind).toBe("worktree"); + const branchOff = WorkspaceCreateRequestSchema.parse({ + type: "workspace.create.request", + requestId: "req-branch-off", + source: { + kind: "worktree", + cwd: "/tmp/repo", + action: "branch-off", + branchName: "feature/auth", + worktreeSlug: "feature-auth", + }, + }); + expect(branchOff.source).toEqual({ + kind: "worktree", + cwd: "/tmp/repo", + action: "branch-off", + branchName: "feature/auth", + worktreeSlug: "feature-auth", + }); + // Directory source must also be accepted. const newDirectory = WorkspaceCreateRequestSchema.parse({ type: "workspace.create.request", diff --git a/packages/protocol/src/provider-manifest.ts b/packages/protocol/src/provider-manifest.ts index 37c79e59a..b3e388faa 100644 --- a/packages/protocol/src/provider-manifest.ts +++ b/packages/protocol/src/provider-manifest.ts @@ -185,7 +185,7 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [ id: "claude", label: "Claude", description: "Anthropic's multi-tool assistant with MCP support, streaming, and deep reasoning", - defaultModeId: "default", + defaultModeId: "auto", modes: CLAUDE_MODES, voice: { enabled: true, @@ -197,7 +197,7 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [ id: "codex", label: "Codex", description: "OpenAI's Codex workspace agent with sandbox controls and optional network access", - defaultModeId: "auto", + defaultModeId: "auto-review", modes: CODEX_MODES, voice: { enabled: true, diff --git a/packages/protocol/src/schedule/cadence.test.ts b/packages/protocol/src/schedule/cadence.test.ts new file mode 100644 index 000000000..3fd3a4af5 --- /dev/null +++ b/packages/protocol/src/schedule/cadence.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { everyMsToFiveFieldCron } from "./cadence.js"; + +describe("everyMsToFiveFieldCron", () => { + it.each([ + [60_000, "*/1 * * * *"], + [15 * 60_000, "*/15 * * * *"], + [60 * 60_000, "0 * * * *"], + [6 * 60 * 60_000, "0 */6 * * *"], + [24 * 60 * 60_000, "0 0 * * *"], + ])("converts %i milliseconds", (everyMs, cron) => { + expect(everyMsToFiveFieldCron(everyMs)).toBe(cron); + }); + + it.each([30_000, 7 * 60_000, 5 * 60 * 60_000, 48 * 60 * 60_000])( + "rejects unrepresentable interval %i", + (everyMs) => { + expect(everyMsToFiveFieldCron(everyMs)).toBeNull(); + }, + ); +}); diff --git a/packages/protocol/src/schedule/cadence.ts b/packages/protocol/src/schedule/cadence.ts new file mode 100644 index 000000000..57abb7e7d --- /dev/null +++ b/packages/protocol/src/schedule/cadence.ts @@ -0,0 +1,27 @@ +/** + * Convert an exact rolling interval to an equivalent five-field cron cadence. + * Returns null when cron's calendar boundaries would change the interval. + */ +export function everyMsToFiveFieldCron(everyMs: number): string | null { + const minutes = everyMs / 60_000; + if (!Number.isInteger(minutes) || minutes <= 0) { + return null; + } + if (minutes < 60 && 60 % minutes === 0) { + return `*/${minutes} * * * *`; + } + if (minutes === 60) { + return "0 * * * *"; + } + if (minutes % 60 !== 0) { + return null; + } + const hours = minutes / 60; + if (hours < 24 && 24 % hours === 0) { + return `0 */${hours} * * *`; + } + if (hours === 24) { + return "0 0 * * *"; + } + return null; +} diff --git a/packages/relay/package.json b/packages/relay/package.json index 5877b3feb..1de1235e5 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.1.110", + "version": "0.2.0-beta.1", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/package.json b/packages/server/package.json index 5b5b3755a..57c9166d8 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.1.110", + "version": "0.2.0-beta.1", "description": "Paseo backend server", "files": [ "dist/server", @@ -66,12 +66,12 @@ }, "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", - "@anthropic-ai/claude-agent-sdk": "^0.3.195", + "@anthropic-ai/claude-agent-sdk": "^0.3.214", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.1.110", - "@getpaseo/highlight": "0.1.110", - "@getpaseo/protocol": "0.1.110", - "@getpaseo/relay": "0.1.110", + "@getpaseo/client": "0.2.0-beta.1", + "@getpaseo/highlight": "0.2.0-beta.1", + "@getpaseo/protocol": "0.2.0-beta.1", + "@getpaseo/relay": "0.2.0-beta.1", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", @@ -101,7 +101,7 @@ "zod": "^4.4.3" }, "devDependencies": { - "@getpaseo/migrate": "0.1.110", + "@getpaseo/migrate": "0.2.0-beta.1", "@playwright/test": "^1.56.1", "@types/express": "^4.17.20", "@types/node": "^20.9.0", diff --git a/packages/server/src/server/agent/agent-loading.ts b/packages/server/src/server/agent/agent-loading.ts index dfe06fc43..e0e806b01 100644 --- a/packages/server/src/server/agent/agent-loading.ts +++ b/packages/server/src/server/agent/agent-loading.ts @@ -20,7 +20,8 @@ export type AgentLoaderManager = Pick< | "getRegisteredProviderIds" | "hydrateTimelineFromProvider" | "resumeAgentFromPersistence" ->; +> & + Partial>; export interface EnsureAgentLoadedDeps { agentManager: AgentLoaderManager; @@ -29,15 +30,45 @@ export interface EnsureAgentLoadedDeps { logger: Logger; } +export async function ensureUnarchivedAgentLoaded( + agentId: string, + deps: EnsureAgentLoadedDeps & { + agentManager: AgentLoaderManager & Pick; + }, +): Promise { + const record = await deps.agentStorage.get(agentId); + if (record?.archivedAt) { + throw new Error(`Agent is archived: ${agentId}`); + } + + const agent = await ensureAgentLoaded(agentId, deps); + const latestRecord = await deps.agentStorage.get(agentId); + if (latestRecord?.archivedAt) { + await deps.agentManager.closeAgent(agentId).catch((error: unknown) => { + deps.logger.warn({ err: error, agentId }, "Failed to close concurrently archived agent"); + }); + throw new Error(`Agent is archived: ${agentId}`); + } + + return agent; +} + export async function ensureAgentLoaded( agentId: string, deps: EnsureAgentLoadedDeps, ): Promise { - const existing = deps.agentManager.getAgent(agentId); + await deps.agentManager.waitForAgentClose?.(agentId); + const existing = + deps.agentManager.touchAgentActivity?.(agentId) ?? deps.agentManager.getAgent(agentId); if (existing) { return existing; } + // A close may have started after the first barrier observed no in-flight + // work. Once the live lookup is empty, this second barrier closes that gap + // before storage-backed resume begins. + await deps.agentManager.waitForAgentClose?.(agentId); + const inflight = pendingAgentInitializations.get(agentId); if (inflight) { return inflight; @@ -75,6 +106,7 @@ export async function ensureAgentLoaded( snapshot = await deps.agentManager.createAgent(config, agentId, { labels: record.labels, workspaceId: record.workspaceId, + owner: record.owner, }); deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config"); } diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 48e32643c..e5f20da37 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -17,6 +17,7 @@ import { AgentStorage } from "./agent-storage.js"; import { toAgentPayload } from "./agent-projections.js"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { formatSystemNotificationPrompt } from "./agent-prompt.js"; +import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent-loading.js"; import type { StoredAgentRecord } from "./agent-storage.js"; import type { AgentClient, @@ -34,6 +35,7 @@ import type { AgentStreamEvent, AgentTimelineItem, ImportProviderSessionInput, + ResolveAgentDefaultModeInput, } from "./agent-sdk-types.js"; import type { PaseoToolCatalog } from "./tools/types.js"; import type { ProviderDefinition } from "./provider-registry.js"; @@ -110,11 +112,15 @@ function expectArchivedAgentRecord( } class TestAgentClient implements AgentClient { - readonly provider = "codex" as const; + readonly provider: AgentProvider; readonly capabilities = TEST_CAPABILITIES; readonly createdConfigs: AgentSessionConfig[] = []; readonly resumeOverrides: Array | undefined> = []; + constructor(provider: AgentProvider = "codex") { + this.provider = provider; + } + async isAvailable(): Promise { return true; } @@ -128,18 +134,18 @@ class TestAgentClient implements AgentClient { return { models: [ { - provider: "codex", + provider: this.provider, id: "gpt-5.4", label: "GPT-5.4", isDefault: true, }, { - provider: "codex", + provider: this.provider, id: "gpt-5.4-mini", label: "GPT-5.4 Mini", }, { - provider: "codex", + provider: this.provider, id: "gpt-5.2-codex", label: "GPT-5.2 Codex", }, @@ -155,7 +161,7 @@ class TestAgentClient implements AgentClient { ): Promise { this.resumeOverrides.push(config); return new TestAgentSession({ - provider: "codex", + provider: this.provider, cwd: config?.cwd ?? process.cwd(), daemonAppendSystemPrompt: config?.daemonAppendSystemPrompt, }); @@ -934,9 +940,43 @@ test("normalizeConfig injects the provider default model when omitted", async () ); expect(snapshot.config.model).toBe("gpt-5.4"); + expect(snapshot.config.modeId).toBe("auto-review"); +}); + +test("normalizeConfig injects Claude's automatic approval default when omitted", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-claude-default-test-")); + const manager = new AgentManager({ + clients: { claude: new TestAgentClient("claude") }, + logger, + }); + + const snapshot = await manager.createAgent({ provider: "claude", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + expect(snapshot.config.modeId).toBe("auto"); }); +test("normalizeConfig uses a capability-aware provider mode default", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-mode-default-test-")); + class CapabilityAwareClient extends TestAgentClient { + override async resolveDefaultModeId(input: ResolveAgentDefaultModeInput): Promise { + return input.env?.CLAUDE_CODE_USE_BEDROCK === "1" ? "default" : "auto"; + } + } + const manager = new AgentManager({ + clients: { codex: new CapabilityAwareClient() }, + logger, + }); + + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + env: { CLAUDE_CODE_USE_BEDROCK: "1" }, + }); + + expect(snapshot.config.modeId).toBe("default"); +}); + test("createAgent forwards request env into the spawned provider process", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-env-test-")); const client = new EnvProbeAgentClient(); @@ -996,7 +1036,7 @@ test("normalizeConfig strips legacy 'default' model id", async () => { ); expect(snapshot.config.model).toBe("gpt-5.4"); - expect(snapshot.config.modeId).toBe("auto"); + expect(snapshot.config.modeId).toBe("auto-review"); }); test("listDraftCommands returns no commands without guessing a missing model", async () => { @@ -1093,7 +1133,7 @@ test("listDraftCommands uses explicit model config without default model fetchin provider: "codex", cwd: workdir, model: "gpt-5.4", - modeId: "auto", + modeId: "auto-review", }, ]); }); @@ -1193,7 +1233,7 @@ test("listDraftFeatures uses explicit model config without default model fetchin provider: "codex", cwd: workdir, model: "gpt-5.4", - modeId: "auto", + modeId: "auto-review", }, ]); }); @@ -1650,7 +1690,7 @@ test("createAgent passes daemon launch env through the provider launch context", provider: "codex", cwd: workdir, model: "gpt-5.4", - modeId: "auto", + modeId: "auto-review", }); expect(client.lastLaunchContext).toEqual({ agentId: snapshot.id, @@ -2447,7 +2487,7 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p }); expect(client.lastResumeOverrides).toMatchObject({ model: "gpt-5.4", - modeId: "auto", + modeId: "auto-review", systemPrompt: "new prompt", mcpServers: { paseo: { @@ -7159,6 +7199,464 @@ test("closeAgent persists one final closed snapshot", async () => { } }); +test("collectIdleAgents releases an idle runtime and resumes the same agent and timeline", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-collection-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + let activeSession: TestAgentSession | null = null; + const client = new (class extends NativeArchiveRecordingClient { + override async createSession(config: AgentSessionConfig): Promise { + activeSession = new TestAgentSession(config); + return activeSession; + } + })(); + const manager = new AgentManager({ + clients: { codex: client }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000210", + }); + + try { + const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: "workspace-idle-collection", + }); + await manager.appendTimelineItem(created.id, { + type: "user_message", + text: "Keep this timeline", + }); + activeSession?.pushEvent({ + type: "provider_subagent", + provider: "codex", + event: { + type: "upsert", + id: "retained-provider-child", + title: "Retained provider child", + status: "completed", + }, + }); + await manager.flush(); + const timelineBeforeCollection = manager.getTimeline(created.id); + + const collection = await manager.collectIdleAgents({ + cutoff: new Date(Date.now() + 1_000), + protectedAgentIds: new Set(), + }); + + expect(collection).toEqual({ + collected: [ + { + agentId: created.id, + provider: "codex", + sessionId: created.persistence?.sessionId, + }, + ], + failures: [], + }); + expect(manager.getAgent(created.id)).toBeNull(); + expect(client.archivedHandles).toEqual([]); + const stored = await storage.get(created.id); + expect(stored).toMatchObject({ + id: created.id, + lastStatus: "closed", + workspaceId: "workspace-idle-collection", + }); + expect(stored?.archivedAt).toBeFalsy(); + + const resumed = await ensureAgentLoaded(created.id, { + agentManager: manager, + agentStorage: storage, + logger, + }); + + expect(resumed.id).toBe(created.id); + expect(resumed.persistence).toEqual(created.persistence); + expect(manager.getTimeline(created.id)).toEqual(timelineBeforeCollection); + expect(manager.listProviderSubagents(created.id)).toEqual([ + expect.objectContaining({ + id: "retained-provider-child", + title: "Retained provider child", + status: "completed", + }), + ]); + const idleBeforeOpen = resumed.updatedAt; + await ensureAgentLoaded(created.id, { + agentManager: manager, + agentStorage: storage, + logger, + }); + await expect( + manager.collectIdleAgents({ cutoff: idleBeforeOpen, protectedAgentIds: new Set() }), + ).resolves.toMatchObject({ collected: [] }); + await expect(manager.runAgent(created.id, "Continue the same agent")).resolves.toMatchObject({ + finalText: "", + canceled: false, + }); + expect(manager.getAgent(created.id)?.id).toBe(created.id); + } finally { + await manager.flush().catch(() => undefined); + await storage.flush().catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("archiving an idle-collected parent still cascades to its managed children", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-collected-parent-archive-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const manager = new AgentManager({ + clients: { codex: new TestAgentClient() }, + registry: storage, + logger, + }); + + try { + const parent = await manager.createAgent( + { provider: "codex", cwd: workdir, title: "Collected parent" }, + undefined, + { workspaceId: undefined }, + ); + const child = await manager.createAgent( + { provider: "codex", cwd: workdir, title: "Managed child" }, + undefined, + { + labels: { [PARENT_AGENT_ID_LABEL]: parent.id }, + workspaceId: undefined, + }, + ); + + await manager.collectIdleAgents({ + cutoff: new Date(Date.now() + 1_000), + protectedAgentIds: new Set([child.id]), + }); + await manager.archiveSnapshot(parent.id, new Date().toISOString()); + + expect((await storage.get(parent.id))?.archivedAt).toEqual(expect.any(String)); + expect((await storage.get(child.id))?.archivedAt).toEqual(expect.any(String)); + expect(manager.getAgent(child.id)).toBeNull(); + } finally { + await manager.flush().catch(() => undefined); + await storage.flush().catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("ensureUnarchivedAgentLoaded does not resume an archived agent", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archived-load-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const manager = new AgentManager({ + clients: { codex: new TestAgentClient() }, + registry: storage, + logger, + }); + + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + await manager.collectIdleAgents({ + cutoff: new Date(Date.now() + 1_000), + protectedAgentIds: new Set(), + }); + await manager.archiveSnapshot(agent.id, new Date().toISOString()); + + await expect( + ensureUnarchivedAgentLoaded(agent.id, { + agentManager: manager, + agentStorage: storage, + logger, + }), + ).rejects.toThrow(`Agent is archived: ${agent.id}`); + expect(manager.getAgent(agent.id)).toBeNull(); + } finally { + await storage.flush().catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("ensureUnarchivedAgentLoaded closes a runtime archived while it resumes", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archived-resume-race-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const resumeStarted = deferred(); + const resumeAllowed = deferred(); + const client = new (class extends TestAgentClient { + override async resumeSession( + handle: AgentPersistenceHandle, + config?: Partial, + launchContext?: AgentLaunchContext, + ): Promise { + resumeStarted.resolve(); + await resumeAllowed.promise; + return super.resumeSession(handle, config, launchContext); + } + })(); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + await manager.collectIdleAgents({ + cutoff: new Date(Date.now() + 1_000), + protectedAgentIds: new Set(), + }); + + const load = ensureUnarchivedAgentLoaded(agent.id, { + agentManager: manager, + agentStorage: storage, + logger, + }); + await resumeStarted.promise; + await manager.archiveSnapshot(agent.id, new Date().toISOString()); + resumeAllowed.resolve(); + + await expect(load).rejects.toThrow(`Agent is archived: ${agent.id}`); + expect(manager.getAgent(agent.id)).toBeNull(); + expect((await storage.get(agent.id))?.archivedAt).toEqual(expect.any(String)); + } finally { + resumeAllowed.resolve(); + await storage.flush().catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("ensureUnarchivedAgentLoaded fences an archived agent after joining a shared resume", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archived-shared-resume-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const resumeStarted = deferred(); + const resumeAllowed = deferred(); + const client = new (class extends TestAgentClient { + override async resumeSession( + handle: AgentPersistenceHandle, + config?: Partial, + launchContext?: AgentLaunchContext, + ): Promise { + resumeStarted.resolve(); + await resumeAllowed.promise; + return super.resumeSession(handle, config, launchContext); + } + })(); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + await manager.collectIdleAgents({ + cutoff: new Date(Date.now() + 1_000), + protectedAgentIds: new Set(), + }); + + const sharedLoad = ensureAgentLoaded(agent.id, { + agentManager: manager, + agentStorage: storage, + logger, + }); + await resumeStarted.promise; + const protectedLoad = ensureUnarchivedAgentLoaded(agent.id, { + agentManager: manager, + agentStorage: storage, + logger, + }); + await manager.archiveSnapshot(agent.id, new Date().toISOString()); + resumeAllowed.resolve(); + + await sharedLoad; + await expect(protectedLoad).rejects.toThrow(`Agent is archived: ${agent.id}`); + expect(manager.getAgent(agent.id)).toBeNull(); + expect((await storage.get(agent.id))?.archivedAt).toEqual(expect.any(String)); + } finally { + resumeAllowed.resolve(); + await storage.flush().catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("collectIdleAgents leaves recent, protected, internal, running, and error agents resident", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-eligibility-")); + const client = new (class extends TestAgentClient { + readonly sessions: TestAgentSession[] = []; + + override async createSession(config: AgentSessionConfig): Promise { + const session = new TestAgentSession(config); + this.sessions.push(session); + return session; + } + })(); + const ids = [ + "00000000-0000-4000-8000-000000000211", + "00000000-0000-4000-8000-000000000212", + "00000000-0000-4000-8000-000000000213", + "00000000-0000-4000-8000-000000000214", + "00000000-0000-4000-8000-000000000215", + ]; + const manager = new AgentManager({ + clients: { codex: client }, + logger, + idFactory: () => ids.shift()!, + }); + + try { + const recent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + const protectedAgent = await manager.createAgent( + { provider: "codex", cwd: workdir }, + undefined, + { workspaceId: undefined }, + ); + const internal = await manager.createAgent( + { provider: "codex", cwd: workdir, internal: true }, + undefined, + { workspaceId: undefined }, + ); + const running = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + const failed = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + client.sessions[3]!.pushEvent({ + type: "turn_started", + provider: "codex", + turnId: "autonomous-running", + }); + client.sessions[4]!.pushEvent({ + type: "turn_failed", + provider: "codex", + turnId: "autonomous-failed", + error: "provider failed", + }); + await manager.flush(); + + const recentSweep = await manager.collectIdleAgents({ + cutoff: new Date(recent.updatedAt.getTime() - 1), + protectedAgentIds: new Set(), + }); + const protectedSweep = await manager.collectIdleAgents({ + cutoff: new Date(Date.now() + 1_000), + protectedAgentIds: new Set([protectedAgent.id, recent.id]), + }); + + expect(recentSweep.collected).toEqual([]); + expect(protectedSweep.collected).toEqual([]); + expect(manager.getAgent(recent.id)?.lifecycle).toBe("idle"); + expect(manager.getAgent(protectedAgent.id)?.lifecycle).toBe("idle"); + expect(manager.getAgent(internal.id)?.lifecycle).toBe("idle"); + expect(manager.getAgent(running.id)?.lifecycle).toBe("running"); + expect(manager.getAgent(failed.id)?.lifecycle).toBe("error"); + } finally { + await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))).catch( + () => undefined, + ); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("load waits for an in-flight collection close and creates only one resumed runtime", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-close-race-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const closeStarted = deferred(); + const closeAllowed = deferred(); + const client = new (class extends TestAgentClient { + resumeCount = 0; + + override async createSession(config: AgentSessionConfig): Promise { + return new (class extends TestAgentSession { + override async close(): Promise { + closeStarted.resolve(); + await closeAllowed.promise; + } + })(config); + } + + override async resumeSession( + handle: AgentPersistenceHandle, + config?: Partial, + ): Promise { + this.resumeCount += 1; + return super.resumeSession(handle, config); + } + })(); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + + try { + const created = await manager.createAgent( + { provider: "codex", cwd: workdir }, + "00000000-0000-4000-8000-000000000216", + { workspaceId: undefined }, + ); + const collection = manager.collectIdleAgents({ + cutoff: new Date(Date.now() + 1_000), + protectedAgentIds: new Set(), + }); + await closeStarted.promise; + const loads = Promise.all([ + ensureAgentLoaded(created.id, { agentManager: manager, agentStorage: storage, logger }), + ensureAgentLoaded(created.id, { agentManager: manager, agentStorage: storage, logger }), + ]); + + expect(client.resumeCount).toBe(0); + closeAllowed.resolve(); + const [first, second] = await loads; + await collection; + + expect(first.id).toBe(created.id); + expect(second.id).toBe(created.id); + expect(client.resumeCount).toBe(1); + } finally { + await manager.closeAgent("00000000-0000-4000-8000-000000000216").catch(() => undefined); + await storage.flush().catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("provider close failure still persists and emits a resumable closed agent", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-close-failure-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const client = new (class extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + return new (class extends TestAgentSession { + override async close(): Promise { + throw new Error("provider cleanup failed"); + } + })(config); + } + })(); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + + try { + const created = await manager.createAgent( + { provider: "codex", cwd: workdir }, + "00000000-0000-4000-8000-000000000217", + { workspaceId: undefined }, + ); + const closed = waitForAgentLifecycle(manager, created.id, "closed"); + + const collection = await manager.collectIdleAgents({ + cutoff: new Date(Date.now() + 1_000), + protectedAgentIds: new Set(), + }); + await closed; + expect(collection.collected).toEqual([]); + expect(collection.failures).toHaveLength(1); + expect(collection.failures[0]).toMatchObject({ + agentId: created.id, + provider: "codex", + error: expect.objectContaining({ message: "provider cleanup failed" }), + }); + const stored = await storage.get(created.id); + expect(stored).toMatchObject({ lastStatus: "closed" }); + expect(stored?.archivedAt).toBeFalsy(); + + await expect( + ensureAgentLoaded(created.id, { agentManager: manager, agentStorage: storage, logger }), + ).resolves.toMatchObject({ id: created.id, lifecycle: "idle" }); + } finally { + await manager.closeAgent("00000000-0000-4000-8000-000000000217").catch(() => undefined); + await storage.flush().catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("hydrateTimeline keeps provider user_message items when no canonical user history exists", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-keep-user-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 73f51346e..0bcbc2ca8 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -44,6 +44,7 @@ import { } from "./agent-sdk-types.js"; import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js"; import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js"; +import type { AgentOwner } from "./agent-owner.js"; import { InMemoryAgentTimelineStore, type SeedAgentTimelineOptions, @@ -116,6 +117,7 @@ interface PreparedSessionConfig { interface NormalizeConfigOptions { resolveDefaultModel?: boolean; + env?: Record; } interface TimeoutOptions { @@ -232,6 +234,7 @@ export interface CreateAgentOptions { initialTitle?: string | null; // undefined is an explicit decision: the agent never appears in the sidebar. workspaceId: string | undefined; + owner?: AgentOwner; } export interface AgentManagerOptions { @@ -306,6 +309,7 @@ interface ManagedAgentBase { * Null/undefined for legacy agents created before ownership stamping. */ workspaceId?: string; + owner?: AgentOwner; capabilities: AgentCapabilityFlags; config: AgentSessionConfig; runtimeInfo?: AgentRuntimeInfo; @@ -388,6 +392,21 @@ export interface AgentMetricsSnapshot { }; } +export interface IdleAgentCollectionEntry { + agentId: string; + provider: AgentProvider; + sessionId?: string; +} + +export interface IdleAgentCollectionFailure extends IdleAgentCollectionEntry { + error: unknown; +} + +export interface IdleAgentCollectionResult { + collected: IdleAgentCollectionEntry[]; + failures: IdleAgentCollectionFailure[]; +} + type ActiveManagedAgent = | ManagedAgentInitializing | ManagedAgentIdle @@ -565,6 +584,7 @@ export class AgentManager { private readonly previousStatuses = new Map(); private readonly backgroundTasks = new Set>(); private readonly agentRegistrationTasks = new Set>(); + private readonly inFlightAgentCloses = new Map>(); private readonly agentStreamCoalescer: AgentStreamCoalescer; private mcpBaseUrl: string | null; private readonly mcpAuthToken: string | null; @@ -780,6 +800,10 @@ export class AgentManager { }; } + subscriptionCount(): number { + return this.subscribers.size; + } + listAgents(): ManagedAgent[] { return Array.from(this.agents.values()) .filter((agent) => !agent.internal) @@ -942,6 +966,19 @@ export class AgentManager { return agent ? { ...agent } : null; } + touchAgentActivity(id: string): ManagedAgent | null { + const agent = this.agents?.get(id); + if (!agent) { + return null; + } + this.touchUpdatedAt(agent); + return { ...agent }; + } + + async waitForAgentClose(agentId: string): Promise { + await this.inFlightAgentCloses?.get(agentId)?.catch(() => undefined); + } + getTimeline(id: string): AgentTimelineItem[] { this.requireAgent(id); return this.timelineStore.getItems(id); @@ -997,7 +1034,12 @@ export class AgentManager { ): Promise { this.assertAcceptingAgentRegistrations(); const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent"); - const { storedConfig, launchConfig } = await this.prepareSessionConfig(config, resolvedAgentId); + await this.deleteAgentState(resolvedAgentId); + const { storedConfig, launchConfig } = await this.prepareSessionConfig( + config, + resolvedAgentId, + options?.env, + ); this.requireEnabledProvider(storedConfig.provider); const client = await this.requireAvailableClient({ provider: storedConfig.provider, @@ -1010,6 +1052,7 @@ export class AgentManager { labels: options.labels, initialTitle: options.initialTitle, workspaceId: options.workspaceId, + owner: options.owner, }); } @@ -1033,6 +1076,7 @@ export class AgentManager { lastUserMessageAt?: Date | null; labels?: Record; workspaceId?: string; + owner?: AgentOwner; }, ): Promise { return this.trackAgentRegistrationOperation( @@ -1050,6 +1094,7 @@ export class AgentManager { lastUserMessageAt?: Date | null; labels?: Record; workspaceId?: string; + owner?: AgentOwner; }, ): Promise { this.assertAcceptingAgentRegistrations(); @@ -1078,7 +1123,10 @@ export class AgentManager { const launchContext = await this.buildLaunchContext(resolvedAgentId, client); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const session = await client.resumeSession(handle, providerLaunchConfig, launchContext); - return this.registerSession(session, storedConfig, resolvedAgentId, options); + return this.registerSession(session, storedConfig, resolvedAgentId, { + ...options, + persistence: handle, + }); } importProviderSession(input: { @@ -1229,6 +1277,7 @@ export class AgentManager { return this.registerSession(session, storedConfig, agentId, { labels: existing.labels, workspaceId: existing.workspaceId, + owner: existing.owner, createdAt: existing.createdAt, updatedAt: existing.updatedAt, lastUserMessageAt: existing.lastUserMessageAt, @@ -1298,7 +1347,24 @@ export class AgentManager { } } - async closeAgent(agentId: string): Promise { + closeAgent(agentId: string): Promise { + const existing = this.inFlightAgentCloses.get(agentId); + if (existing) { + return existing; + } + + const close = this.closeAgentRuntime(agentId); + this.inFlightAgentCloses.set(agentId, close); + const clearClose = () => { + if (this.inFlightAgentCloses.get(agentId) === close) { + this.inFlightAgentCloses.delete(agentId); + } + }; + void close.then(clearClose, clearClose); + return close; + } + + private async closeAgentRuntime(agentId: string): Promise { const agent = this.requireAgent(agentId); this.logger.trace( { @@ -1313,12 +1379,19 @@ export class AgentManager { "agent.manager.close.start", ); const closedAgent = this.prepareAgentForClosure(agent, "agent closed"); - await agent.session.close(); - this.timelineStore.delete(agentId); - for (const event of this.providerSubagents.deleteParent(agentId)) { - this.dispatch({ type: "provider_subagent", event }); + let closeError: unknown; + try { + await agent.session.close(); + } catch (error) { + closeError = error; + } + + let persistError: unknown; + try { + await this.persistSnapshot(closedAgent); + } catch (error) { + persistError = error; } - await this.persistSnapshot(closedAgent); this.emitClosedAgent(closedAgent, { persist: false }); this.logger.trace( { @@ -1328,6 +1401,58 @@ export class AgentManager { }, "agent.manager.close.complete", ); + + if (closeError !== undefined) { + throw closeError; + } + if (persistError !== undefined) { + throw persistError; + } + } + + async collectIdleAgents(options: { + cutoff: Date; + protectedAgentIds: ReadonlySet; + }): Promise { + const result: IdleAgentCollectionResult = { collected: [], failures: [] }; + + for (const agent of Array.from(this.agents.values())) { + const current = this.agents.get(agent.id); + if (!current || !this.isIdleAgentCollectable(current, options)) { + continue; + } + + const entry: IdleAgentCollectionEntry = { + agentId: current.id, + provider: current.provider, + ...(current.persistence?.sessionId ? { sessionId: current.persistence.sessionId } : {}), + }; + try { + await this.closeAgent(current.id); + result.collected.push(entry); + } catch (error) { + result.failures.push({ ...entry, error }); + } + } + + return result; + } + + private isIdleAgentCollectable( + agent: LiveManagedAgent, + options: { cutoff: Date; protectedAgentIds: ReadonlySet }, + ): agent is ManagedAgentIdle { + return ( + agent.lifecycle === "idle" && + agent.updatedAt.getTime() <= options.cutoff.getTime() && + !agent.internal && + !options.protectedAgentIds.has(agent.id) && + agent.activeForegroundTurnId === null && + !this.runs.hasRun(agent.id) && + !agent.pendingReplacement && + agent.pendingPermissions.size === 0 && + agent.inFlightPermissionResponses.size === 0 + ); } async archiveAgent(agentId: string): Promise<{ archivedAt: string }> { @@ -1347,6 +1472,7 @@ export class AgentManager { const { archivedAt } = await this.markRecordArchived(stored); agent.updatedAt = new Date(archivedAt); await this.closeAgent(agentId); + this.discardRetainedAgentState(agentId); await this.cascadeArchiveChildren(agentId); @@ -1373,8 +1499,7 @@ export class AgentManager { if (this.agents.has(record.id)) { await this.archiveAgent(record.id); } else { - await this.markRecordArchived(record); - await this.cascadeArchiveChildren(record.id); + await this.archiveSnapshot(record.id, new Date().toISOString()); } } } @@ -1420,6 +1545,7 @@ export class AgentManager { provider: record.provider, cwd: record.cwd, workspaceId: record.workspaceId, + owner: record.owner, session: null, capabilities: STORED_AGENT_CAPABILITIES, config: buildStoredAgentConfig(record), @@ -1660,11 +1786,15 @@ export class AgentManager { if (this.agents.has(agentId)) { this.notifyAgentState(agentId); - } else if (!nextRecord.internal) { - this.dispatchArchivedStoredAgent(nextRecord); + } else { + this.discardRetainedAgentState(agentId); + if (!nextRecord.internal) { + this.dispatchArchivedStoredAgent(nextRecord); + } } await this.fireAgentArchived(agentId); + await this.cascadeArchiveChildren(agentId); return nextRecord; } @@ -2315,6 +2445,11 @@ export class AgentManager { await this.durableTimelineStore.deleteAgent(agentId); } + async deleteAgentState(agentId: string): Promise { + this.discardRetainedAgentState(agentId); + await this.deleteCommittedTimeline(agentId); + } + async getLastAssistantMessage(agentId: string): Promise { const agent = this.agents.get(agentId); if (!agent) { @@ -2574,6 +2709,7 @@ export class AgentManager { initialTitle?: string | null; publishWhenReady?: boolean; workspaceId?: string; + owner?: AgentOwner; }, ): Promise { let registered = false; @@ -2623,6 +2759,7 @@ export class AgentManager { await this.refreshSessionState(managed, { emit: false }); this.assertAgentRegistrationActive(managed); managed.lifecycle = "idle"; + this.touchUpdatedAt(managed); await this.persistSnapshot(managed); this.assertAgentRegistrationActive(managed); this.emitState(managed, { persist: false }); @@ -2671,6 +2808,7 @@ export class AgentManager { | undefined; }): Promise<{ durableTimelineHasRows: boolean }> { const { agentId, now, options } = params; + const timelineAlreadyPrimed = this.timelineStore.has(agentId); const explicitTimelineSeed = buildExplicitTimelineSeedForRegister(now, options); const shouldSeedFromDurable = !explicitTimelineSeed && @@ -2680,7 +2818,8 @@ export class AgentManager { ? await this.loadCommittedTimelineSeed(agentId, now) : null; const durableTimelineHasRows = - durableTimelineSeed != null && (durableTimelineSeed.nextSeq ?? 1) > 1; + timelineAlreadyPrimed || + (durableTimelineSeed != null && (durableTimelineSeed.nextSeq ?? 1) > 1); const timelineSeed = explicitTimelineSeed ?? durableTimelineSeed; if (timelineSeed || !this.timelineStore.has(agentId)) { this.timelineStore.initialize(agentId, timelineSeed ?? { timestamp: now.toISOString() }); @@ -2709,6 +2848,7 @@ export class AgentManager { attention?: AttentionState; persistence?: AgentPersistenceHandle; workspaceId?: string; + owner?: AgentOwner; } | undefined; }): ActiveManagedAgent { @@ -2718,6 +2858,7 @@ export class AgentManager { provider: config.provider, cwd: config.cwd, workspaceId: options?.workspaceId, + owner: options?.owner, session, capabilities: session.capabilities, config, @@ -2786,9 +2927,23 @@ export class AgentManager { lifecycle: "closed", session: null, activeForegroundTurnId: null, + pendingPermissions: new Map(), + bufferedPermissionResolutions: new Map(), + inFlightPermissionResponses: new Set(), + pendingReplacement: false, + foregroundTurnWaiters: new Set(), + finalizedForegroundTurnIds: new Set(), + unsubscribeSession: null, }; } + private discardRetainedAgentState(agentId: string): void { + this.timelineStore.delete(agentId); + for (const event of this.providerSubagents.deleteParent(agentId)) { + this.dispatch({ type: "provider_subagent", event }); + } + } + private emitClosedAgent(agent: ManagedAgentClosed, options?: { persist?: boolean }): void { this.emitState(agent, options); } @@ -3925,17 +4080,27 @@ export class AgentManager { } if (!normalized.modeId) { - try { - normalized.modeId = - getAgentProviderDefinition(normalized.provider).defaultModeId ?? undefined; - } catch { - // Unknown provider - } + normalized.modeId = await this.resolveDefaultModeId(normalized, options.env); } return normalized; } + private async resolveDefaultModeId( + config: AgentSessionConfig, + env?: Record, + ): Promise { + const providerDefault = await this.clients + .get(config.provider) + ?.resolveDefaultModeId?.({ config, env }); + if (providerDefault) return providerDefault; + try { + return getAgentProviderDefinition(config.provider).defaultModeId ?? undefined; + } catch { + return undefined; + } + } + private async resolveDefaultModelId(config: AgentSessionConfig): Promise { const client = this.clients.get(config.provider); if (!client) { @@ -3957,8 +4122,9 @@ export class AgentManager { private async prepareSessionConfig( config: AgentSessionConfig, agentId: string, + env?: Record, ): Promise { - const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config)); + const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), { env }); const launchConfig = this.applyDaemonAppendSystemPrompt( withRuntimePaseoMcpServer({ config: storedConfig, diff --git a/packages/server/src/server/agent/agent-owner.ts b/packages/server/src/server/agent/agent-owner.ts new file mode 100644 index 000000000..7dd9e9442 --- /dev/null +++ b/packages/server/src/server/agent/agent-owner.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +export const AgentOwnerSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("daemon"), + daemonId: z.string(), + executionId: z.string(), + }), +]); + +export type AgentOwner = z.infer; +export type DaemonAgentOwner = Extract; + +export function daemonExecutionKey(owner: DaemonAgentOwner): string { + return `${owner.daemonId}\0${owner.executionId}`; +} diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index 6adee4bd5..a01c67b29 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -93,6 +93,7 @@ export function toStoredAgentRecord( ? agent.attention.attentionTimestamp.toISOString() : null, internal: options?.internal, + owner: agent.owner, } satisfies StoredAgentRecord; } diff --git a/packages/server/src/server/agent/agent-prompt.test.ts b/packages/server/src/server/agent/agent-prompt.test.ts index d726509a8..f42061698 100644 --- a/packages/server/src/server/agent/agent-prompt.test.ts +++ b/packages/server/src/server/agent/agent-prompt.test.ts @@ -38,13 +38,17 @@ function createCapturedLogger(): CapturedLogger { interface FinishNotificationScenarioOptions { childLastAssistantMessage?: string | null; + childParentAgentId?: string | null; + requireParentOwnership?: boolean; parentPromptError?: Error; logger?: Logger; } interface FinishNotificationScenario { startWatchingChild(): void; + finishChild(): void; finishChildAndReadParentPrompt(): Promise; + wasParentPrompted(): boolean; } function createFinishNotificationScenario( @@ -52,6 +56,7 @@ function createFinishNotificationScenario( ): FinishNotificationScenario { let subscriber: ((event: AgentManagerEvent) => void) | null = null; let resolveParentPrompt: ((prompt: string) => void) | null = null; + let parentPrompted = false; const childAgent: ManagedAgent = Object.create(null); Reflect.set(childAgent, "id", "child-agent"); @@ -85,6 +90,7 @@ function createFinishNotificationScenario( Reflect.set(agentManager, "tryRunOutOfBand", () => false); Reflect.set(agentManager, "hasInFlightRun", () => Boolean(options?.parentPromptError)); Reflect.set(agentManager, "streamAgent", (_agentId: string, prompt: string) => { + parentPrompted = true; resolveParentPrompt?.(prompt); return (async function* noop() {})(); }); @@ -96,7 +102,12 @@ function createFinishNotificationScenario( const agentStorage: AgentStorage = Object.create(AgentStorage.prototype); Reflect.set(agentStorage, "get", async (agentId: string) => { if (agentId === "child-agent") { - return { title: "Child Agent" }; + const parentAgentId = + options?.childParentAgentId === undefined ? "caller-agent" : options.childParentAgentId; + return { + title: "Child Agent", + labels: parentAgentId ? { "paseo.parent-agent-id": parentAgentId } : {}, + }; } return null; }); @@ -108,14 +119,11 @@ function createFinishNotificationScenario( agentStorage, childAgentId: "child-agent", callerAgentId: "caller-agent", + requireParentOwnership: options?.requireParentOwnership, logger: options?.logger ?? createTestLogger(), }); }, - async finishChildAndReadParentPrompt() { - const parentPrompt = new Promise((resolve) => { - resolveParentPrompt = resolve; - }); - + finishChild() { childAgent.lifecycle = "running"; subscriber?.({ type: "agent_state", @@ -127,9 +135,18 @@ function createFinishNotificationScenario( type: "agent_state", agent: childAgent, }); + }, + async finishChildAndReadParentPrompt() { + const parentPrompt = new Promise((resolve) => { + resolveParentPrompt = resolve; + }); + this.finishChild(); return parentPrompt; }, + wasParentPrompted() { + return parentPrompted; + }, }; } @@ -192,6 +209,26 @@ test("finish notifications tell the parent the child's last assistant message", ); }); +test("detaching a child ends its parent-owned finish notification", async () => { + const scenario = createFinishNotificationScenario({ + childParentAgentId: null, + requireParentOwnership: true, + }); + scenario.startWatchingChild(); + scenario.finishChild(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(scenario.wasParentPrompted()).toBe(false); +}); + +test("follow-up finish notifications do not require a parent relationship", async () => { + const scenario = createFinishNotificationScenario({ childParentAgentId: "another-agent" }); + + scenario.startWatchingChild(); + const parentPrompt = await scenario.finishChildAndReadParentPrompt(); + + expect(parentPrompt).toContain("Agent child-agent (Child Agent) finished."); +}); + test("finish notifications log a rejected parent prompt without an unhandled rejection", async () => { const captured = createCapturedLogger(); const scenario = createFinishNotificationScenario({ diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index 325fa0dcb..96b4c9c29 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -4,6 +4,7 @@ import type { AgentPromptInput, AgentRunOptions } from "./agent-sdk-types.js"; import type { AgentManager, ManagedAgent } from "./agent-manager.js"; import type { AgentStorage } from "./agent-storage.js"; import { ensureAgentLoaded } from "./agent-loading.js"; +import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels"; export type AgentUnarchiveController = Pick; @@ -244,6 +245,7 @@ export interface SetupFinishNotificationParams { agentStorage: AgentStorage; childAgentId: string; callerAgentId: string; + requireParentOwnership?: boolean; logger: Logger; } @@ -264,7 +266,14 @@ function formatFinishNotificationBody(params: FinishNotificationBodyInput): stri } export function setupFinishNotification(params: SetupFinishNotificationParams): void { - const { agentManager, agentStorage, childAgentId, callerAgentId, logger } = params; + const { + agentManager, + agentStorage, + childAgentId, + callerAgentId, + requireParentOwnership = false, + logger, + } = params; let hasSeenRunning = false; let fired = false; let unsubscribe: (() => void) | null = null; @@ -282,6 +291,9 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): } const record = await agentStorage.get(childAgentId); + if (requireParentOwnership && getParentAgentIdFromLabels(record?.labels) !== callerAgentId) { + return; + } const title = record?.title ?? childAgentId; const lastAssistantMessage = await agentManager.getLastAssistantMessage(childAgentId); const body = formatFinishNotificationBody({ diff --git a/packages/server/src/server/agent/agent-response-loop.ts b/packages/server/src/server/agent/agent-response-loop.ts index d3a3de010..a76f82fda 100644 --- a/packages/server/src/server/agent/agent-response-loop.ts +++ b/packages/server/src/server/agent/agent-response-loop.ts @@ -382,6 +382,8 @@ export async function generateStructuredAgentResponse( await manager.closeAgent(agent.id); } catch { // ignore cleanup errors + } finally { + await manager.deleteAgentState(agent.id).catch(() => undefined); } } } diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 16da95c05..c5b8e37b1 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -630,6 +630,7 @@ export interface AgentSession { ): Promise; describePersistence(): AgentPersistenceHandle | null; interrupt(): Promise; + /** Release live runtime resources without archiving or deleting the durable native session. */ close(): Promise; listCommands?(): Promise; setModel?(modelId: string | null): Promise; @@ -667,6 +668,12 @@ export type FetchCatalogOptions = export interface ProviderCatalog { models: AgentModelDefinition[]; modes: AgentMode[]; + defaultModeId?: string | null; +} + +export interface ResolveAgentDefaultModeInput { + config: AgentSessionConfig; + env?: Record; } export interface AgentClient { @@ -689,6 +696,7 @@ export interface AgentClient { * The registry is responsible for merging configured model overrides. */ fetchCatalog(options: FetchCatalogOptions): Promise; + resolveDefaultModeId?(input: ResolveAgentDefaultModeInput): Promise; resolveCreateConfig?(input: ResolveAgentCreateConfigInput): ResolveAgentCreateConfigResult; isCreateConfigUnattended?(input: AgentCreateConfigUnattendedInput): boolean; listCommands?(config: AgentSessionConfig): Promise; @@ -707,12 +715,12 @@ export interface AgentClient { isAvailable(): Promise; getDiagnostic?(): Promise<{ diagnostic: string }>; /** - * Archive a persisted session in the native provider (best-effort). + * 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; /** - * Unarchive a persisted session in the native provider. + * Unarchive a durable native session in the provider. * Called before Paseo clears its archived flag so provider resume can succeed. */ unarchiveNativeSession?(handle: AgentPersistenceHandle): Promise; diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index 069865f66..5e504cee6 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -1,4 +1,4 @@ -import { promises as fs } from "node:fs"; +import { promises as fs, type Dirent } from "node:fs"; import path from "node:path"; import { z } from "zod"; import type { Logger } from "pino"; @@ -8,6 +8,7 @@ import { AgentFeatureSchema, AgentStatusSchema } from "../messages.js"; import { toStoredAgentRecord } from "./agent-projections.js"; import type { ManagedAgent } from "./agent-manager.js"; import type { AgentSessionConfig } from "./agent-sdk-types.js"; +import { AgentOwnerSchema, daemonExecutionKey, type DaemonAgentOwner } from "./agent-owner.js"; const SERIALIZABLE_CONFIG_SCHEMA = z .object({ @@ -64,6 +65,7 @@ const STORED_AGENT_SCHEMA = z.object({ attentionTimestamp: z.string().nullable().optional(), internal: z.boolean().optional(), archivedAt: z.string().nullable().optional(), + owner: AgentOwnerSchema.optional(), }); export type SerializableAgentConfig = Pick< @@ -88,6 +90,8 @@ export class AgentStorage { private pathsById: Map> = new Map(); private pendingWrites: Map> = new Map(); private deleting: Set = new Set(); + private daemonAgentIdsByExecution: Map = new Map(); + private daemonExecutionKeysByAgentId: Map = new Map(); private loaded = false; private baseDir: string; private loadPromise: Promise | null = null; @@ -112,6 +116,12 @@ export class AgentStorage { return this.cache.get(agentId) ?? null; } + async findByDaemonExecution(owner: DaemonAgentOwner): Promise { + await this.load(); + const agentId = this.daemonAgentIdsByExecution.get(daemonExecutionKey(owner)); + return agentId ? (this.cache.get(agentId) ?? null) : null; + } + async upsert(record: StoredAgentRecord): Promise { await this.load(); await this.queueRecordWrite(record); @@ -157,6 +167,7 @@ export class AgentStorage { } this.cache.set(agentId, record); + this.indexOwner(record); this.pathById.set(agentId, nextPath); } @@ -186,6 +197,7 @@ export class AgentStorage { ); this.cache.delete(agentId); + this.removeOwnerIndex(agentId); this.pathById.delete(agentId); this.pathsById.delete(agentId); } @@ -248,6 +260,8 @@ export class AgentStorage { this.cache.clear(); this.pathById.clear(); this.pathsById.clear(); + this.daemonAgentIdsByExecution.clear(); + this.daemonExecutionKeysByAgentId.clear(); try { const records = await this.scanDisk(); @@ -266,7 +280,7 @@ export class AgentStorage { private async scanDisk(): Promise { const records: StoredAgentRecord[] = []; - let entries: Array = []; + let entries: Dirent[] = []; try { entries = await fs.readdir(this.baseDir, { withFileTypes: true }); } catch (error) { @@ -310,6 +324,7 @@ export class AgentStorage { const { record, filePath } = item; records.push(record); this.cache.set(record.id, record); + this.indexOwner(record); this.pathById.set(record.id, filePath); this.addIndexedPath(record.id, filePath); } @@ -350,6 +365,28 @@ export class AgentStorage { } } + private indexOwner(record: StoredAgentRecord): void { + this.removeOwnerIndex(record.id); + if (record.owner?.kind === "daemon") { + const key = daemonExecutionKey(record.owner); + const previousAgentId = this.daemonAgentIdsByExecution.get(key); + if (previousAgentId && previousAgentId !== record.id) { + this.daemonExecutionKeysByAgentId.delete(previousAgentId); + } + this.daemonAgentIdsByExecution.set(key, record.id); + this.daemonExecutionKeysByAgentId.set(record.id, key); + } + } + + private removeOwnerIndex(agentId: string): void { + const key = this.daemonExecutionKeysByAgentId.get(agentId); + if (!key) return; + if (this.daemonAgentIdsByExecution.get(key) === agentId) { + this.daemonAgentIdsByExecution.delete(key); + } + this.daemonExecutionKeysByAgentId.delete(agentId); + } + private async waitForPendingWrite(agentId: string): Promise { await (this.pendingWrites.get(agentId) ?? Promise.resolve()).catch(() => undefined); } diff --git a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.test.ts b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.test.ts new file mode 100644 index 000000000..bdb903043 --- /dev/null +++ b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from "vitest"; + +import type { AgentManagerEvent, AgentSubscriber } from "./agent-manager.js"; +import { registerAgentAutoArchive } from "./create-agent-lifecycle-dispatch.js"; + +class AgentLifecycleEvents { + private readonly listeners = new Set(); + + subscribe(listener: AgentSubscriber): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + completeTurn(agentId: string): void { + const event: AgentManagerEvent = { + type: "agent_stream", + agentId, + event: { type: "turn_completed", provider: "codex" }, + }; + for (const listener of this.listeners) listener(event); + } + + listenerCount(): number { + return this.listeners.size; + } +} + +test("auto-archive self-releases once and later cancellation waits harmlessly", async () => { + const agentId = "4a7e2521-286d-4ad5-af35-e091c55302e3"; + const agents = new AgentLifecycleEvents(); + let archiveCount = 0; + const registration = registerAgentAutoArchive({ + agentManager: agents, + agentId, + archive: async () => { + archiveCount += 1; + }, + }); + + agents.completeTurn(agentId); + await registration.cancel(); + await registration.cancel(); + agents.completeTurn(agentId); + + expect(archiveCount).toBe(1); + expect(agents.listenerCount()).toBe(0); +}); 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 31f77500d..e4c29c958 100644 --- a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts +++ b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts @@ -3,11 +3,7 @@ import type pino from "pino"; import type { ForgeService } from "../../services/forge-service.js"; import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js"; -import { - archiveByScope, - type ActiveWorkspaceRef, - resolveWorkspaceIdAtPath, -} from "../workspace-archive-service.js"; +import { archiveByScope, type ActiveWorkspaceRef } from "../workspace-archive-service.js"; import type { CreatePaseoWorktreeWorkflowFn, CreatePaseoWorktreeWorkflowResult, @@ -18,7 +14,7 @@ import type { FirstAgentContext, SessionOutboundMessage, } from "../messages.js"; -import type { AgentManager } from "./agent-manager.js"; +import type { AgentManager, AgentSubscriber, SubscribeOptions } from "./agent-manager.js"; import type { AgentStorage } from "./agent-storage.js"; interface CreateAgentLifecycleDispatchDependencies { @@ -42,6 +38,20 @@ interface CreateAgentLifecycleDispatchDependencies { logger: pino.Logger; } +export interface LifecycleRegistration { + cancel(): Promise; +} + +interface AgentLifecycleEvents { + subscribe(callback: AgentSubscriber, options?: SubscribeOptions): () => void; +} + +const inactiveRegistration: LifecycleRegistration = { cancel: async () => undefined }; + +type AutoArchiveTarget = + | { kind: "agent-only" } + | { kind: "created-worktree"; result: CreatePaseoWorktreeWorkflowResult }; + export class CreateAgentLifecycleDispatch { private readonly autoArchiveAgentIds = new Set(); @@ -67,15 +77,15 @@ export class CreateAgentLifecycleDispatch { autoArchive: boolean | undefined; agentId: string; createdWorktree: CreatePaseoWorktreeWorkflowResult | null; - }): void { + }): LifecycleRegistration { if (input.autoArchive !== true) { - return; + return inactiveRegistration; } - this.registerAutoArchiveOnTerminalState(input.agentId, { - worktreePath: input.createdWorktree?.worktree.worktreePath ?? null, - repoRoot: input.createdWorktree?.repoRoot ?? null, - }); + return this.registerAutoArchiveOnTerminalState( + input.agentId, + toAutoArchiveTarget(input.createdWorktree), + ); } async cleanupCreatedWorktreeAfterFailedAgentCreate(input: { @@ -89,8 +99,7 @@ export class CreateAgentLifecycleDispatch { await this.archiveAutoCreatedWorktree({ agentId: null, - worktreePath: createdWorktree.worktree.worktreePath, - repoRoot: createdWorktree.repoRoot, + createdWorktree, }).catch((archiveError) => { this.dependencies.logger.warn( { @@ -145,42 +154,26 @@ export class CreateAgentLifecycleDispatch { private registerAutoArchiveOnTerminalState( agentId: string, - options: { worktreePath: string | null; repoRoot: string | null }, - ): void { - const unsubscribe = this.dependencies.agentManager.subscribe( - (event) => { - if (event.type !== "agent_stream") { - return; - } - if ( - event.event.type !== "turn_completed" && - event.event.type !== "turn_failed" && - event.event.type !== "turn_canceled" - ) { - return; - } - unsubscribe(); - void this.autoArchiveAgentOnce(agentId, options); - }, - { agentId, replayState: false }, - ); + target: AutoArchiveTarget, + ): LifecycleRegistration { + return registerAgentAutoArchive({ + agentManager: this.dependencies.agentManager, + agentId, + archive: () => this.autoArchiveAgentOnce(agentId, target), + }); } - private async autoArchiveAgentOnce( - agentId: string, - options: { worktreePath: string | null; repoRoot: string | null }, - ): Promise { + private async autoArchiveAgentOnce(agentId: string, target: AutoArchiveTarget): Promise { if (this.autoArchiveAgentIds.has(agentId)) { return; } this.autoArchiveAgentIds.add(agentId); try { - if (options.worktreePath) { + if (target.kind === "created-worktree") { await this.archiveAutoCreatedWorktree({ agentId, - worktreePath: options.worktreePath, - repoRoot: options.repoRoot, + createdWorktree: target.result, }); return; } @@ -193,10 +186,11 @@ export class CreateAgentLifecycleDispatch { private async archiveAutoCreatedWorktree(options: { agentId: string | null; - worktreePath: string; - repoRoot: string | null; + createdWorktree: CreatePaseoWorktreeWorkflowResult; }): Promise { - const ownership = await isPaseoOwnedWorktreeCwd(options.worktreePath, { + const { createdWorktree } = options; + const worktreePath = createdWorktree.worktree.worktreePath; + const ownership = await isPaseoOwnedWorktreeCwd(worktreePath, { paseoHome: this.dependencies.paseoHome, worktreesRoot: this.dependencies.worktreesRoot, }); @@ -204,49 +198,76 @@ export class CreateAgentLifecycleDispatch { throw new Error("Auto-created worktree is not a Paseo-owned worktree"); } - const workspaceId = await resolveWorkspaceIdAtPath( + await archiveByScope( { + paseoHome: this.dependencies.paseoHome, + paseoWorktreesBaseRoot: this.dependencies.worktreesRoot, + github: this.dependencies.github, + workspaceGitService: this.dependencies.workspaceGitService, + agentManager: this.dependencies.agentManager, + agentStorage: this.dependencies.agentStorage, findWorkspaceIdForCwd: this.dependencies.findWorkspaceIdForCwd, listActiveWorkspaces: this.dependencies.listActiveWorkspaces, + archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord, + emitWorkspaceUpdatesForWorkspaceIds: this.dependencies.emitWorkspaceUpdatesForWorkspaceIds, + markWorkspaceArchiving: this.dependencies.markWorkspaceArchiving, + clearWorkspaceArchiving: this.dependencies.clearWorkspaceArchiving, + killTerminalsForWorkspace: this.dependencies.killTerminalsForWorkspace, + sessionLogger: this.dependencies.logger, + }, + { + scope: { kind: "workspace", workspaceId: createdWorktree.workspace.workspaceId }, + requestId: randomUUID(), }, - options.worktreePath, ); - if (!workspaceId) { - this.dependencies.logger.warn( - { worktreePath: options.worktreePath }, - "Could not resolve workspace for auto-archive; skipping", - ); - } else { - await archiveByScope( - { - paseoHome: this.dependencies.paseoHome, - paseoWorktreesBaseRoot: this.dependencies.worktreesRoot, - github: this.dependencies.github, - workspaceGitService: this.dependencies.workspaceGitService, - agentManager: this.dependencies.agentManager, - agentStorage: this.dependencies.agentStorage, - findWorkspaceIdForCwd: this.dependencies.findWorkspaceIdForCwd, - listActiveWorkspaces: this.dependencies.listActiveWorkspaces, - archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord, - emitWorkspaceUpdatesForWorkspaceIds: - this.dependencies.emitWorkspaceUpdatesForWorkspaceIds, - markWorkspaceArchiving: this.dependencies.markWorkspaceArchiving, - clearWorkspaceArchiving: this.dependencies.clearWorkspaceArchiving, - killTerminalsForWorkspace: this.dependencies.killTerminalsForWorkspace, - sessionLogger: this.dependencies.logger, - }, - { - scope: { kind: "workspace", workspaceId }, - repoRoot: options.repoRoot ?? ownership.repoRoot ?? null, - paseoWorktreesBaseRoot: this.dependencies.worktreesRoot, - requestId: randomUUID(), - }, - ); - } - if (options.agentId) { this.dependencies.emitAgentRemove(options.agentId); } } } + +export function registerAgentAutoArchive(input: { + agentManager: AgentLifecycleEvents; + agentId: string; + archive: () => Promise; +}): LifecycleRegistration { + let unsubscribe: (() => void) | null = null; + let archiveTask: Promise | null = null; + const release = () => { + if (!unsubscribe) return; + const subscribed = unsubscribe; + unsubscribe = null; + subscribed(); + }; + const registration: LifecycleRegistration = { + async cancel() { + release(); + await archiveTask; + }, + }; + unsubscribe = input.agentManager.subscribe( + (event) => { + if (event.type !== "agent_stream") return; + if ( + event.event.type !== "turn_completed" && + event.event.type !== "turn_failed" && + event.event.type !== "turn_canceled" + ) { + return; + } + release(); + archiveTask = input.archive(); + }, + { agentId: input.agentId, replayState: false }, + ); + return registration; +} + +function toAutoArchiveTarget( + createdWorktree: CreatePaseoWorktreeWorkflowResult | null, +): AutoArchiveTarget { + return createdWorktree + ? { kind: "created-worktree", result: createdWorktree } + : { kind: "agent-only" }; +} 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 5b51fe521..e5c90efc2 100644 --- a/packages/server/src/server/agent/create-agent/create.test.ts +++ b/packages/server/src/server/agent/create-agent/create.test.ts @@ -27,12 +27,13 @@ function createRealAgentManager(storage: AgentStorage): AgentManager { // worktree service). function fakeWorktreeCreator(args: { repoRoot: string; createdWorkspaceId: string }) { const worktreePath = join(args.repoRoot, "worktree"); - mkdirSync(worktreePath, { recursive: true }); + const workspaceCwd = join(worktreePath, "packages", "app"); + mkdirSync(workspaceCwd, { recursive: true }); return async (): Promise => ({ worktree: { worktreePath }, intent: {}, - workspace: { workspaceId: args.createdWorkspaceId }, + workspace: { workspaceId: args.createdWorkspaceId, cwd: workspaceCwd }, repoRoot: args.repoRoot, created: true, setupContinuation: { kind: "agent" as const, startAfterAgentCreate: () => {} }, @@ -321,6 +322,59 @@ test("mcp create stamps the new worktree's workspaceId, not the parent's", async const storedChild = await storage.get(child.id); expect(storedChild?.workspaceId).toBe("ws-new-worktree"); + expect(child.cwd).toBe(join(workdir, "worktree", "packages", "app")); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("mcp create exposes the created worktree before dispatching the initial prompt", async () => { + const workdir = mkdtempSync(join(tmpdir(), "create-agent-worktree-callback-test-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const agentManager = createRealAgentManager(storage); + const createdWorktree = await fakeWorktreeCreator({ + repoRoot: workdir, + createdWorkspaceId: "ws-created-worktree", + })(); + let observed: + | { + createdWorktree: CreatePaseoWorktreeWorkflowResult | null; + lifecycle: ManagedAgent["lifecycle"] | null; + } + | undefined; + + try { + await createAgentCommand( + { + agentManager, + agentStorage: storage, + logger, + providerSnapshotManager: { + async resolveCreateConfig() { + return {}; + }, + }, + createPaseoWorktree: async () => createdWorktree, + }, + { + kind: "mcp", + provider: "codex", + cwd: workdir, + title: "worktree callback", + initialPrompt: "Say done.", + background: true, + notifyOnFinish: false, + worktree: { worktreeName: "feature", baseBranch: "main" }, + onCreated: ({ agentId, createdWorktree: callbackWorktree }) => { + observed = { + createdWorktree: callbackWorktree, + lifecycle: agentManager.getAgent(agentId)?.lifecycle ?? null, + }; + }, + }, + ); + + expect(observed).toEqual({ createdWorktree, lifecycle: "idle" }); } finally { rmSync(workdir, { recursive: true, force: true }); } diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index 7954ec1d4..182edc833 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -1,6 +1,5 @@ import type { Logger } from "pino"; -import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import type { TerminalManager } from "../../../terminal/terminal-manager.js"; import type { CreatePaseoWorktreeInput } from "../../paseo-worktree-service.js"; import { expandUserPath, resolvePathFromBase } from "../../path-utils.js"; @@ -15,6 +14,7 @@ import type { AgentAttachment, FirstAgentContext, GitSetupOptions } from "../../ import type { AgentManager, CreateAgentOptions, ManagedAgent } from "../agent-manager.js"; import type { AgentPromptInput, AgentRunOptions, AgentSessionConfig } from "../agent-sdk-types.js"; import type { AgentStorage } from "../agent-storage.js"; +import type { AgentOwner } from "../agent-owner.js"; import type { ProviderSnapshotManager } from "../provider-snapshot-manager.js"; import { setupFinishNotification, startCreatedAgentInitialPrompt } from "../agent-prompt.js"; import { resolveCreateAgentTitles } from "../create-agent-title.js"; @@ -25,6 +25,7 @@ import { appendTimelineItemIfAgentKnown, emitLiveTimelineItemIfAgentKnown, } from "../timeline-append.js"; +import { resolveCreateAgentIntent } from "./intent.js"; export interface CreateAgentSessionWorktreeResult { sessionConfig: AgentSessionConfig; @@ -41,7 +42,7 @@ export interface CreateAgentCommandDependencies { paseoHome?: string; worktreesRoot?: string; terminalManager?: TerminalManager | null; - providerSnapshotManager: ProviderSnapshotManager; + providerSnapshotManager: Pick; createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn; // Mints a fresh directory workspace for a cwd and returns its id. ensureWorkspaceForCreate?: EnsureWorkspaceForCreate; @@ -93,6 +94,13 @@ export interface CreateAgentFromMcpInput { notifyOnFinish: boolean; internal?: boolean; detached?: boolean; + owner?: AgentOwner; + env?: Record; + onCreated?: (created: { + agentId: string; + createdWorktree: CreatePaseoWorktreeWorkflowResult | null; + }) => void; + onWorktreeCreated?: (createdWorktree: CreatePaseoWorktreeWorkflowResult) => void; callerAgentId?: string; callerContext?: { lockedCwd?: string; @@ -118,6 +126,7 @@ export interface CreateAgentCommandResult { background: boolean; initialPromptStarted: boolean; initialPromptError: unknown | null; + createdWorktree?: CreatePaseoWorktreeWorkflowResult; } export type BoundCreateAgentCommand = ( @@ -158,6 +167,7 @@ interface ResolvedCreateAgent { background: boolean; promptFailure: CreateAgentPromptFailureMode; promptLogger?: Logger; + createdWorktree?: CreatePaseoWorktreeWorkflowResult; } export async function createAgentCommand( @@ -182,6 +192,9 @@ export async function createAgentCommand( let liveSnapshot = snapshot; let initialPromptStarted = false; let initialPromptError: unknown | null = null; + if (input.kind === "mcp") { + input.onCreated?.({ agentId: snapshot.id, createdWorktree: resolved.createdWorktree ?? null }); + } if (resolved.prompt !== undefined) { const sendResult = await sendInitialPrompt(dependencies, resolved, snapshot); initialPromptStarted = sendResult.started; @@ -195,6 +208,7 @@ export async function createAgentCommand( agentStorage: dependencies.agentStorage, childAgentId: snapshot.id, callerAgentId: input.callerAgentId, + requireParentOwnership: true, logger: dependencies.logger, }); } @@ -205,6 +219,7 @@ export async function createAgentCommand( background: resolved.background, initialPromptStarted, initialPromptError, + ...(resolved.createdWorktree ? { createdWorktree: resolved.createdWorktree } : {}), }; } @@ -294,20 +309,30 @@ async function resolveMcpCreateAgent( ? requireParentAgent(dependencies.agentManager, input.callerAgentId) : null; const cwd = resolveMcpInitialCwd(input, parentAgent); - const { resolvedCwd, setupContinuation, createdWorkspaceId } = await resolveMcpCwd({ - dependencies, - cwd, - worktree: input.worktree, - initialPrompt: input.initialPrompt ?? "", - }); + const { resolvedCwd, setupContinuation, createdWorkspaceId, createdWorktree } = + await resolveMcpCwd({ + dependencies, + cwd, + worktree: input.worktree, + initialPrompt: input.initialPrompt ?? "", + }); + if (createdWorktree) input.onWorktreeCreated?.(createdWorktree); - const workspaceId = await resolveMcpWorkspaceId({ - dependencies, - input, - parentAgent, - setupContinuation, - createdWorkspaceId, - resolvedCwd, + const intent = await resolveCreateAgentIntent({ + explicitWorkspaceId: setupContinuation ? createdWorkspaceId : input.workspaceId, + caller: parentAgent + ? { id: parentAgent.id, cwd: parentAgent.cwd, workspaceId: parentAgent.workspaceId } + : null, + labels: input.labels, + childAgentDefaultLabels: input.callerContext?.childAgentDefaultLabels, + legacyDetached: input.detached ?? false, + resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: resolvedCwd }), + createWorkspace: async () => ({ + workspaceId: requireResolvedWorkspaceId( + await ensureWorkspaceForMcpCreate(dependencies, resolvedCwd, input.initialPrompt ?? ""), + ), + cwd: resolvedCwd, + }), }); const resolvedCreateConfig = await resolveMcpProviderCreateConfig({ dependencies, @@ -317,30 +342,26 @@ async function resolveMcpCreateAgent( parentAgent, }); - const labels = mergeLabels({ - callerAgentId: input.callerAgentId, - detached: input.detached ?? false, - childAgentDefaultLabels: input.callerContext?.childAgentDefaultLabels, - labels: input.labels, - }); - const trimmedPrompt = input.initialPrompt?.trim() ?? ""; return { config: buildMcpSessionConfig({ input, resolvedProviderModel, provider, - resolvedCwd, + resolvedCwd: intent.cwd, trimmedPrompt, resolvedMode: resolvedCreateConfig.modeId, resolvedFeatures: resolvedCreateConfig.featureValues, }), createOptions: { - ...(labels ? { labels } : {}), - workspaceId: requireResolvedWorkspaceId(workspaceId), + ...(Object.keys(intent.labels).length > 0 ? { labels: intent.labels } : {}), + workspaceId: intent.workspaceId, + owner: input.owner, + env: input.env, }, prompt: trimmedPrompt ? trimmedPrompt : undefined, setupContinuation, + createdWorktree, background: input.background, promptFailure: input.promptFailure ?? "log", }; @@ -361,34 +382,6 @@ function resolveMcpInitialCwd( }); } -async function resolveMcpWorkspaceId(params: { - dependencies: CreateAgentCommandDependencies; - input: CreateAgentFromMcpInput; - parentAgent: ManagedAgent | null; - setupContinuation?: AgentWorktreeSetupContinuation; - createdWorkspaceId?: string; - resolvedCwd: string; -}): Promise { - // MCP callers resolve workspace ownership before this point. Worktree - // creation wins because the new agent lives in the fresh worktree workspace. - // Otherwise use the explicit workspace id, then the parent workspace for - // direct internal callers. Ownership is never resolved from cwd. - if (params.setupContinuation) { - return params.createdWorkspaceId; - } - if (params.input.workspaceId) { - return params.input.workspaceId; - } - if (params.parentAgent?.workspaceId) { - return params.parentAgent.workspaceId; - } - return ensureWorkspaceForMcpCreate( - params.dependencies, - params.resolvedCwd, - params.input.initialPrompt ?? "", - ); -} - async function resolveMcpProviderCreateConfig(params: { dependencies: CreateAgentCommandDependencies; input: CreateAgentFromMcpInput; @@ -518,6 +511,7 @@ async function resolveMcpCwd(params: { resolvedCwd: string; setupContinuation?: AgentWorktreeSetupContinuation; createdWorkspaceId?: string; + createdWorktree?: CreatePaseoWorktreeWorkflowResult; }> { const { dependencies, worktree } = params; if (!worktree) { @@ -573,9 +567,10 @@ async function resolveMcpCwd(params: { }, }); return { - resolvedCwd: createdWorktree.worktree.worktreePath, + resolvedCwd: createdWorktree.workspace.cwd, setupContinuation: createdWorktree.setupContinuation, createdWorkspaceId: createdWorktree.workspace.workspaceId, + createdWorktree, }; } @@ -603,22 +598,3 @@ async function createMcpWorktree( throw toWorktreeRequestError(error); } } - -function mergeLabels(params: { - callerAgentId: string | undefined; - detached: boolean; - childAgentDefaultLabels: Record | undefined; - labels: Record | undefined; -}): Record | undefined { - const mergedLabels = { - ...(!params.detached && params.callerAgentId - ? { [PARENT_AGENT_ID_LABEL]: params.callerAgentId } - : {}), - ...params.childAgentDefaultLabels, - ...params.labels, - }; - if (params.detached) { - delete mergedLabels[PARENT_AGENT_ID_LABEL]; - } - return Object.keys(mergedLabels).length > 0 ? mergedLabels : undefined; -} diff --git a/packages/server/src/server/agent/create-agent/intent.test.ts b/packages/server/src/server/agent/create-agent/intent.test.ts new file mode 100644 index 000000000..38f390d3f --- /dev/null +++ b/packages/server/src/server/agent/create-agent/intent.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; +import { resolveCreateAgentIntent } from "./intent.js"; + +describe("resolveCreateAgentIntent", () => { + it("keeps caller parentage when an explicit workspace changes placement", async () => { + const intent = await resolveCreateAgentIntent({ + explicitWorkspaceId: "workspace-isolated", + caller: { id: "parent-agent", cwd: "/parent", workspaceId: "workspace-parent" }, + labels: { purpose: "review" }, + resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: "/isolated" }), + createWorkspace: async () => ({ workspaceId: "workspace-created", cwd: "/created" }), + }); + + expect(intent).toEqual({ + workspaceId: "workspace-isolated", + cwd: "/isolated", + parentAgentId: "parent-agent", + labels: { + purpose: "review", + [PARENT_AGENT_ID_LABEL]: "parent-agent", + }, + }); + }); + + it("defaults an agent caller to its workspace without creating one", async () => { + let createCount = 0; + const intent = await resolveCreateAgentIntent({ + caller: { id: "parent-agent", cwd: "/parent", workspaceId: "workspace-parent" }, + resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: "/unused" }), + createWorkspace: async () => { + createCount += 1; + return { workspaceId: "workspace-created", cwd: "/created" }; + }, + }); + + expect(intent.workspaceId).toBe("workspace-parent"); + expect(intent.cwd).toBe("/parent"); + expect(intent.parentAgentId).toBe("parent-agent"); + expect(createCount).toBe(0); + }); + + it("creates a workspace for a human caller with no workspace context", async () => { + const intent = await resolveCreateAgentIntent({ + caller: null, + resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: "/unused" }), + createWorkspace: async () => ({ workspaceId: "workspace-created", cwd: "/created" }), + }); + + expect(intent).toEqual({ + workspaceId: "workspace-created", + cwd: "/created", + parentAgentId: null, + labels: {}, + }); + }); + + it("keeps legacy detached creation independent", async () => { + const intent = await resolveCreateAgentIntent({ + caller: { id: "parent-agent", cwd: "/parent", workspaceId: "workspace-parent" }, + labels: { [PARENT_AGENT_ID_LABEL]: "spoofed-parent" }, + legacyDetached: true, + resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: "/unused" }), + createWorkspace: async () => ({ workspaceId: "workspace-created", cwd: "/created" }), + }); + + expect(intent).toEqual({ + workspaceId: "workspace-parent", + cwd: "/parent", + parentAgentId: null, + labels: {}, + }); + }); +}); diff --git a/packages/server/src/server/agent/create-agent/intent.ts b/packages/server/src/server/agent/create-agent/intent.ts new file mode 100644 index 000000000..c811144b7 --- /dev/null +++ b/packages/server/src/server/agent/create-agent/intent.ts @@ -0,0 +1,64 @@ +import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; + +export interface CreateAgentCaller { + id: string; + cwd: string; + workspaceId?: string; +} + +export interface CreateAgentPlacement { + workspaceId: string; + cwd: string; +} + +export interface CreateAgentIntent { + workspaceId: string; + cwd: string; + parentAgentId: string | null; + labels: Record; +} + +export async function resolveCreateAgentIntent(input: { + explicitWorkspaceId?: string; + caller: CreateAgentCaller | null; + labels?: Record; + childAgentDefaultLabels?: Record; + resolveWorkspace: (workspaceId: string) => Promise; + createWorkspace: () => Promise; + legacyDetached?: boolean; +}): Promise { + const parentAgentId = input.legacyDetached ? null : (input.caller?.id ?? null); + const placement = await resolvePlacement(input); + const labels = { + ...input.childAgentDefaultLabels, + ...input.labels, + ...(parentAgentId ? { [PARENT_AGENT_ID_LABEL]: parentAgentId } : {}), + }; + + // COMPAT(detachedCreate): legacy callers may still request detached creation. + // Added in v0.2.0; remove after 2027-01-17 once detached creation is outside the floor. + // The delete also strips a parent label injected through input.labels. + if (input.legacyDetached) { + delete labels[PARENT_AGENT_ID_LABEL]; + } + + return { ...placement, parentAgentId, labels }; +} + +async function resolvePlacement(input: { + explicitWorkspaceId?: string; + caller: CreateAgentCaller | null; + resolveWorkspace: (workspaceId: string) => Promise; + createWorkspace: () => Promise; +}): Promise { + if (input.explicitWorkspaceId) { + return input.resolveWorkspace(input.explicitWorkspaceId); + } + if (input.caller) { + if (!input.caller.workspaceId) { + throw new Error(`Caller agent ${input.caller.id} has no workspace`); + } + return { workspaceId: input.caller.workspaceId, cwd: input.caller.cwd }; + } + return input.createWorkspace(); +} diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 594a1794a..532389815 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -22,9 +22,12 @@ import { AgentSnapshotPayloadSchema, } from "@getpaseo/protocol/messages"; import { + createPersistedProjectRecord, createPersistedWorkspaceRecord, type PersistedProjectRecord, type PersistedWorkspaceRecord, + type ProjectRegistry, + type WorkspaceRegistry, } from "../workspace-registry.js"; import type { CreateScheduleInput, @@ -46,11 +49,13 @@ import { WorkspaceAutoName } from "../workspace-auto-name.js"; import { createGitMutationService } from "../session/git-mutation/git-mutation-service.js"; import type { GeneratedWorkspaceName } from "../worktree-branch-name-generator.js"; import type { ForgeService } from "../../services/forge-service.js"; +import { areEquivalentPaths } from "../../utils/path.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-tools/broker.js"; 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"; const REPO_CWD = resolvePath("/tmp/repo"); const TARGET_CWD = resolvePath("/tmp/target"); @@ -670,13 +675,68 @@ function createPaseoWorktreeForMcpTest(options: { paseoHome: options.paseoHome, deps: { forgeOverrides: { github } }, }); - const workspaceRegistry = { + const projectRegistry: ProjectRegistry = { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => Array.from(projects.values()), + get: async (projectId) => projects.get(projectId) ?? null, + getOrCreateActiveByRoot: async (allocation) => { + const existing = Array.from(projects.values()).find( + (project) => + areEquivalentPaths(project.rootPath, allocation.rootPath) && !project.archivedAt, + ); + if (existing) return existing; + const project = createPersistedProjectRecord({ + projectId: `prj_test_${projects.size + 1}`, + rootPath: allocation.rootPath, + kind: allocation.kind, + displayName: allocation.displayName, + createdAt: allocation.timestamp, + updatedAt: allocation.timestamp, + }); + projects.set(project.projectId, project); + return project; + }, + upsert: async (record) => { + projects.set(record.projectId, record); + }, + archive: async (projectId, archivedAt) => { + const project = projects.get(projectId); + if (project) projects.set(projectId, { ...project, archivedAt }); + }, + remove: async (projectId) => { + projects.delete(projectId); + }, + }; + const workspaceRegistry: WorkspaceRegistry = { + initialize: async () => {}, + existsOnDisk: async () => true, get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null, list: async () => Array.from(workspaces.values()), + update: async (workspaceId, updater) => { + const workspace = workspaces.get(workspaceId); + if (!workspace) return null; + const updated = updater(workspace); + workspaces.set(workspaceId, updated); + return updated; + }, upsert: async (record: PersistedWorkspaceRecord) => { workspaces.set(record.workspaceId, record); }, + archive: async (workspaceId, archivedAt) => { + const workspace = workspaces.get(workspaceId); + if (workspace) workspaces.set(workspaceId, { ...workspace, archivedAt }); + }, + remove: async (workspaceId) => { + workspaces.delete(workspaceId); + }, }; + const workspaceProvisioning = createWorkspaceProvisioningService({ + projectRegistry, + workspaceRegistry, + workspaceGitService, + logger: createTestLogger(), + }); const workspaceAutoName = new WorkspaceAutoName({ agentManager: buildAgentManagerSpies() as unknown as AgentManager, workspaceRegistry, @@ -710,14 +770,8 @@ function createPaseoWorktreeForMcpTest(options: { ...(workflowOptions?.resolveDefaultBranch ? { resolveDefaultBranch: workflowOptions.resolveDefaultBranch } : {}), - projectRegistry: { - get: async (projectId) => projects.get(projectId) ?? null, - upsert: async (record) => { - projects.set(record.projectId, record); - }, - }, - workspaceRegistry, workspaceGitService, + workspaceProvisioning, }), warmWorkspaceGitData: async () => {}, autoNameWorkspaceBranchForFirstAgent: (autoNameInput) => @@ -1148,6 +1202,42 @@ describe("create_agent MCP tool", () => { ).toBe(true); }); + it("creates a fresh local workspace for canonical top-level creation", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + spies.agentManager.createAgent.mockResolvedValue({ + id: "top-level-agent", + provider: "codex", + cwd: existingCwd, + workspaceId: "workspace-created", + lifecycle: "idle", + currentModeId: null, + availableModes: [], + config: { title: "Top-level agent" }, + } as ManagedAgent); + const ensureWorkspace = vi.fn(async () => "workspace-created"); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + ensureWorkspaceForCreate: ensureWorkspace, + logger, + }); + + await registeredTool(server, "create_agent").handler({ + title: "Top-level agent", + provider: "codex/gpt-5.4", + initialPrompt: "Do work", + background: true, + }); + + expect(ensureWorkspace).toHaveBeenCalledWith(existingCwd, { prompt: "Do work" }); + expect(spies.agentManager.createAgent).toHaveBeenCalledWith( + expect.objectContaining({ cwd: existingCwd }), + undefined, + { workspaceId: "workspace-created" }, + ); + }); + it("rejects partial explicit workspace shape", async () => { const { agentManager, agentStorage } = createTestDeps(); const server = await createAgentMcpServer({ @@ -1454,7 +1544,7 @@ describe("create_agent MCP tool", () => { expect(parsed.success).toBe(true); }); - it("accepts each create_worktree target kind", async () => { + it("exposes workspace tools instead of worktree tools", async () => { const { agentManager, agentStorage } = createTestDeps(); const server = await createAgentMcpServer({ agentManager, @@ -1462,31 +1552,14 @@ describe("create_agent MCP tool", () => { providerSnapshotManager: createOpenCodeManager().manager, logger, }); - const tool = registeredTool(server, "create_worktree"); - - for (const target of [ - { kind: "branch-off", worktreeSlug: "feature-x", baseBranch: "main" }, - { kind: "branch-off" }, - { kind: "checkout-branch", branch: "head-ref" }, - { kind: "checkout-pr", githubPrNumber: 42 }, - ] as const) { - const parsed = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, target }); - expect(parsed.success).toBe(true); - } - }); - - it("rejects create_worktree without a target", async () => { - const { agentManager, agentStorage } = createTestDeps(); - const server = await createAgentMcpServer({ - agentManager, - agentStorage, - providerSnapshotManager: createOpenCodeManager().manager, - logger, - }); - const tool = registeredTool(server, "create_worktree"); - - const parsed = await tool.inputSchema.safeParseAsync({}); - expect(parsed.success).toBe(false); + expect(lookupTool(server, "create_workspace")).toBeDefined(); + expect(lookupTool(server, "list_workspaces")).toBeDefined(); + expect(lookupTool(server, "archive_workspace")).toBeDefined(); + expect(lookupTool(server, "create_worktree")).toBeUndefined(); + expect(lookupTool(server, "list_worktrees")).toBeUndefined(); + expect(lookupTool(server, "archive_worktree")).toBeUndefined(); + expect(lookupTool(server, "detach_agent")).toBeUndefined(); + expect(lookupTool(server, "update_heartbeat")).toBeUndefined(); }); it("surfaces createAgent validation failures", async () => { @@ -2389,7 +2462,7 @@ describe("create_agent MCP tool", () => { expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); }); - it("registers and broadcasts a workspace when create_worktree creates a worktree", async () => { + it("creates a worktree-isolated workspace", async () => { const { agentManager, agentStorage } = createTestDeps(); const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-create-worktree-")); const repoDir = join(tempDir, "repo"); @@ -2434,25 +2507,19 @@ describe("create_agent MCP tool", () => { >, logger, }); - const tool = registeredTool(server, "create_worktree"); + const tool = registeredTool(server, "create_workspace"); const response = await tool.handler({ - cwd: repoDir, - target: { - kind: "branch-off", - worktreeSlug: "tool-worktree", - branchName: "feature/tool-worktree", - baseBranch: "main", - }, + isolation: "worktree", + path: repoDir, + worktreeSlug: "tool-worktree", + branchName: "feature/tool-worktree", + baseBranch: "main", }); - expect(response.structuredContent.branchName).toBe("feature/tool-worktree"); - expect(response.structuredContent.worktreePath).toContain("tool-worktree"); + expect(response.structuredContent.isolation).toBe("worktree"); + expect(response.structuredContent.cwd).toContain("tool-worktree"); expect(response.structuredContent.workspaceId).toBe(broadcasts[0]); expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); - expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(repoDir, { - force: true, - reason: "mcp:create-worktree", - }); expect(setupContinuations).toEqual([undefined]); expect(broadcasts).toHaveLength(1); expect(broadcasts[0]).toMatch(/^wks_[0-9a-f]{16}$/); @@ -2461,7 +2528,149 @@ describe("create_agent MCP tool", () => { } }); - it("forces a workspace git snapshot refresh when archive_worktree deletes a worktree", async () => { + it("creates a worktree workspace from a project root without a path", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const project = createPersistedProjectRecord({ + projectId: "project-source", + rootPath: REPO_CWD, + kind: "git", + displayName: "source", + createdAt: "2026-07-18T00:00:00.000Z", + updatedAt: "2026-07-18T00:00:00.000Z", + }); + const receivedInputs: CreatePaseoWorktreeInput[] = []; + const createPaseoWorktree: CreatePaseoWorktreeWorkflowFn = async (input) => { + receivedInputs.push(input); + return { + worktree: { branchName: "project-worktree", worktreePath: TARGET_CWD }, + intent: { kind: "branch-off", branchName: "project-worktree", baseBranch: "main" }, + workspace: createPersistedWorkspaceRecord({ + workspaceId: "ws-project-source", + projectId: project.projectId, + cwd: TARGET_CWD, + kind: "worktree", + displayName: "project-worktree", + title: input.title ?? null, + createdAt: "2026-07-18T00:00:00.000Z", + updatedAt: "2026-07-18T00:00:00.000Z", + }), + repoRoot: REPO_CWD, + created: true, + }; + }; + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + projectRegistry: { + get: async (projectId) => (projectId === project.projectId ? project : null), + }, + createPaseoWorktree, + logger, + }); + + const response = await invokeToolWithParsedInput(registeredTool(server, "create_workspace"), { + isolation: "worktree", + projectId: project.projectId, + worktreeSlug: "project-worktree", + title: "Project workspace", + }); + + expect(response.structuredContent.workspaceId).toBe("ws-project-source"); + expect(receivedInputs).toEqual([ + expect.objectContaining({ + cwd: REPO_CWD, + projectId: project.projectId, + title: "Project workspace", + }), + ]); + }); + + it("preserves branch checkout and pull request checkout workspace modes", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const createPaseoWorktree = vi.fn(async (input: CreatePaseoWorktreeInput) => ({ + worktree: { + branchName: input.refName ?? "pr-42", + worktreePath: "/tmp/worktrees/selected", + }, + intent: { + kind: "checkout-branch" as const, + branchName: input.refName ?? "pr-42", + }, + workspace: createPersistedWorkspaceRecord({ + workspaceId: "ws-selected", + projectId: "project-1", + cwd: "/tmp/worktrees/selected", + kind: "worktree", + displayName: "selected", + createdAt: "2026-07-18T00:00:00.000Z", + updatedAt: "2026-07-18T00:00:00.000Z", + }), + repoRoot: REPO_CWD, + created: true, + })); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + createPaseoWorktree, + logger, + }); + const tool = registeredTool(server, "create_workspace"); + + await invokeToolWithParsedInput(tool, { + isolation: "worktree", + path: REPO_CWD, + mode: "checkout-branch", + branch: "existing-work", + worktreeSlug: "existing-work-copy", + }); + await invokeToolWithParsedInput(tool, { + isolation: "worktree", + path: REPO_CWD, + mode: "checkout-pr", + prNumber: 42, + forge: "gitlab", + }); + await invokeToolWithParsedInput(tool, { + isolation: "worktree", + path: REPO_CWD, + mode: "checkout-pr", + prNumber: 43, + }); + + expect(createPaseoWorktree).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + action: "checkout", + refName: "existing-work", + worktreeSlug: "existing-work-copy", + }), + ); + expect(createPaseoWorktree).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + action: "checkout", + checkoutSource: { + kind: "change_request", + forge: "gitlab", + number: 42, + }, + }), + ); + expect(createPaseoWorktree).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + action: "checkout", + checkoutSource: { + kind: "change_request", + number: 43, + }, + }), + ); + }); + + it("archives a worktree-isolated workspace by workspace id", async () => { const { agentManager, agentStorage } = createTestDeps(); const tempDir = realpathSync.native( await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-")), @@ -2514,13 +2723,15 @@ describe("create_agent MCP tool", () => { github: createGitHubServiceStub(), logger, }); - const createTool = registeredTool(server, "create_worktree"); - const archiveTool = registeredTool(server, "archive_worktree"); + const createTool = registeredTool(server, "create_workspace"); + const archiveTool = registeredTool(server, "archive_workspace"); const created = await createTool.handler({ - cwd: repoDir, - target: { kind: "branch-off", worktreeSlug: "archive-tool-worktree", baseBranch: "main" }, + isolation: "worktree", + path: repoDir, + worktreeSlug: "archive-tool-worktree", + baseBranch: "main", }); - const createdWorktreePath = z.string().parse(created.structuredContent.worktreePath); + const createdWorktreePath = z.string().parse(created.structuredContent.cwd); listActiveWorkspaces.mockImplementation(async () => [ { workspaceId: "ws-archive-tool-worktree", cwd: createdWorktreePath, kind: "worktree" }, ]); @@ -2530,19 +2741,13 @@ describe("create_agent MCP tool", () => { workspaceGitService.getSnapshot.mockClear(); await archiveTool.handler({ - cwd: repoDir, - worktreePath: created.structuredContent.worktreePath, + workspaceId: "ws-archive-tool-worktree", }); expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, { force: true, reason: "archive-worktree", }); - expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(repoDir); - expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(repoDir, { - force: true, - reason: "mcp:archive-worktree", - }); expect(archiveWorkspaceRecord).toHaveBeenCalledWith("ws-archive-tool-worktree"); expect(markWorkspaceArchiving).toHaveBeenCalledWith( ["ws-archive-tool-worktree"], @@ -2557,7 +2762,22 @@ describe("create_agent MCP tool", () => { } }); - it("archives every workspace on a directory and removes the directory", async () => { + it("rejects archiving a missing workspace", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + listActiveWorkspaces: async () => [], + logger, + }); + + await expect( + registeredTool(server, "archive_workspace").handler({ workspaceId: "missing-workspace" }), + ).rejects.toThrow("Workspace not found: missing-workspace"); + }); + + it("keeps an owned worktree while another workspace still references it", async () => { const { agentManager, agentStorage } = createTestDeps(); const tempDir = realpathSync.native( await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-multi-")), @@ -2616,13 +2836,15 @@ describe("create_agent MCP tool", () => { github: createGitHubServiceStub(), logger, }); - const createTool = registeredTool(server, "create_worktree"); - const archiveTool = registeredTool(server, "archive_worktree"); + const createTool = registeredTool(server, "create_workspace"); + const archiveTool = registeredTool(server, "archive_workspace"); const created = await createTool.handler({ - cwd: repoDir, - target: { kind: "branch-off", worktreeSlug: "archive-multi-worktree", baseBranch: "main" }, + isolation: "worktree", + path: repoDir, + worktreeSlug: "archive-multi-worktree", + baseBranch: "main", }); - const worktreePath = z.string().parse(created.structuredContent.worktreePath); + const worktreePath = z.string().parse(created.structuredContent.cwd); // Populate the active workspaces with the real created path so archiveByScope // matches it against the worktree directory. @@ -2632,19 +2854,18 @@ describe("create_agent MCP tool", () => { ]; await archiveTool.handler({ - cwd: repoDir, - worktreePath, + workspaceId: "ws-mcp-A", }); expect(archivedWorkspaceIds).toContain("ws-mcp-A"); - expect(archivedWorkspaceIds).toContain("ws-mcp-B"); - await expect(access(worktreePath)).rejects.toThrow(); + expect(archivedWorkspaceIds).not.toContain("ws-mcp-B"); + await expect(access(worktreePath)).resolves.toBeUndefined(); } finally { await removeTempDir(tempDir); } }); - it("archives a worktree by slug", async () => { + it("does not expose worktree path or slug operations", async () => { const { agentManager, agentStorage } = createTestDeps(); const tempDir = realpathSync.native( await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-slug-")), @@ -2692,71 +2913,41 @@ describe("create_agent MCP tool", () => { github: createGitHubServiceStub(), logger, }); - const createTool = registeredTool(server, "create_worktree"); - const archiveTool = registeredTool(server, "archive_worktree"); - const created = await createTool.handler({ - cwd: repoDir, - target: { kind: "branch-off", worktreeSlug: "archive-slug-worktree", baseBranch: "main" }, - }); - - const response = await archiveTool.handler({ - cwd: repoDir, - worktreeSlug: "archive-slug-worktree", - }); - - expect(response.structuredContent).toEqual({ success: true }); - expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, { - force: true, - reason: "archive-worktree", - }); - expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(repoDir); - expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(repoDir, { - force: true, - reason: "mcp:archive-worktree", - }); - await expect( - access(z.string().parse(created.structuredContent.worktreePath)), - ).rejects.toThrow(); + expect(lookupTool(server, "create_worktree")).toBeUndefined(); + expect(lookupTool(server, "archive_worktree")).toBeUndefined(); } finally { await removeTempDir(tempDir); } }); - it("routes list_worktrees through WorkspaceGitService", async () => { + it("lists active workspace descriptors", async () => { const { agentManager, agentStorage } = createTestDeps(); - const workspaceGitService = { - getSnapshot: vi.fn(async () => null), - listWorktrees: vi.fn(async () => [ - { - path: "/tmp/paseo/worktrees/repo/feature", - branchName: "feature", - createdAt: "2026-04-12T00:00:00.000Z", - }, - ]), - }; + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-feature", + projectId: "project-1", + cwd: "/tmp/paseo/worktrees/repo/feature", + kind: "worktree", + displayName: "feature", + createdAt: "2026-07-17T00:00:00.000Z", + updatedAt: "2026-07-17T00:00:00.000Z", + }); const server = await createAgentMcpServer({ agentManager, agentStorage, providerSnapshotManager: createOpenCodeManager().manager, - workspaceGitService: workspaceGitService as unknown as Pick< - WorkspaceGitService, - "getSnapshot" | "listWorktrees" - >, + workspaceRegistry: { + get: vi.fn(async () => workspace), + list: vi.fn(async () => [workspace]), + upsert: vi.fn(async () => undefined), + }, logger, }); - const tool = registeredTool(server, "list_worktrees"); + const tool = registeredTool(server, "list_workspaces"); - const response = await tool.handler({ cwd: REPO_CWD }); + const response = await tool.handler({}); - expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(REPO_CWD, { - reason: "mcp:list-worktrees", - }); - expect(response.structuredContent.worktrees).toEqual([ - { - path: "/tmp/paseo/worktrees/repo/feature", - branchName: "feature", - createdAt: "2026-04-12T00:00:00.000Z", - }, + expect(response.structuredContent.workspaces).toEqual([ + expect.objectContaining({ workspaceId: "ws-feature", isolation: "worktree" }), ]); }); @@ -3781,11 +3972,11 @@ describe("create_schedule MCP tool", () => { cron: "*/5 * * * *", name: "Default schedule", }), - ).rejects.toThrow("provider is required when target is new-agent"); + ).rejects.toThrow("provider"); expect(createOrReplace).not.toHaveBeenCalled(); }); - it("keeps create_schedule provider overrides compatible with provider and provider/model forms", async () => { + it("keeps provider forms compatible without materializing default schedule isolation", async () => { const { agentManager, agentStorage } = createTestDeps(); const createOrReplace = vi.fn(async (input: CreateScheduleInput) => createStoredSchedule(input), @@ -3809,6 +4000,12 @@ describe("create_schedule MCP tool", () => { cron: "*/10 * * * *", provider: "codex/gpt-5.4", }); + await tool.handler({ + prompt: "say hello in a worktree", + cron: "*/15 * * * *", + provider: "codex", + isolation: "worktree", + }); expect(createOrReplace).toHaveBeenNthCalledWith( 1, @@ -3835,9 +4032,22 @@ describe("create_schedule MCP tool", () => { }, }), ); + expect(createOrReplace).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + target: { + type: "new-agent", + config: { + provider: "codex", + cwd: process.cwd(), + isolation: "worktree", + }, + }, + }), + ); }); - it("returns create_schedule structured content with inherited feature values", async () => { + it("inherits the caller provider, model, and features when provider is omitted", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.getAgent.mockReturnValue({ id: "parent-agent", @@ -3868,12 +4078,17 @@ describe("create_schedule MCP tool", () => { const response = await tool.handler({ prompt: "say hello", cron: "*/5 * * * *", - provider: "opencode/openai/gpt-5.5", }); - expect(response.structuredContent.target).toMatchObject({ + expect(response.structuredContent.target).toEqual({ type: "new-agent", - config: { featureValues: { auto_accept: true } }, + config: { + provider: "opencode", + cwd: REPO_CWD, + modeId: "build", + model: "openai/gpt-5.5", + featureValues: { auto_accept: true }, + }, }); }); @@ -4049,6 +4264,60 @@ describe("create_heartbeat MCP tool", () => { }); }); +describe("heartbeat ownership MCP tools", () => { + const logger = createTestLogger(); + + it("deletes the caller's heartbeat", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const heartbeat = createStoredSchedule({ + prompt: "check status", + cadence: { type: "cron", expression: "*/15 * * * *" }, + target: { type: "agent", agentId: "parent-agent" }, + }); + const inspect = vi.fn(async () => heartbeat); + const deleteSchedule = vi.fn(async () => undefined); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + scheduleService: { + inspect, + delete: deleteSchedule, + } as unknown as ScheduleService, + callerAgentId: "parent-agent", + logger, + }); + + await registeredTool(server, "delete_heartbeat").handler({ id: heartbeat.id }); + + expect(deleteSchedule).toHaveBeenCalledWith(heartbeat.id); + }); + + it("rejects another agent's heartbeat", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const foreignHeartbeat = createStoredSchedule({ + prompt: "foreign", + cadence: { type: "cron", expression: "0 * * * *" }, + target: { type: "agent", agentId: "other-agent" }, + }); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + scheduleService: { + inspect: vi.fn(async () => foreignHeartbeat), + delete: vi.fn(), + } as unknown as ScheduleService, + callerAgentId: "parent-agent", + logger, + }); + + await expect( + registeredTool(server, "delete_heartbeat").handler({ id: foreignHeartbeat.id }), + ).rejects.toThrow("does not belong to caller"); + }); +}); + describe("update_schedule MCP tool", () => { const logger = createTestLogger(); @@ -4071,6 +4340,16 @@ describe("update_schedule MCP tool", () => { }; } + function scheduleServiceWithUpdate( + update: (input: UpdateScheduleInput) => Promise, + stored = makeStoredSchedule(), + ): ScheduleService { + return { + update, + inspect: vi.fn(async () => stored), + } as unknown as ScheduleService; + } + it("calls scheduleService.update with correct input", async () => { const { agentManager, agentStorage } = createTestDeps(); const stored = makeStoredSchedule(); @@ -4084,7 +4363,7 @@ describe("update_schedule MCP tool", () => { agentManager, agentStorage, providerSnapshotManager: createOpenCodeManager().manager, - scheduleService: { update } as unknown as ScheduleService, + scheduleService: scheduleServiceWithUpdate(update, stored), logger, }); const tool = registeredTool(server, "update_schedule"); @@ -4110,7 +4389,7 @@ describe("update_schedule MCP tool", () => { agentManager, agentStorage, providerSnapshotManager: createOpenCodeManager().manager, - scheduleService: { update } as unknown as ScheduleService, + scheduleService: scheduleServiceWithUpdate(update, stored), logger, }); const tool = registeredTool(server, "update_schedule"); @@ -4122,7 +4401,7 @@ describe("update_schedule MCP tool", () => { expect(update).toHaveBeenCalledWith({ id: "schedule-1", - cadence: { type: "every", everyMs: 600000 }, + cadence: { type: "cron", expression: "*/10 * * * *" }, }); }); @@ -4134,7 +4413,7 @@ describe("update_schedule MCP tool", () => { agentManager, agentStorage, providerSnapshotManager: createOpenCodeManager().manager, - scheduleService: { update } as unknown as ScheduleService, + scheduleService: scheduleServiceWithUpdate(update, stored), logger, }); const tool = registeredTool(server, "update_schedule"); @@ -4163,7 +4442,7 @@ describe("update_schedule MCP tool", () => { agentManager, agentStorage, providerSnapshotManager: createOpenCodeManager().manager, - scheduleService: { update } as unknown as ScheduleService, + scheduleService: scheduleServiceWithUpdate(update, stored), logger, }); const tool = registeredTool(server, "update_schedule"); @@ -4176,7 +4455,7 @@ describe("update_schedule MCP tool", () => { expect(update).toHaveBeenCalledWith({ id: "schedule-1", - cadence: { type: "every", everyMs: 600000 }, + cadence: { type: "cron", expression: "*/10 * * * *" }, }); }); @@ -4184,7 +4463,7 @@ describe("update_schedule MCP tool", () => { { label: "whitespace cron field", input: { id: "schedule-1", every: "10m", cron: " " }, - cadence: { type: "every", everyMs: 600000 }, + cadence: { type: "cron", expression: "*/10 * * * *" }, }, { label: "blank every field for cron cadence", @@ -4204,7 +4483,7 @@ describe("update_schedule MCP tool", () => { agentManager, agentStorage, providerSnapshotManager: createOpenCodeManager().manager, - scheduleService: { update } as unknown as ScheduleService, + scheduleService: scheduleServiceWithUpdate(update, stored), logger, }); const tool = registeredTool(server, "update_schedule"); @@ -4224,7 +4503,7 @@ describe("update_schedule MCP tool", () => { agentManager, agentStorage, providerSnapshotManager: createOpenCodeManager().manager, - scheduleService: { update } as unknown as ScheduleService, + scheduleService: scheduleServiceWithUpdate(update), logger, }); const tool = registeredTool(server, "update_schedule"); @@ -4246,7 +4525,7 @@ describe("update_schedule MCP tool", () => { agentManager, agentStorage, providerSnapshotManager: createOpenCodeManager().manager, - scheduleService: { update } as unknown as ScheduleService, + scheduleService: scheduleServiceWithUpdate(update), logger, }); const tool = registeredTool(server, "update_schedule"); @@ -4269,7 +4548,7 @@ describe("update_schedule MCP tool", () => { agentManager, agentStorage, providerSnapshotManager: createOpenCodeManager().manager, - scheduleService: { update } as unknown as ScheduleService, + scheduleService: scheduleServiceWithUpdate(update), logger, }); const tool = registeredTool(server, "update_schedule"); @@ -4293,7 +4572,7 @@ describe("update_schedule MCP tool", () => { agentManager, agentStorage, providerSnapshotManager: createOpenCodeManager().manager, - scheduleService: { update } as unknown as ScheduleService, + scheduleService: scheduleServiceWithUpdate(update, stored), logger, }); const tool = registeredTool(server, "update_schedule"); @@ -4327,7 +4606,7 @@ describe("update_schedule MCP tool", () => { agentManager, agentStorage, providerSnapshotManager: createOpenCodeManager().manager, - scheduleService: { update } as unknown as ScheduleService, + scheduleService: scheduleServiceWithUpdate(update, stored), logger, }); const tool = registeredTool(server, "update_schedule"); @@ -4358,7 +4637,7 @@ describe("update_schedule MCP tool", () => { agentManager, agentStorage, providerSnapshotManager: createOpenCodeManager().manager, - scheduleService: { update } as unknown as ScheduleService, + scheduleService: scheduleServiceWithUpdate(update), logger, }); const tool = registeredTool(server, "update_schedule"); @@ -4401,11 +4680,15 @@ describe("schedule_logs MCP tool", () => { const { agentManager, agentStorage } = createTestDeps(); const runs = [makeRun({ id: "run-1" }), makeRun({ id: "run-2", status: "failed" })]; const logs = vi.fn(async (_id: string) => runs); + const inspect = vi.fn(async () => ({ + id: "schedule-1", + target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp" } }, + })); const server = await createAgentMcpServer({ agentManager, agentStorage, providerSnapshotManager: createOpenCodeManager().manager, - scheduleService: { logs } as unknown as ScheduleService, + scheduleService: { logs, inspect } as unknown as ScheduleService, logger, }); const tool = registeredTool(server, "schedule_logs"); diff --git a/packages/server/src/server/agent/provider-notices.ts b/packages/server/src/server/agent/provider-notices.ts index 921f70a50..d2a9c3af5 100644 --- a/packages/server/src/server/agent/provider-notices.ts +++ b/packages/server/src/server/agent/provider-notices.ts @@ -1,6 +1,11 @@ import type { AgentProviderNotice } from "./agent-sdk-types.js"; -export const SETTING_APPLIES_NEXT_TURN_NOTICE: AgentProviderNotice = { - type: "info", - message: "This change applies next turn.", +export const MODE_APPLIES_NEXT_TURN_NOTICE: AgentProviderNotice = { + type: "warning", + message: "Permission mode applies next turn", +}; + +export const THINKING_APPLIES_NEXT_TURN_NOTICE: AgentProviderNotice = { + type: "warning", + message: "Thinking level applies next turn", }; diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index 177c55477..4596b6d91 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -814,7 +814,7 @@ test("enabled: false keeps provider metadata in registry", () => { id: "claude", label: "Claude", description: "Anthropic's multi-tool assistant with MCP support, streaming, and deep reasoning", - defaultModeId: "default", + defaultModeId: "auto", enabled: false, }); expect(registry.claude.modes).toEqual( @@ -1487,6 +1487,31 @@ describe("fetchCatalog", () => { expect(catalog.models.map((model) => model.id)).toEqual(["profile-model", "extra-model"]); }); + test("replacement models still resolve the provider's capability-aware default mode", async () => { + const resolveDefaultModeId = vi.fn(async () => "default"); + const injectedClient = { + provider: "codex", + capabilities: {}, + resolveDefaultModeId, + isAvailable: vi.fn(async () => true), + } satisfies Partial as AgentClient; + const registry = buildProviderRegistry(logger, { + providerOverrides: { + codex: { models: [{ id: "profile-model", label: "Profile Model" }] }, + }, + }); + + const catalog = await registry.codex.fetchCatalog( + { scope: "workspace", cwd: "/tmp/catalog", force: false }, + injectedClient, + ); + + expect(catalog.defaultModeId).toBe("default"); + expect(resolveDefaultModeId).toHaveBeenCalledWith({ + config: { provider: "codex", cwd: "/tmp/catalog" }, + }); + }); + test("additionalModels can override replacement model fields", async () => { const registry = buildProviderRegistry(logger, { providerOverrides: { diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 9d9022f5a..dbdb5c076 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -14,6 +14,7 @@ import type { ProviderCatalog, ResolveAgentCreateConfigInput, ResolveAgentCreateConfigResult, + ResolveAgentDefaultModeInput, } from "./agent-sdk-types.js"; import { isDefaultAgentCreateConfigUnattended, @@ -427,12 +428,20 @@ function wrapClientProvider( fetchCatalog: async (options) => { const catalog = await inner.fetchCatalog(options); return { + ...catalog, models: mergeModels(provider, profileModels, additionalModels, catalog.models, { profileModelsAreAdditive, }), modes: catalog.modes, }; }, + resolveDefaultModeId: inner.resolveDefaultModeId + ? async ({ config, env }: ResolveAgentDefaultModeInput) => + await inner.resolveDefaultModeId?.({ + config: { ...config, provider: inner.provider }, + env, + }) + : undefined, resolveCreateConfig: inner.resolveCreateConfig?.bind(inner), isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner), listFeatures: listFeatures @@ -516,17 +525,25 @@ function createRegistryEntry( // the single catalog API; otherwise use static/empty modes with no runtime. const models = mergeModelAdditions(provider, replacementModels, resolved.additionalModels); if (hasStaticModes) { + const defaultModeId = await catalogClient.resolveDefaultModeId?.({ + config: { + provider, + cwd: options.scope === "workspace" ? options.cwd : process.cwd(), + }, + }); return { models, modes: decorateModes(resolved.definition.modes), + defaultModeId, }; } const catalog = await catalogClient.fetchCatalog(options); - return { models, modes: decorateModes(catalog.modes) }; + return { ...catalog, models, modes: decorateModes(catalog.modes) }; } const catalog = await catalogClient.fetchCatalog(options); return { + ...catalog, models: mergeModels( provider, resolved.profileModels, 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 4b6a3eac5..980abe088 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -117,9 +117,11 @@ describe("ProviderSnapshotManager public surface", () => { try { const snapshot = manager.getSnapshot("/tmp/project"); const claude = snapshot.find((entry) => entry.provider === "claude"); + const codex = snapshot.find((entry) => entry.provider === "codex"); expect(claude?.status).toBe("loading"); expect(claude?.label).toBe("Claude"); - expect(claude?.defaultModeId).toBe("default"); + expect(claude?.defaultModeId).toBe("auto"); + expect(codex?.defaultModeId).toBe("auto-review"); } finally { manager.destroy(); } @@ -220,6 +222,34 @@ describe("ProviderSnapshotManager public surface", () => { } }); + test("ready snapshots publish the catalog's capability-aware default mode", async () => { + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + codex: createExtraClient("codex", { + isAvailable: async () => true, + fetchCatalog: async () => ({ + models: [], + modes: [{ id: "default", label: "Default", description: "Ask before running tools" }], + defaultModeId: "default", + }), + }), + }, + }); + + try { + const entry = await manager.getProvider({ + cwd: "/tmp/project", + provider: "codex", + wait: true, + }); + + expect(entry).toMatchObject({ status: "ready", defaultModeId: "default" }); + } finally { + manager.destroy(); + } + }); + test("explicit refresh re-probes only the requested warm provider", async () => { const cwd = "/tmp/project"; const isAvailableCodex = vi.fn(async () => true); diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index f21d91923..771c4d24c 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -799,6 +799,8 @@ export class ProviderSnapshotManager { setEntry({ ...base, + defaultModeId: + catalog.defaultModeId === undefined ? definition.defaultModeId : catalog.defaultModeId, status: "ready", enabled: true, models: catalog.models, diff --git a/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts b/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts index f43bbacf7..1af10b8a5 100644 --- a/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts @@ -218,6 +218,37 @@ test("rejects auto mode when Claude Code uses Bedrock", async () => { } }); +test.each([ + { env: {}, expected: "auto" }, + { env: { CLAUDE_CODE_USE_BEDROCK: "1" }, expected: "default" }, + { env: { CLAUDE_CODE_USE_VERTEX: "true" }, expected: "default" }, +])("defaults to $expected for transport environment $env", async ({ env, expected }) => { + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + runtimeSettings: { env }, + }); + + await expect( + client.resolveDefaultModeId({ + config: { provider: "claude", cwd: process.cwd() }, + }), + ).resolves.toBe(expected); +}); + +test("launch environment participates in the Claude default mode", async () => { + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + runtimeSettings: { env: {} }, + }); + + await expect( + client.resolveDefaultModeId({ + config: { provider: "claude", cwd: process.cwd() }, + env: { CLAUDE_CODE_USE_BEDROCK: "1" }, + }), + ).resolves.toBe("default"); +}); + test("allows launch env to disable inherited Bedrock transport for auto mode", async () => { const previousBedrock = process.env.CLAUDE_CODE_USE_BEDROCK; process.env.CLAUDE_CODE_USE_BEDROCK = "1"; 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 20f8f5235..bd535ac13 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -704,8 +704,8 @@ describe("ClaudeAgentSession features", () => { }); await expect(session.setThinkingOption?.("ultracode")).resolves.toEqual({ - type: "info", - message: "This change applies next turn.", + type: "warning", + message: "Thinking level applies next turn", }); await session.close(); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 99a6acfe2..4a07fd4fe 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -50,7 +50,7 @@ import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./quer import { realClaudeRewindSdk, revertClaudeConversation, revertClaudeFiles } from "./rewind.js"; import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js"; import { claudeProjectDirSync } from "./project-dir.js"; -import { SETTING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js"; +import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js"; import { isProviderImageMarkdown, materializeProviderImage, @@ -91,6 +91,7 @@ import { type ListImportableSessionsOptions, type McpServerConfig, type ProviderCatalog, + type ResolveAgentDefaultModeInput, } from "../../agent-sdk-types.js"; import { importSessionFromPersistence } from "../../provider-session-import.js"; import { @@ -1472,7 +1473,28 @@ export class ClaudeAgentClient implements AgentClient { async fetchCatalog(_options: FetchCatalogOptions): Promise { // Claude exposes a global catalog here; cwd/force are intentionally irrelevant. const models = await getClaudeModelsWithSettings(this.logger, this.configDir); - return { models, modes: DEFAULT_MODES }; + const modes = detectIneligibleAutoModeTransport( + createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings }), + ) + ? DEFAULT_MODES.filter((mode) => mode.id !== "auto") + : DEFAULT_MODES; + return { + models, + modes, + defaultModeId: modes.some((mode) => mode.id === "auto") ? "auto" : "default", + }; + } + + async resolveDefaultModeId({ + config, + env: launchEnv, + }: ResolveAgentDefaultModeInput): Promise { + const env = createProviderEnv({ + baseEnv: process.env, + runtimeSettings: this.runtimeSettings, + overlays: [config.extra?.claude?.env, launchEnv], + }); + return detectIneligibleAutoModeTransport(env) ? "default" : "auto"; } async listFeatures(config: AgentSessionConfig): Promise { @@ -2209,7 +2231,7 @@ class ClaudeAgentSession implements AgentSession { } this.queryRestartNeeded = true; if (this.activeForegroundTurnId || this.autonomousTurn) { - return SETTING_APPLIES_NEXT_TURN_NOTICE; + return THINKING_APPLIES_NEXT_TURN_NOTICE; } } 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 569370cae..7a585f0ce 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 @@ -347,12 +347,12 @@ describe("Codex app-server provider", () => { const session = createSession({ modeId: "auto", thinkingOptionId: "medium" }); await expect(session.setMode("full-access")).resolves.toEqual({ - type: "info", - message: "This change applies next turn.", + type: "warning", + message: "Permission mode applies next turn", }); await expect(session.setThinkingOption?.("high")).resolves.toEqual({ - type: "info", - message: "This change applies next turn.", + type: "warning", + message: "Thinking level applies next turn", }); session.activeForegroundTurnId = null; 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 fd6b7b68e..b1d12194c 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 @@ -92,7 +92,10 @@ import { resolveBinaryVersion, } from "./diagnostic-utils.js"; import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js"; -import { SETTING_APPLIES_NEXT_TURN_NOTICE } from "../provider-notices.js"; +import { + MODE_APPLIES_NEXT_TURN_NOTICE, + THINKING_APPLIES_NEXT_TURN_NOTICE, +} from "../provider-notices.js"; import type { WorkspaceGitService } from "../../workspace-git-service.js"; function assertChildWithPipes( @@ -3943,7 +3946,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.currentMode = modeId; this.cachedRuntimeInfo = null; if (this.activeForegroundTurnId) { - return SETTING_APPLIES_NEXT_TURN_NOTICE; + return MODE_APPLIES_NEXT_TURN_NOTICE; } } @@ -3961,7 +3964,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.refreshResolvedCollaborationMode(); this.cachedRuntimeInfo = null; if (this.activeForegroundTurnId) { - return SETTING_APPLIES_NEXT_TURN_NOTICE; + return THINKING_APPLIES_NEXT_TURN_NOTICE; } } @@ -6376,8 +6379,21 @@ export class CodexAppServerAgentClient implements AgentClient { } async fetchCatalog(_options: FetchCatalogOptions): Promise { - const models = await this.fetchModelsFromAppServer(); - return { models, modes: CODEX_MODES }; + const [models, autoReviewEnabled] = await Promise.all([ + this.fetchModelsFromAppServer(), + this.resolveAutoReviewEnabled(), + ]); + return { + models, + defaultModeId: autoReviewEnabled ? "auto-review" : DEFAULT_CODEX_MODE_ID, + modes: autoReviewEnabled + ? CODEX_MODES + : CODEX_MODES.filter((mode) => mode.id !== "auto-review"), + }; + } + + async resolveDefaultModeId(): Promise { + return (await this.resolveAutoReviewEnabled()) ? "auto-review" : DEFAULT_CODEX_MODE_ID; } private async fetchModelsFromAppServer(): Promise { diff --git a/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts b/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts index f8c059307..26e33043f 100644 --- a/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts +++ b/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts @@ -140,6 +140,29 @@ describe("JsonlRpcProcess", () => { } }); + test("null timeout waits past short wall-clock limits until the response arrives", async () => { + const transport = startProcess(); + + try { + await expect( + transport.request({ type: "echo", value: "slow", delayMs: 80 }, null), + ).resolves.toMatchObject({ value: "slow" }); + } finally { + await transport.close(); + } + }); + + test("null timeout still rejects when the process is closed", async () => { + const transport = startProcess(); + await transport.request({ type: "echo", value: "ready" }); + const request = transport.request({ type: "hang" }, null); + + const rejection = expect(request).rejects.toThrow("JSONL RPC process is closed"); + await transport.close(); + + await rejection; + }); + test("rejects pending requests and publishes stderr when the child exits", async () => { const transport = startProcess(); const exit = nextExit(transport); diff --git a/packages/server/src/server/agent/providers/jsonl-rpc-process.ts b/packages/server/src/server/agent/providers/jsonl-rpc-process.ts index 2fddf5995..2e56a0659 100644 --- a/packages/server/src/server/agent/providers/jsonl-rpc-process.ts +++ b/packages/server/src/server/agent/providers/jsonl-rpc-process.ts @@ -4,7 +4,14 @@ import type { Logger } from "pino"; import { spawnProcess } from "../../../utils/spawn.js"; import { terminateWithTreeKill } from "../../../utils/tree-kill.js"; -const DEFAULT_TIMEOUT_MS = 30_000; +/** Default wall-clock timeout for control-plane / short RPC calls. */ +export const JSONL_RPC_DEFAULT_TIMEOUT_MS = 30_000; +/** + * Pass as `timeoutMs` to wait only for a response, process death, or `close()`. + * Use for long-running blocking RPCs (e.g. LLM-backed compact). + */ +export const JSONL_RPC_NO_TIMEOUT = null; + const STDERR_BUFFER_LIMIT = 8192; const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000; const FORCE_SHUTDOWN_TIMEOUT_MS = 1_000; @@ -28,7 +35,7 @@ interface JsonlRpcResponse { interface PendingRequest { resolve: (value: unknown) => void; reject: (error: Error) => void; - timer: NodeJS.Timeout; + timer: NodeJS.Timeout | null; } export interface JsonlRpcExit { @@ -116,7 +123,7 @@ export class JsonlRpcProcess { startRequest( command: { type: string; [key: string]: unknown }, - timeoutMs = DEFAULT_TIMEOUT_MS, + timeoutMs: number | null = JSONL_RPC_DEFAULT_TIMEOUT_MS, ): { id: string; promise: Promise } { if (this.disposed) { return { @@ -127,14 +134,14 @@ export class JsonlRpcProcess { const id = `req_${this.nextRequestId}`; this.nextRequestId += 1; const promise = new Promise((resolve, reject) => { - const timer = setTimeout(() => { + const timer = createRequestTimeout(timeoutMs, () => { this.pending.delete(id); reject( new Error( `${this.diagnosticName} request timed out for ${command.type}\n${this.stderrBuffer}`.trim(), ), ); - }, timeoutMs); + }); this.pending.set(id, { resolve, reject, timer }); this.send({ ...command, id }); }); @@ -143,7 +150,7 @@ export class JsonlRpcProcess { request( command: { type: string; [key: string]: unknown }, - timeoutMs = DEFAULT_TIMEOUT_MS, + timeoutMs: number | null = JSONL_RPC_DEFAULT_TIMEOUT_MS, ): Promise { return this.startRequest(command, timeoutMs).promise; } @@ -228,7 +235,9 @@ export class JsonlRpcProcess { if (!pending) { return; } - clearTimeout(pending.timer); + if (pending.timer) { + clearTimeout(pending.timer); + } this.pending.delete(response.id); if (!response.success) { pending.reject( @@ -247,9 +256,25 @@ export class JsonlRpcProcess { } this.disposed = true; for (const pending of this.pending.values()) { - clearTimeout(pending.timer); + if (pending.timer) { + clearTimeout(pending.timer); + } pending.reject(error); } this.pending.clear(); } } + +/** + * Schedule a request timeout, or return null when the call should wait + * indefinitely for a response, process exit, or close(). + */ +function createRequestTimeout( + timeoutMs: number | null, + onTimeout: () => void, +): NodeJS.Timeout | null { + if (timeoutMs == null || !Number.isFinite(timeoutMs) || timeoutMs <= 0) { + return null; + } + return setTimeout(onTimeout, timeoutMs); +} diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index 7ac3608a7..032315696 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -67,6 +67,13 @@ describe("OMP agent client and session", () => { }); }); + test("preserves max as the selected thinking option", async () => { + const omp = new OmpHarness(); + await omp.start({ thinkingOptionId: "max" }); + + expect(omp.launchConfiguration().argv).toEqual(expect.arrayContaining(["--thinking", "max"])); + }); + test("streams a prompt through completion", async () => { const omp = new OmpHarness(); await omp.start(); @@ -225,7 +232,10 @@ describe("OMP agent client and session", () => { expect.objectContaining({ name: "review", kind: "skill" }), ]), ); - await expect(omp.setMode("ask")).resolves.toEqual(expect.objectContaining({ type: "info" })); + await expect(omp.setMode("ask")).resolves.toEqual({ + type: "warning", + message: "Start a new OMP session to change approval mode", + }); }); test("rewinds natively, interrupts, and shuts down", async () => { diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index 23066b631..1e002c9f4 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -136,7 +136,8 @@ const OMP_THINKING_OPTIONS: ReadonlyArray<{ { id: "low", label: "Low", description: "Faster reasoning" }, { id: "medium", label: "Medium", description: "Balanced reasoning", isDefault: true }, { id: "high", label: "High", description: "Deeper reasoning" }, - { id: "xhigh", label: "XHigh", description: "Maximum reasoning" }, + { id: "xhigh", label: "XHigh", description: "Extra-high reasoning" }, + { id: "max", label: "Max", description: "Maximum reasoning" }, ] as const; export interface OmpAgentClientOptions { @@ -269,7 +270,8 @@ function isOmpThinkingLevel(value: string | null | undefined): value is OmpThink value === "low" || value === "medium" || value === "high" || - value === "xhigh" + value === "xhigh" || + value === "max" ); } @@ -1082,9 +1084,8 @@ export class OmpAgentSession implements AgentSession { throw new Error(`Invalid OMP mode '${modeId}'`); } return { - type: "info", - message: - "OMP approval mode is set when the agent launches. Start a new OMP session to use a different mode.", + type: "warning", + message: "Start a new OMP session to change approval mode", }; } diff --git a/packages/server/src/server/agent/providers/omp/rpc-types.ts b/packages/server/src/server/agent/providers/omp/rpc-types.ts index df3c7f9b3..bdd5412a4 100644 --- a/packages/server/src/server/agent/providers/omp/rpc-types.ts +++ b/packages/server/src/server/agent/providers/omp/rpc-types.ts @@ -1,6 +1,14 @@ import { z } from "zod"; -export const OmpThinkingLevelSchema = z.enum(["off", "minimal", "low", "medium", "high", "xhigh"]); +export const OmpThinkingLevelSchema = z.enum([ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +]); export const OmpImageContentSchema = z .object({ type: z.literal("image"), data: z.string(), mimeType: z.string() }) diff --git a/packages/server/src/server/agent/providers/opencode-agent-commands.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-agent-commands.e2e.test.ts index f712c6034..8dd3832cf 100644 --- a/packages/server/src/server/agent/providers/opencode-agent-commands.e2e.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent-commands.e2e.test.ts @@ -37,6 +37,28 @@ describe("opencode agent commands E2E", () => { } }, 60_000); + test("listing commands resumes an idle-collected agent", async () => { + const agent = await ctx.client.createAgent({ + ...getFullAccessConfig("opencode"), + cwd: "/tmp", + title: "Collected OpenCode Commands Test Agent", + }); + + const collection = await ctx.daemon.daemon.agentManager.collectIdleAgents({ + cutoff: new Date(Date.now() + 1_000), + protectedAgentIds: new Set(), + }); + expect(collection.failures).toEqual([]); + expect(collection.collected.map((entry) => entry.agentId)).toContain(agent.id); + expect(ctx.daemon.daemon.agentManager.getAgent(agent.id)).toBeNull(); + + const result = await ctx.client.listCommands({ agentId: agent.id }); + + expect(result.error).toBeNull(); + expect(result.commands.length).toBeGreaterThan(0); + expect(ctx.daemon.daemon.agentManager.getAgent(agent.id)?.id).toBe(agent.id); + }, 60_000); + test("sendMessage executes a slash command without arguments", async () => { const agent = await ctx.client.createAgent({ ...getFullAccessConfig("opencode"), diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 2cccfcbee..e5ef7f681 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -194,7 +194,8 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { test("creates a session with valid id and provider", async () => { const cwd = tmpCwd(); const runtime = new TestOpenCodeHarness(); - runtime.enqueueClient(new TestOpenCodeClient()); + const openCode = new TestOpenCodeClient(); + runtime.enqueueClient(openCode); const client = new OpenCodeAgentClient(logger, undefined, { serverManager: runtime, createClient: runtime.createClient, @@ -206,9 +207,49 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { expect(session.provider).toBe("opencode"); await session.close(); + expect(openCode.calls.sessionAbort).toEqual([{ sessionID: "session-1", directory: cwd }]); + expect(openCode.calls.sessionUpdate).toEqual([]); rmSync(cwd, { recursive: true, force: true }); }, 60_000); + test("archives and unarchives the durable native session through client hooks", async () => { + const cwd = tmpCwd(); + const runtime = new TestOpenCodeHarness(); + const archiveClient = new TestOpenCodeClient(); + const unarchiveClient = new TestOpenCodeClient(); + runtime.enqueueClient(archiveClient); + runtime.enqueueClient(unarchiveClient); + const client = new OpenCodeAgentClient(logger, undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const handle = { + provider: "opencode" as const, + sessionId: "session-1", + metadata: { cwd }, + }; + + await client.archiveNativeSession(handle); + await client.unarchiveNativeSession(handle); + + expect(archiveClient.calls.sessionUpdate).toEqual([ + { + sessionID: "session-1", + directory: cwd, + time: { archived: expect.any(Number) }, + }, + ]); + expect(unarchiveClient.calls.sessionUpdate).toEqual([ + { + sessionID: "session-1", + directory: cwd, + time: { archived: null }, + }, + ]); + expect(runtime.acquisitions.every((acquisition) => acquisition.releaseCount === 1)).toBe(true); + rmSync(cwd, { recursive: true, force: true }); + }); + test("single turn completes with streaming deltas", async () => { const cwd = tmpCwd(); const runtime = new TestOpenCodeHarness(); @@ -713,65 +754,6 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { }); describe("OpenCode adapter context-window normalization", () => { - test("close reconciliation aborts then archives upstream session", async () => { - const abort = vi.fn().mockResolvedValue({ data: true, error: undefined }); - const update = vi.fn().mockResolvedValue({ - data: { id: "session-1", time: { archived: Date.now() } }, - error: undefined, - }); - - await __openCodeInternals.reconcileOpenCodeSessionClose({ - client: { - session: { - abort, - update, - }, - } as never, - sessionId: "session-1", - directory: "/tmp/project", - logger: createTestLogger(), - }); - - expect(abort).toHaveBeenCalledWith({ - sessionID: "session-1", - directory: "/tmp/project", - }); - expect(update).toHaveBeenCalledTimes(1); - expect(update).toHaveBeenCalledWith({ - sessionID: "session-1", - directory: "/tmp/project", - time: { - archived: expect.any(Number), - }, - }); - }); - - test("close reconciliation still archives when abort returns an error", async () => { - const abort = vi.fn().mockResolvedValue({ - data: undefined, - error: { data: {}, errors: [], success: false }, - }); - const update = vi.fn().mockResolvedValue({ - data: { id: "session-1", time: { archived: Date.now() } }, - error: undefined, - }); - - await __openCodeInternals.reconcileOpenCodeSessionClose({ - client: { - session: { - abort, - update, - }, - } as never, - sessionId: "session-1", - directory: "/tmp/project", - logger: createTestLogger(), - }); - - expect(abort).toHaveBeenCalledTimes(1); - expect(update).toHaveBeenCalledTimes(1); - }); - test("builds OpenCode file parts for image prompt blocks", () => { expect( __openCodeInternals.buildOpenCodePromptParts([ @@ -2345,6 +2327,7 @@ describe("OpenCode persisted sessions", () => { describe("OpenCode provider subagent contract", () => { async function createAdoptedChildSession(): Promise<{ readonly runtime: TestOpenCodeHarness; + readonly provider: OpenCodeAgentClient; readonly parent: Awaited>; readonly child: Awaited>; readonly childClient: TestOpenCodeClient; @@ -2387,9 +2370,36 @@ describe("OpenCode provider subagent contract", () => { undefined, { env: { PASEO_AGENT_ID: "child-agent" } }, ); - return { runtime, parent, child, childClient }; + return { runtime, provider: client, parent, child, childClient }; } + test("archives an adopted child on the parent's registered OpenCode server", async () => { + const { runtime, provider, parent, child } = await createAdoptedChildSession(); + const archiveClient = new TestOpenCodeClient(); + runtime.enqueueClient(archiveClient); + + await provider.archiveNativeSession({ + provider: "opencode", + sessionId: "ses_child_external", + metadata: { cwd: "/workspace/repo" }, + }); + + expect(archiveClient.calls.sessionUpdate).toEqual([ + { + sessionID: "ses_child_external", + directory: "/workspace/repo", + time: { archived: expect.any(Number) }, + }, + ]); + expect(runtime.acquisitions.at(-1)).toEqual({ + kind: "existing", + url: runtime.server.url, + releaseCount: 1, + }); + await child.close(); + await parent.close(); + }); + test("resumes an adopted child on the parent's registered OpenCode server", async () => { const runtime = new TestOpenCodeHarness(); const parentClient = new TestOpenCodeClient(); @@ -3033,11 +3043,11 @@ describe("OpenCode provider subagent contract", () => { event: { type: "timeline", id: "ses_child_with_history", - item: { + item: expect.objectContaining({ type: "assistant_message", text: "Persisted child result.", messageId: "msg_child_history", - }, + }), timestamp: "1970-01-01T00:00:02.000Z", }, }), @@ -3111,11 +3121,11 @@ describe("OpenCode provider subagent contract", () => { event: expect.objectContaining({ type: "timeline", id: "ses_child_hydrating", - item: { + item: expect.objectContaining({ type: "assistant_message", text: "Hydration did not lose this.", messageId: "msg_child_hydrating", - }, + }), }), }), ), diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index b70fdfa76..aabd1b627 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -431,7 +431,7 @@ function isOpenCodeNotFoundError(error: unknown): boolean { ); } -async function reconcileOpenCodeSessionClose(params: { +async function abortOpenCodeSession(params: { client: Pick; sessionId: string; directory: string; @@ -462,31 +462,6 @@ async function reconcileOpenCodeSessionClose(params: { "Failed to abort OpenCode session during close", ); } - - try { - const response = await client.session.update({ - sessionID: sessionId, - directory, - time: { archived: Date.now() }, - }); - if (response.error && !isOpenCodeNotFoundError(response.error)) { - logger.warn( - { - sessionId, - error: toDiagnosticErrorMessage(response.error), - }, - "Failed to archive OpenCode session during close", - ); - } - } catch (error) { - logger.warn( - { - sessionId, - error: toDiagnosticErrorMessage(error), - }, - "Failed to archive OpenCode session during close", - ); - } } function isOpenCodeHeadersTimeoutFailure(error: unknown): boolean { @@ -1254,7 +1229,6 @@ export const __openCodeInternals = { hasNormalizedOpenCodeUsage, mergeOpenCodeStepFinishUsage, parseOpenCodeModelLookupKey, - reconcileOpenCodeSessionClose, resolveOpenCodeModelLookupKeyFromAssistantMessage, resolveOpenCodeSelectedModelContextWindow, isSelectableOpenCodeAgent, @@ -1353,7 +1327,7 @@ export class OpenCodeAgentClient implements AgentClient { url, ); } catch (error) { - acquisition.release(); + await acquisition.release(); throw error; } } @@ -1407,7 +1381,7 @@ export class OpenCodeAgentClient implements AgentClient { registeredAcquisition !== null, ); } catch (error) { - acquisition.release(); + await acquisition.release(); throw error; } } @@ -1439,7 +1413,7 @@ export class OpenCodeAgentClient implements AgentClient { ]); return { models, modes }; } finally { - acquisition.release(); + await acquisition.release(); } } @@ -1455,7 +1429,7 @@ export class OpenCodeAgentClient implements AgentClient { try { return await listOpenCodeCommandsFromSdk(client, openCodeConfig.cwd); } finally { - acquisition.release(); + await acquisition.release(); } } @@ -1476,7 +1450,7 @@ export class OpenCodeAgentClient implements AgentClient { try { return await collectOpenCodeImportableSessionsFromSdk(client, options); } finally { - acquisition.release(); + await acquisition.release(); } } @@ -1512,7 +1486,57 @@ export class OpenCodeAgentClient implements AgentClient { }, }); } finally { - acquisition.release(); + await acquisition.release(); + } + } + + async archiveNativeSession(handle: AgentPersistenceHandle): Promise { + await this.setNativeSessionArchived(handle, Date.now()); + } + + async unarchiveNativeSession(handle: AgentPersistenceHandle): Promise { + await this.setNativeSessionArchived(handle, null); + } + + private async setNativeSessionArchived( + handle: AgentPersistenceHandle, + archivedAt: number | null, + ): Promise { + const metadata = (handle.metadata ?? {}) as Partial; + if (!metadata.cwd) { + throw new Error("OpenCode native archive update requires the original working directory"); + } + + const registeredServerUrl = getOpenCodeChildSessionServerUrl(handle.sessionId); + const acquisition = + (registeredServerUrl ? this.serverManager.acquireExisting(registeredServerUrl) : null) ?? + (await this.serverManager.acquireCurrent()); + const client = this.createOpenCodeClient({ + baseUrl: acquisition.server.url, + directory: metadata.cwd, + }); + try { + // OpenCode accepts null to clear the archive timestamp, but this SDK + // release's generated request type still exposes only number. + const updateSession = client.session.update.bind(client.session) as (parameters: { + sessionID: string; + directory?: string; + time?: { archived?: number | null }; + }) => ReturnType; + const response = readOpenCodeRecord( + await updateSession({ + sessionID: handle.sessionId, + directory: metadata.cwd, + time: { archived: archivedAt }, + }), + ); + if (response?.error) { + throw new Error( + `Failed to ${archivedAt === null ? "unarchive" : "archive"} OpenCode session: ${toDiagnosticErrorMessage(response.error)}`, + ); + } + } finally { + await acquisition.release(); } } @@ -2897,7 +2921,7 @@ class OpenCodeAgentSession implements AgentSession { private childHydrationCompleted = false; private readonly unrelatedSessionIds = new Set(); private selectedModelContextWindowMaxTokens: number | undefined; - private releaseServer: (() => void) | null; + private releaseServer: (() => Promise) | null; private eventStreamAbortController: AbortController | null = null; private eventStreamReady: Deferred | null = null; private eventStreamTask: Promise | null = null; @@ -2911,7 +2935,7 @@ class OpenCodeAgentSession implements AgentSession { sessionId: string, logger: Logger, modelContextWindowsByModelKey: ReadonlyMap = new Map(), - releaseServer?: () => void, + releaseServer?: () => Promise, persistSession = true, private readonly agentId?: string, private readonly serverUrl?: string, @@ -3891,7 +3915,7 @@ class OpenCodeAgentSession implements AgentSession { this.eventStreamReady = null; this.eventStreamTask = null; this.subscribers.clear(); - await reconcileOpenCodeSessionClose({ + await abortOpenCodeSession({ client: this.client, sessionId: this.sessionId, directory: this.config.cwd, @@ -3900,7 +3924,7 @@ class OpenCodeAgentSession implements AgentSession { await this.deleteProviderSessionIfEphemeral(); this.activeForegroundTurnId = null; } finally { - this.releaseServer?.(); + await this.releaseServer?.(); this.releaseServer = null; } } diff --git a/packages/server/src/server/agent/providers/opencode-server-manager.test.ts b/packages/server/src/server/agent/providers/opencode-server-manager.test.ts index 60c93c6ef..de094dba6 100644 --- a/packages/server/src/server/agent/providers/opencode-server-manager.test.ts +++ b/packages/server/src/server/agent/providers/opencode-server-manager.test.ts @@ -37,10 +37,10 @@ describe("OpenCodeServerManager generations", () => { expect(newAcquisition.server.url).toBe("http://127.0.0.1:4102"); expect(runtime.terminatedPorts).toEqual([]); - newAcquisition.release(); - oldAcquisition.release(); + await newAcquisition.release(); + await oldAcquisition.release(); - expect(runtime.terminatedPorts).toEqual([4101]); + expect(runtime.terminatedPorts).toEqual([4102, 4101]); }); test("new acquisitions after rotation use the new server", async () => { @@ -48,22 +48,22 @@ describe("OpenCodeServerManager generations", () => { const oldAcquisition = await manager.acquireCurrent(); const rotatedAcquisition = await manager.acquireNew(); - rotatedAcquisition.release(); - const nextAcquisition = await manager.acquireCurrent(); expect(nextAcquisition.server.url).toBe("http://127.0.0.1:4202"); expect(runtime.terminatedPorts).toEqual([]); - nextAcquisition.release(); - oldAcquisition.release(); + await rotatedAcquisition.release(); + expect(runtime.terminatedPorts).toEqual([]); + await nextAcquisition.release(); + await oldAcquisition.release(); }); test("concurrent new-server acquisitions share one fresh generation", async () => { const { manager, runtime } = createTestManager([4251, 4252, 4253]); const initialAcquisition = await manager.acquireCurrent(); - initialAcquisition.release(); + await initialAcquisition.release(); const [modelsAcquisition, modesAcquisition] = await Promise.all([ manager.acquireNew(), @@ -74,8 +74,8 @@ describe("OpenCodeServerManager generations", () => { expect(modesAcquisition.server.url).toBe("http://127.0.0.1:4252"); expect(runtime.launchedPorts).toEqual([4251, 4252]); - modesAcquisition.release(); - modelsAcquisition.release(); + await modesAcquisition.release(); + await modelsAcquisition.release(); }); test("release is idempotent", async () => { @@ -83,12 +83,12 @@ describe("OpenCodeServerManager generations", () => { const oldAcquisition = await manager.acquireCurrent(); const newAcquisition = await manager.acquireNew(); - newAcquisition.release(); + await newAcquisition.release(); - oldAcquisition.release(); - oldAcquisition.release(); + await oldAcquisition.release(); + await oldAcquisition.release(); - expect(runtime.terminatedPorts).toEqual([4301]); + expect(runtime.terminatedPorts).toEqual([4302, 4301]); }); test("shutdown kills current and retired servers", async () => { @@ -152,16 +152,16 @@ describe("OpenCodeServerManager generations", () => { const dedicatedStart = manager.acquireDedicated({ TEST_ENV: "custom" }); await runtime.settle(); - currentAcquisition.release(); - expect(runtime.terminatedPorts).toEqual([]); + await currentAcquisition.release(); + expect(runtime.terminatedPorts).toEqual([4473]); runtime.processForPort(4474).announceListening(); const dedicatedAcquisition = await dedicatedStart; expect(dedicatedAcquisition.server.url).toBe("http://127.0.0.1:4474"); - dedicatedAcquisition.release(); - expect(runtime.terminatedPorts).toEqual([4474]); + await dedicatedAcquisition.release(); + expect(runtime.terminatedPorts).toEqual([4473, 4474]); }); test("acquireExisting keeps a retired dedicated server alive until every reference releases", async () => { @@ -172,10 +172,10 @@ describe("OpenCodeServerManager generations", () => { expect(existingAcquisition?.server.url).toBe("http://127.0.0.1:4475"); - dedicatedAcquisition.release(); + await dedicatedAcquisition.release(); expect(runtime.terminatedPorts).toEqual([]); - existingAcquisition?.release(); + await existingAcquisition?.release(); expect(runtime.terminatedPorts).toEqual([4475]); }); @@ -187,7 +187,7 @@ describe("OpenCodeServerManager generations", () => { expect(manager.acquireExisting("http://127.0.0.1:9999")).toBe(null); - acquisition.release(); + await acquisition.release(); expect(runtime.terminatedPorts).toEqual([4476]); expect(manager.acquireExisting(url)).toBe(null); }); @@ -197,12 +197,26 @@ describe("OpenCodeServerManager generations", () => { const firstAcquisition = await manager.acquireCurrent(); const secondAcquisition = await manager.acquireNew(); - secondAcquisition.release(); + await secondAcquisition.release(); const thirdAcquisition = await manager.acquireNew(); - thirdAcquisition.release(); - firstAcquisition.release(); + await thirdAcquisition.release(); + await firstAcquisition.release(); - expect(runtime.terminatedPorts).toEqual([4502, 4501]); + expect(runtime.terminatedPorts).toEqual([4502, 4503, 4501]); + }); + + test("final release detaches the terminating generation before a concurrent acquire", async () => { + const { manager, runtime } = createTestManager([4551, 4552]); + + const first = await manager.acquireCurrent(); + const release = first.release(); + const next = await manager.acquireCurrent(); + + await release; + expect(next.server.url).toBe("http://127.0.0.1:4552"); + expect(runtime.terminatedPorts).toEqual([4551]); + + await next.release(); }); }); @@ -258,7 +272,7 @@ describe("OpenCodeServerManager managed process ledger", () => { }), ]); - acquisition.release(); + await acquisition.release(); await manager.shutdown(); } finally { rmSync(tempDir, { recursive: true, force: true }); diff --git a/packages/server/src/server/agent/providers/opencode/server-manager.ts b/packages/server/src/server/agent/providers/opencode/server-manager.ts index 3fbb89ad2..d765d3d27 100644 --- a/packages/server/src/server/agent/providers/opencode/server-manager.ts +++ b/packages/server/src/server/agent/providers/opencode/server-manager.ts @@ -21,11 +21,10 @@ const OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000; export interface OpenCodeServerAcquisition { server: { port: number; url: string }; - release: () => void; + release: () => Promise; } export interface OpenCodeServerManagerLike { - ensureRunning(): Promise<{ port: number; url: string }>; acquireCurrent(): Promise; acquireNew(): Promise; acquireDedicated(env: Record): Promise; @@ -135,12 +134,6 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { process.on("SIGINT", cleanup); } - async ensureRunning(): Promise<{ port: number; url: string }> { - const acquisition = await this.acquireCurrent(); - acquisition.release(); - return acquisition.server; - } - async acquireCurrent(): Promise { const server = await this.getCurrentServer(); return this.acquireServer(server); @@ -160,7 +153,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { await server.ready; return acquisition; } catch (error) { - acquisition.release(); + await acquisition.release(); throw error; } } @@ -188,20 +181,37 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { private acquireServer(server: OpenCodeServerGeneration): OpenCodeServerAcquisition { server.refCount += 1; - let released = false; + let releasePromise: Promise | null = null; return { server: { port: server.port, url: server.url }, - release: () => { - if (released) { - return; + release: async () => { + if (releasePromise) { + return releasePromise; } - released = true; - server.refCount -= 1; - this.cleanupRetiredServers(); + releasePromise = this.releaseServer(server); + return releasePromise; }, }; } + private async releaseServer(server: OpenCodeServerGeneration): Promise { + server.refCount = Math.max(0, server.refCount - 1); + if (server.refCount > 0) { + return; + } + + if (this.currentServer === server) { + this.currentServer = null; + server.retired = true; + } + if (!server.retired) { + return; + } + + this.retiredServers.delete(server); + await this.killServer(server); + } + private async getNewServer(): Promise { if (this.newServerPromise) { return this.newServerPromise; @@ -261,14 +271,14 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { existing.retired = true; this.retiredServers.add(existing); this.currentServer = null; - this.cleanupRetiredServers(); + await this.cleanupRetiredServers(); } if (this.startPromise) { const pending = await this.startPromise; pending.retired = true; this.retiredServers.add(pending); this.currentServer = null; - this.cleanupRetiredServers(); + await this.cleanupRetiredServers(); } } @@ -417,13 +427,15 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { this.retiredServers.clear(); } - private cleanupRetiredServers(): void { + private async cleanupRetiredServers(): Promise { + const cleanup: Promise[] = []; for (const server of Array.from(this.retiredServers)) { if (server.refCount === 0) { this.retiredServers.delete(server); - void this.killServer(server); + cleanup.push(this.killServer(server)); } } + await Promise.all(cleanup); } private async killServer(server: OpenCodeServerGeneration): Promise { diff --git a/packages/server/src/server/agent/providers/opencode/test-server-manager.ts b/packages/server/src/server/agent/providers/opencode/test-server-manager.ts index f13a2eb95..e4cc8a65e 100644 --- a/packages/server/src/server/agent/providers/opencode/test-server-manager.ts +++ b/packages/server/src/server/agent/providers/opencode/test-server-manager.ts @@ -10,12 +10,6 @@ export interface TestOpenCodeServerAcquisition { export class TestOpenCodeServerManager implements OpenCodeServerManagerLike { readonly acquisitions: TestOpenCodeServerAcquisition[] = []; readonly server = { port: 1234, url: "http://127.0.0.1:1234" }; - ensureRunningCount = 0; - - async ensureRunning(): Promise<{ port: number; url: string }> { - this.ensureRunningCount += 1; - return this.server; - } async acquireCurrent(): Promise { return this.recordAcquisition({ kind: "current" }); @@ -47,7 +41,7 @@ export class TestOpenCodeServerManager implements OpenCodeServerManagerLike { this.acquisitions.push(acquisition); return { server: this.server, - release: () => { + release: async () => { acquisition.released = true; }, }; diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts index 754b1b86d..3c3ac4dc7 100644 --- a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts +++ b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts @@ -53,16 +53,12 @@ export class TestOpenCodeHarness implements OpenCodeServerManagerLike { this.acquisitions.push(acquisition); return { server: this.server, - release: () => { + release: async () => { acquisition.releaseCount += 1; }, }; } - async ensureRunning(): Promise<{ port: number; url: string }> { - return this.server; - } - readonly createClient = (options: { baseUrl: string; directory: string }): OpencodeClient => { this.clientCreations.push(options); const client = this.clients.shift() ?? new TestOpenCodeClient(); diff --git a/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts b/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts index 07cf1341d..f3b2af84a 100644 --- a/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts +++ b/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts @@ -2,7 +2,7 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; import pino from "pino"; -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { PiCliRuntime } from "./cli-runtime.js"; import type { PiRuntimeLaunch } from "./runtime.js"; @@ -47,10 +47,7 @@ function createRuntime( }); } -function replyToCommands( - child: PiChild, - handler: (command: Record) => unknown, -): void { +function onPiCommand(child: PiChild, handler: (command: Record) => void): void { let buffer = ""; child.stdin.on("data", (chunk) => { buffer += chunk.toString(); @@ -59,34 +56,68 @@ function replyToCommands( if (newlineIndex === -1) break; const line = buffer.slice(0, newlineIndex); buffer = buffer.slice(newlineIndex + 1); - const command = JSON.parse(line) as Record; - try { - const result = handler(command); - child.stdout.write( - `${JSON.stringify({ - id: command.id, - type: "response", - command: command.type, - success: true, - data: result, - })}\n`, - ); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - child.stdout.write( - `${JSON.stringify({ - id: command.id, - type: "response", - command: command.type, - success: false, - error: message, - })}\n`, - ); - } + handler(JSON.parse(line) as Record); } }); } +function replyToCommands( + child: PiChild, + handler: (command: Record) => unknown, +): void { + onPiCommand(child, (command) => { + try { + const result = handler(command); + child.stdout.write( + `${JSON.stringify({ + id: command.id, + type: "response", + command: command.type, + success: true, + data: result, + })}\n`, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + child.stdout.write( + `${JSON.stringify({ + id: command.id, + type: "response", + command: command.type, + success: false, + error: message, + })}\n`, + ); + } + }); +} + +function capturePendingCommand(child: PiChild, type: string): Promise> { + return new Promise((resolve) => { + onPiCommand(child, (command) => { + if (command.type === type) { + resolve(command); + } + }); + }); +} + +function writePiResponse( + child: PiChild, + command: Record, + data: unknown = {}, +): void { + child.stdout.write( + `${JSON.stringify({ + id: command.id, + type: "response", + command: command.type, + success: true, + data, + })}\n`, + ); +} + describe("PiCliRuntime", () => { test("starts pi in rpc mode and resolves command responses", async () => { const child = createPiChild(); @@ -282,6 +313,51 @@ describe("PiCliRuntime", () => { await rejection; }); + test("compact waits beyond the default control-plane timeout for a late response", async () => { + vi.useFakeTimers(); + const child = createPiChild(); + const pendingCompact = capturePendingCommand(child, "compact"); + const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + + try { + const compactPromise = session.compact("focus on tests"); + const compactCommand = await pendingCompact; + await vi.advanceTimersByTimeAsync(35_000); + + expect(compactCommand).toMatchObject({ + type: "compact", + customInstructions: "focus on tests", + id: expect.any(String), + }); + + writePiResponse(child, compactCommand, { + summary: "done", + firstKeptEntryId: "entry-1", + tokensBefore: 120_000, + estimatedTokensAfter: 20_000, + }); + + await expect(compactPromise).resolves.toBeUndefined(); + } finally { + vi.useRealTimers(); + await session.close(); + } + }); + + test("compact without a wall-clock timeout rejects when the session closes", async () => { + const child = createPiChild(); + const pendingCompact = capturePendingCommand(child, "compact"); + const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + + const compactPromise = session.compact(); + await pendingCompact; + + const rejection = expect(compactPromise).rejects.toThrow("Pi RPC session is closed"); + await session.close(); + + await rejection; + }); + test("disposes the Pi process", async () => { const child = createPiChild(); const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); 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 aee7b2a78..66cff0daf 100644 --- a/packages/server/src/server/agent/providers/pi/cli-runtime.ts +++ b/packages/server/src/server/agent/providers/pi/cli-runtime.ts @@ -2,7 +2,11 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process"; import type { Logger } from "pino"; import type { ProviderRuntimeSettings } from "../../provider-launch-config.js"; -import { JsonlRpcProcess, type JsonlRpcLaunch } from "../jsonl-rpc-process.js"; +import { + JSONL_RPC_NO_TIMEOUT, + JsonlRpcProcess, + type JsonlRpcLaunch, +} from "../jsonl-rpc-process.js"; import { buildPiLaunch, type PiRuntime, @@ -26,6 +30,14 @@ const DEFAULT_PI_COMMAND: [string, ...string[]] = [ ]; const DEFAULT_COMMANDS_RPC_NAME = "get_commands"; +/** + * Pi RPC timeout policy: + * - Control-plane / accept-and-stream (`prompt`, `get_state`, `abort`, …): default 30s + * - Long-running blocking LLM jobs (`compact`): no wall-clock timeout — complete on + * response, process death, or session close (`JsonlRpcProcess.failAll` / `close`). + */ +const PI_COMPACT_REQUEST_TIMEOUT_MS = JSONL_RPC_NO_TIMEOUT; + export interface PiCliRuntimeOptions { logger: Logger; runtimeSettings?: ProviderRuntimeSettings; @@ -112,10 +124,16 @@ class PiCliRuntimeSession implements PiRuntimeSession { } async compact(customInstructions?: string): Promise { - await this.request({ - type: "compact", - ...(customInstructions ? { customInstructions } : {}), - }); + // Compact is a blocking LLM summarization job; Pi only returns the RPC + // response after the summary is written. A control-plane 30s timeout falsely + // fails long sessions while the real compact continues (issue #1946). + await this.request( + { + type: "compact", + ...(customInstructions ? { customInstructions } : {}), + }, + PI_COMPACT_REQUEST_TIMEOUT_MS, + ); } async setAutoCompaction(enabled: boolean): Promise { @@ -135,7 +153,7 @@ class PiCliRuntimeSession implements PiRuntimeSession { return data.messages ?? []; } - async getAvailableModels(timeoutMs?: number): Promise { + async getAvailableModels(timeoutMs?: number | null): Promise { const data = (await this.request({ type: "get_available_models" }, timeoutMs)) as { models?: PiModel[]; }; @@ -208,7 +226,7 @@ class PiCliRuntimeSession implements PiRuntimeSession { await this.process.close(new Error("Pi RPC session is closed")); } - request(command: PiRpcCommand, timeoutMs?: number): Promise { + request(command: PiRpcCommand, timeoutMs?: number | null): Promise { return this.process.request(command, timeoutMs); } diff --git a/packages/server/src/server/agent/providers/pi/runtime.ts b/packages/server/src/server/agent/providers/pi/runtime.ts index 0b4d38afa..6d50693ed 100644 --- a/packages/server/src/server/agent/providers/pi/runtime.ts +++ b/packages/server/src/server/agent/providers/pi/runtime.ts @@ -51,12 +51,15 @@ export interface PiRuntimeSession { abort(): Promise; getState(): Promise; getMessages(): Promise; - getAvailableModels(timeoutMs?: number): Promise; + getAvailableModels(timeoutMs?: number | null): Promise; setModel(provider: string, modelId: string): Promise; setThinkingLevel(level: string): Promise; getSessionStats(): Promise; getCommands(): Promise; - request(command: { type: string; [key: string]: unknown }, timeoutMs?: number): Promise; + request( + command: { type: string; [key: string]: unknown }, + timeoutMs?: number | null, + ): Promise; sendRawFrame(frame: object & { type: string }): void; respondToExtensionUiRequest( id: string, diff --git a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts index 8332c3f50..8093baeb2 100644 --- a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts +++ b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts @@ -227,7 +227,7 @@ export class FakePiSession implements PiRuntimeSession { return this.messages; } - async getAvailableModels(_timeoutMs?: number): Promise { + async getAvailableModels(_timeoutMs?: number | null): Promise { return this.models; } diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 0cc428193..12aa43079 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -23,12 +23,15 @@ import type { AgentStorage } from "../agent-storage.js"; import { ensureAgentLoaded } from "../agent-loading.js"; import { isStoredAgentProviderAvailable } from "../../persistence-hooks.js"; import { + archiveByScope, killTerminalsForWorkspace, + requireActiveWorkspaceForArchive, type ArchiveDependencies, } from "../../workspace-archive-service.js"; import { createAgentCommand, type CreateAgentFromMcpInput } from "../create-agent/create.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "../../voice-types.js"; import type { FirstAgentContext } from "../../messages.js"; +import { everyMsToFiveFieldCron } from "@getpaseo/protocol/schedule/cadence"; import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../../path-utils.js"; import type { TerminalManager } from "../../../terminal/terminal-manager.js"; import type { CreatePaseoWorktreeWorkflowFn } from "../../worktree-session.js"; @@ -65,14 +68,16 @@ import { } from "../lifecycle-command.js"; import type { ForgeService } from "../../../services/forge-service.js"; import type { WorkspaceGitService } from "../../workspace-git-service.js"; -import type { WorkspaceRegistry } from "../../workspace-registry.js"; -import { WorktreeRequestError } from "../../worktree-errors.js"; +import type { + PersistedWorkspaceRecord, + ProjectRegistry, + WorkspaceRegistry, +} from "../../workspace-registry.js"; +import { resolveWorktreeSourceCwd } from "../../workspace-source.js"; import { - archiveCommand, type ArchiveCommandDependencies, - createPaseoWorktreeCommand, type CreatePaseoWorktreeCommandInput, - listPaseoWorktreesCommand, + createPaseoWorktreeCommand, } from "../../worktree/commands.js"; import { registerBrowserTools } from "../../browser-tools/tools.js"; import type { BrowserToolsBroker } from "../../browser-tools/broker.js"; @@ -100,7 +105,13 @@ export interface PaseoToolHostDependencies { listActiveWorkspaces?: ArchiveDependencies["listActiveWorkspaces"]; archiveWorkspaceRecord?: ArchiveDependencies["archiveWorkspaceRecord"]; emitWorkspaceUpdatesForWorkspaceIds?: ArchiveDependencies["emitWorkspaceUpdatesForWorkspaceIds"]; - workspaceRegistry?: Pick; + workspaceRegistry?: Pick; + projectRegistry?: Pick; + createDirectoryWorkspace?: ( + cwd: string, + title?: string | null, + projectId?: string, + ) => Promise; markWorkspaceArchiving?: ArchiveDependencies["markWorkspaceArchiving"]; clearWorkspaceArchiving?: ArchiveDependencies["clearWorkspaceArchiving"]; createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn; @@ -157,6 +168,105 @@ interface ProviderSummary { error?: string; } +const WorkspaceAutomationSummarySchema = z.object({ + workspaceId: z.string(), + projectId: z.string(), + cwd: z.string(), + isolation: z.enum(["local", "worktree"]), + kind: z.enum(["directory", "local_checkout", "worktree"]), + title: z.string().nullable(), +}); + +function toWorkspaceAutomationSummary(workspace: PersistedWorkspaceRecord) { + return { + workspaceId: workspace.workspaceId, + projectId: workspace.projectId, + cwd: workspace.cwd, + isolation: workspace.kind === "worktree" ? ("worktree" as const) : ("local" as const), + kind: workspace.kind, + title: workspace.title, + }; +} + +type WorkspaceWorktreeMode = "branch-off" | "checkout-branch" | "checkout-pr"; + +interface WorkspaceWorktreeOptions { + mode?: WorkspaceWorktreeMode; + worktreeSlug?: string; + branchName?: string; + baseBranch?: string; + branch?: string; + prNumber?: number; + forge?: string; +} + +type WorkspaceWorktreeTarget = Pick< + CreatePaseoWorktreeCommandInput, + "action" | "branchName" | "refName" | "checkoutSource" +>; + +function assertOptionsAbsent( + options: Array<[name: string, value: unknown]>, + message: string, +): void { + if (options.some(([, value]) => value !== undefined)) { + throw new Error(message); + } +} + +function resolveWorkspaceWorktreeTarget(input: WorkspaceWorktreeOptions): WorkspaceWorktreeTarget { + switch (input.mode ?? "branch-off") { + case "branch-off": + assertOptionsAbsent( + [ + ["branch", input.branch], + ["prNumber", input.prNumber], + ["forge", input.forge], + ], + "branch, prNumber, and forge require a checkout mode", + ); + return { + action: "branch-off", + ...(input.branchName ? { branchName: input.branchName } : {}), + ...(input.baseBranch ? { refName: input.baseBranch } : {}), + }; + case "checkout-branch": + if (!input.branch) { + throw new Error("branch is required for checkout-branch mode"); + } + assertOptionsAbsent( + [ + ["branchName", input.branchName], + ["baseBranch", input.baseBranch], + ["prNumber", input.prNumber], + ["forge", input.forge], + ], + "branchName, baseBranch, prNumber, and forge are not valid for checkout-branch mode", + ); + return { action: "checkout", refName: input.branch }; + case "checkout-pr": + if (input.prNumber === undefined) { + throw new Error("prNumber is required for checkout-pr mode"); + } + assertOptionsAbsent( + [ + ["branchName", input.branchName], + ["baseBranch", input.baseBranch], + ["branch", input.branch], + ], + "branchName, baseBranch, and branch are not valid for checkout-pr mode", + ); + return { + action: "checkout", + checkoutSource: { + kind: "change_request", + ...(input.forge ? { forge: input.forge } : {}), + number: input.prNumber, + }, + }; + } +} + function toProviderSummary(entry: { provider: AgentProvider; label?: string; @@ -307,7 +417,14 @@ function resolveScheduleUpdateCadence(input: ScheduleUpdateToolInput): ScheduleC throw new Error("timezone can only be used with cron"); } if (every !== undefined) { - return { type: "every", everyMs: parseDurationString(every) }; + // COMPAT(scheduleEveryInput): accept the old hidden field and canonicalize it before write. + // Added in v0.2.0; remove after 2027-01-17. + const everyMs = parseDurationString(every); + const expression = everyMsToFiveFieldCron(everyMs); + if (expression) { + return { type: "cron", expression }; + } + throw new Error(`${every} cannot be represented faithfully by five-field cron`); } if (cron !== undefined) { return { @@ -382,13 +499,6 @@ const TerminalSummarySchema = z.object({ cwd: z.string(), }); -const WorktreeSummarySchema = z.object({ - path: z.string(), - createdAt: z.string(), - branchName: z.string().optional(), - head: z.string().optional(), -}); - function resolveTerminalKeyToken(key: string, literal: boolean): string { if (literal) { return key; @@ -592,7 +702,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase ...(typeof callerAgent.config.webSearch === "boolean" ? { webSearch: callerAgent.config.webSearch } : {}), - ...(callerAgent.config.title ? { title: callerAgent.config.title } : {}), ...(callerAgent.config.extra ? { extra: callerAgent.config.extra } : {}), ...(callerAgent.config.featureValues ? { featureValues: callerAgent.config.featureValues } @@ -633,19 +742,26 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }; }; - const resolveNewAgentScheduleTarget = (params?: { provider?: string; cwd?: string }) => { - if (!params?.provider?.trim()) { - throw new Error("provider is required when target is new-agent"); - } - + const resolveNewAgentScheduleTarget = (params?: { + provider?: string; + cwd?: string; + isolation?: "local" | "worktree"; + }) => { const callerAgent = resolveCallerAgent(); if (callerAgent) { return { type: "new-agent" as const, - config: buildCallerAgentScheduleConfig(callerAgent, params), + config: { + ...buildCallerAgentScheduleConfig(callerAgent, params), + ...(params?.isolation ? { isolation: params.isolation } : {}), + }, }; } + if (!params?.provider?.trim()) { + throw new Error("provider is required when target is new-agent"); + } + const resolvedProviderModel = resolveScheduleProviderAndModel({ provider: params?.provider, defaultProvider: params.provider, @@ -656,9 +772,34 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase provider: resolvedProviderModel.provider, cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(), ...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}), + ...(params?.isolation ? { isolation: params.isolation } : {}), }, }; }; + + async function requireScheduleTarget(id: string, type: "agent" | "new-agent") { + if (!scheduleService) { + throw new Error("Schedule service is not configured"); + } + const schedule = await scheduleService.inspect(id); + if (schedule.target.type !== type) { + throw new Error( + type === "agent" ? `Heartbeat not found: ${id}` : `Schedule not found: ${id}`, + ); + } + return schedule; + } + + async function requireCallerHeartbeat(id: string) { + if (!callerAgentId) { + throw new Error("Heartbeat operations require an agent-scoped session"); + } + const schedule = await requireScheduleTarget(id, "agent"); + if (schedule.target.type !== "agent" || schedule.target.agentId !== callerAgentId) { + throw new Error(`Heartbeat ${id} does not belong to caller ${callerAgentId}`); + } + return schedule; + } const ProviderModelInputSchema = AgentProviderEnum.trim() .refine((value) => value.includes("/"), { message: "provider must be provider/model, for example codex/gpt-5.4", @@ -820,13 +961,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase .strict() .describe("Create a new workspace for the agent."), ]); - const commonCreateAgentInputSchema = { - relationship: AgentRelationshipInputSchema.describe( - "Whether the created agent is a subagent under you or a detached root agent.", - ), - workspace: AgentWorkspaceInputSchema.describe( - "Workspace ownership/location for the created agent.", - ), + const commonCreateAgentFields = { title: z .string() .trim() @@ -846,8 +981,26 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase .min(1, "initialPrompt is required") .describe("Required first task to run immediately after creation."), }; + const legacyCreateAgentPlacementFields = { + relationship: AgentRelationshipInputSchema.describe( + "Whether the created agent is a subagent under you or a detached root agent.", + ), + workspace: AgentWorkspaceInputSchema.describe( + "Workspace ownership/location for the created agent.", + ), + }; + const canonicalCreateAgentFields = { + ...commonCreateAgentFields, + workspaceId: z + .string() + .min(1) + .optional() + .describe( + "Existing workspace id. Agent-scoped calls default to the caller workspace; top-level calls create a new local workspace when omitted.", + ), + }; const agentToAgentInputSchema = { - ...commonCreateAgentInputSchema, + ...canonicalCreateAgentFields, notifyOnFinish: z .boolean() .optional() @@ -857,7 +1010,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase ), }; const canonicalTopLevelInputSchema = { - ...commonCreateAgentInputSchema, + ...canonicalCreateAgentFields, background: z .boolean() .optional() @@ -873,9 +1026,17 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase "Agent-scoped only: get notified when the created agent finishes, errors, or needs permission.", ), }; + const legacyAgentToAgentInputSchema = { + ...commonCreateAgentFields, + ...legacyCreateAgentPlacementFields, + notifyOnFinish: agentToAgentInputSchema.notifyOnFinish, + }; const legacyTopLevelCreateAgentInputSchema = { - relationship: commonCreateAgentInputSchema.relationship.optional(), - workspace: commonCreateAgentInputSchema.workspace.optional(), + ...commonCreateAgentFields, + relationship: legacyCreateAgentPlacementFields.relationship.optional(), + workspace: legacyCreateAgentPlacementFields.workspace.optional(), + background: canonicalTopLevelInputSchema.background, + notifyOnFinish: canonicalTopLevelInputSchema.notifyOnFinish, cwd: z .string() .optional() @@ -916,15 +1077,15 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase .optional() .describe("Legacy GitHub PR number. Prefer workspace.source.target.githubPrNumber."), }; - const topLevelInputSchema = { - ...canonicalTopLevelInputSchema, - ...legacyTopLevelCreateAgentInputSchema, - }; - - const createAgentInputSchema = callerAgentId ? agentToAgentInputSchema : topLevelInputSchema; + const createAgentInputSchema = z + .object(callerAgentId ? agentToAgentInputSchema : canonicalTopLevelInputSchema) + .passthrough(); const agentToAgentCreateAgentArgsSchema = z.object(agentToAgentInputSchema).strict(); + const legacyAgentToAgentCreateAgentArgsSchema = z.object(legacyAgentToAgentInputSchema).strict(); const canonicalTopLevelCreateAgentArgsSchema = z.object(canonicalTopLevelInputSchema).strict(); - const topLevelCreateAgentArgsSchema = z.object(topLevelInputSchema).strict(); + const legacyTopLevelCreateAgentArgsSchema = z + .object(legacyTopLevelCreateAgentInputSchema) + .strict(); const commonSendAgentPromptInputSchema = { agentId: z.string(), prompt: z.string(), @@ -980,8 +1141,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase ), }; type AgentToAgentCreateAgentArgs = z.infer; + type LegacyAgentToAgentCreateAgentArgs = z.infer; type TopLevelCreateAgentArgs = z.infer; - type TopLevelCreateAgentToolArgs = z.infer; + type LegacyTopLevelCreateAgentArgs = z.infer; if (options.voiceOnly || options.enableVoiceTools || callerContext?.enableVoiceTools) { registerTool( @@ -1035,12 +1197,201 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }); } + registerTool( + "create_workspace", + { + title: "Create workspace", + description: + "Create a workspace using an existing local checkout or a new Paseo-managed worktree.", + inputSchema: { + isolation: z.enum(["local", "worktree"]), + path: z + .string() + .optional() + .describe("Local directory or source checkout. Defaults to your current workspace."), + projectId: z.string().optional().describe("Existing project id to own the workspace."), + title: z.string().trim().min(1).optional(), + mode: z + .enum(["branch-off", "checkout-branch", "checkout-pr"]) + .optional() + .describe("Worktree creation mode. Defaults to branch-off."), + worktreeSlug: z.string().trim().min(1).optional(), + branchName: z + .string() + .trim() + .min(1) + .optional() + .describe("New branch name for branch-off mode."), + baseBranch: z.string().trim().min(1).optional().describe("Base ref for branch-off mode."), + branch: z + .string() + .trim() + .min(1) + .optional() + .describe("Existing branch for checkout-branch mode."), + prNumber: z + .number() + .int() + .positive() + .optional() + .describe("Pull request or change request number for checkout-pr mode."), + forge: z + .string() + .trim() + .min(1) + .optional() + .describe("Forge for checkout-pr mode. Defaults to the source checkout."), + }, + outputSchema: WorkspaceAutomationSummarySchema.shape, + }, + async ({ + isolation, + path, + projectId, + title, + mode, + worktreeSlug, + branchName, + baseBranch, + branch, + prNumber, + forge, + }) => { + let workspace: PersistedWorkspaceRecord; + if (isolation === "local") { + const cwd = resolveScopedCwd(path, { required: true }); + assertOptionsAbsent( + [ + ["mode", mode], + ["worktreeSlug", worktreeSlug], + ["branchName", branchName], + ["baseBranch", baseBranch], + ["branch", branch], + ["prNumber", prNumber], + ["forge", forge], + ], + "Worktree options require isolation worktree", + ); + if (!options.createDirectoryWorkspace) { + throw new Error("Workspace provisioning is not configured"); + } + workspace = await options.createDirectoryWorkspace(cwd, title, projectId); + } else { + let cwd = + path !== undefined || !projectId ? resolveScopedCwd(path, { required: true }) : null; + if (!cwd) { + if (!options.projectRegistry) { + throw new Error("Project registry is not configured"); + } + cwd = await resolveWorktreeSourceCwd({ projectId }, options.projectRegistry); + } + const worktreeTarget = resolveWorkspaceWorktreeTarget({ + mode, + worktreeSlug, + branchName, + baseBranch, + branch, + prNumber, + forge, + }); + const result = await createPaseoWorktreeCommand( + { + paseoHome: options.paseoHome, + worktreesRoot: options.worktreesRoot, + createPaseoWorktreeWorkflow: options.createPaseoWorktree, + }, + { + cwd, + ...(projectId ? { projectId } : {}), + ...(worktreeSlug ? { worktreeSlug } : {}), + ...worktreeTarget, + ...(title ? { title } : {}), + }, + ); + if (!result.ok) { + throw result.cause; + } + workspace = result.createdWorktree.workspace; + } + + return { + content: [], + structuredContent: ensureValidJson(toWorkspaceAutomationSummary(workspace)), + }; + }, + ); + + registerTool( + "list_workspaces", + { + title: "List workspaces", + description: "List active workspaces.", + inputSchema: {}, + outputSchema: { workspaces: z.array(WorkspaceAutomationSummarySchema) }, + }, + async () => { + if (!options.workspaceRegistry) { + throw new Error("Workspace registry is not configured"); + } + const workspaces = (await options.workspaceRegistry.list()) + .filter((workspace) => !workspace.archivedAt) + .map(toWorkspaceAutomationSummary); + return { + content: [], + structuredContent: ensureValidJson({ workspaces }), + }; + }, + ); + + registerTool( + "archive_workspace", + { + title: "Archive workspace", + description: "Archive a workspace and everything it owns.", + inputSchema: { workspaceId: z.string().min(1) }, + outputSchema: { + workspaceId: z.string(), + archivedAgentIds: z.array(z.string()), + removedDirectory: z.boolean(), + }, + }, + async ({ workspaceId }) => { + if (!options.listActiveWorkspaces) { + throw new Error("Active workspace lister is required to archive workspaces"); + } + const workspace = await requireActiveWorkspaceForArchive( + { listActiveWorkspaces: options.listActiveWorkspaces }, + workspaceId, + ); + const result = await archiveByScope( + archiveWorktreeDependencies(options, { + agentManager, + agentStorage, + terminalManager: terminalManager ?? null, + logger: childLogger, + }), + { + requestId: "mcp:archive_workspace", + scope: { kind: "workspace", workspaceId: workspace.workspaceId }, + }, + ); + return { + content: [], + structuredContent: ensureValidJson({ + workspaceId, + archivedAgentIds: result.archivedAgentIds, + removedDirectory: result.removedDirectory, + }), + }; + }, + ); + registerTool( "create_agent", { title: "Create agent", description: - "Create an agent. Requires relationship, workspace, provider/model (for example codex/gpt-5.4), and an initial prompt. Do not guess; call list_providers and list_models first if uncertain.", + "Create an agent. Agent-scoped creation defaults to your workspace and creates your subagent. Top-level creation without workspaceId creates a new local workspace. Requires provider/model (for example codex/gpt-5.4) and an initial prompt. Do not guess; call list_providers and list_models first if uncertain.", inputSchema: createAgentInputSchema, outputSchema: { agentId: z.string(), @@ -1060,15 +1411,12 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase const { parsedArgs, worktree } = resolvedArgs; let requestedBackground: boolean; let notifyOnFinish: boolean; - let detached: boolean; if (resolvedArgs.kind === "agent-scoped") { requestedBackground = true; notifyOnFinish = parsedArgs.notifyOnFinish; - detached = resolvedArgs.relationship.kind === "detached"; } else { requestedBackground = resolvedArgs.parsedArgs.background; notifyOnFinish = resolvedArgs.parsedArgs.notifyOnFinish ?? false; - detached = resolvedArgs.parsedArgs.relationship.kind === "detached"; } const { snapshot, @@ -1101,7 +1449,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase mode: parsedArgs.settings?.modeId, background: requestedBackground, notifyOnFinish, - detached, + detached: resolvedArgs.detached, callerAgentId, callerContext, worktree, @@ -1167,15 +1515,16 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase type ResolvedCreateAgentToolArgs = | { kind: "agent-scoped"; - parsedArgs: AgentToAgentCreateAgentArgs; - relationship: AgentToAgentCreateAgentArgs["relationship"]; + parsedArgs: AgentToAgentCreateAgentArgs | LegacyAgentToAgentCreateAgentArgs; + detached: boolean; cwd: string | undefined; workspaceId: string | undefined; worktree: CreateAgentFromMcpInput["worktree"]; } | { kind: "top-level"; - parsedArgs: TopLevelCreateAgentArgs; + parsedArgs: TopLevelCreateAgentArgs | LegacyTopLevelCreateAgentArgs; + detached: boolean; cwd: string | undefined; workspaceId: string | undefined; worktree: CreateAgentFromMcpInput["worktree"]; @@ -1183,38 +1532,125 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase async function resolveCreateAgentToolArgs(args: unknown): Promise { if (callerAgentId) { + if (hasLegacyCreateAgentPlacement(args)) { + // COMPAT(nestedCreateAgentPlacement): accept the old relationship/workspace shape without + // advertising it to models. Added in v0.2.0; remove after 2027-01-17. + const parsed = legacyAgentToAgentCreateAgentArgsSchema.parse(args); + const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(parsed.workspace, { + prompt: parsed.initialPrompt, + }); + return { + kind: "agent-scoped", + parsedArgs: parsed, + detached: parsed.relationship.kind === "detached", + cwd, + workspaceId, + worktree, + }; + } const parsed = agentToAgentCreateAgentArgsSchema.parse(args); - const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(parsed.workspace, { + const { cwd, workspaceId } = await resolveCanonicalCreateAgentWorkspace(parsed.workspaceId, { prompt: parsed.initialPrompt, }); return { kind: "agent-scoped", parsedArgs: parsed, - relationship: parsed.relationship, + detached: false, + cwd, + workspaceId, + worktree: undefined, + }; + } + if (hasLegacyCreateAgentPlacement(args)) { + // COMPAT(nestedCreateAgentPlacement): see the agent-scoped branch above. + const parsedArgs = normalizeTopLevelCreateAgentArgs( + legacyTopLevelCreateAgentArgsSchema.parse(args), + ); + if (parsedArgs.relationship?.kind === "subagent") { + throw new Error("relationship subagent requires an agent-scoped tool session"); + } + if (!parsedArgs.workspace) { + throw new Error("Legacy create_agent placement could not be resolved"); + } + const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace( + parsedArgs.workspace, + { prompt: parsedArgs.initialPrompt }, + ); + return { + kind: "top-level", + parsedArgs, + detached: true, cwd, workspaceId, worktree, }; } - const parsedArgs = normalizeTopLevelCreateAgentArgs(topLevelCreateAgentArgsSchema.parse(args)); - if (parsedArgs.relationship.kind === "subagent") { - throw new Error("relationship subagent requires an agent-scoped tool session"); - } - const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(parsedArgs.workspace, { - prompt: parsedArgs.initialPrompt, - }); + const parsedArgs = canonicalTopLevelCreateAgentArgsSchema.parse(args); + const { cwd, workspaceId } = await resolveCanonicalCreateAgentWorkspace( + parsedArgs.workspaceId, + { prompt: parsedArgs.initialPrompt }, + ); return { kind: "top-level", parsedArgs, + detached: false, cwd, workspaceId, - worktree, + worktree: undefined, }; } + function hasLegacyCreateAgentPlacement(args: unknown): boolean { + if (!args || typeof args !== "object") { + return false; + } + const input = args as Record; + return [ + "relationship", + "workspace", + "cwd", + "worktreeName", + "branchName", + "baseBranch", + "refName", + "githubPrNumber", + ].some((key) => input[key] !== undefined); + } + + async function resolveCanonicalCreateAgentWorkspace( + workspaceId?: string, + firstAgentContext?: FirstAgentContext, + ): Promise<{ + cwd: string | undefined; + workspaceId: string; + }> { + if (workspaceId) { + const resolved = await resolveCreateAgentWorkspace( + { kind: "existing", workspaceId }, + undefined, + ); + return { cwd: resolved.cwd, workspaceId }; + } + if (!callerAgentId) { + if (!options.ensureWorkspaceForCreate) { + throw new Error("Workspace creation is not configured"); + } + const cwd = process.cwd(); + return { + cwd, + workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext), + }; + } + const caller = resolveCallerAgent(); + if (!caller?.workspaceId) { + throw new Error(`Caller agent ${callerAgentId} has no current workspace`); + } + return { cwd: undefined, workspaceId: caller.workspaceId }; + } + function normalizeTopLevelCreateAgentArgs( - args: TopLevelCreateAgentToolArgs, - ): TopLevelCreateAgentArgs { + args: LegacyTopLevelCreateAgentArgs, + ): LegacyTopLevelCreateAgentArgs { const { cwd, mode, @@ -1235,7 +1671,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }; if (canonicalCandidate.relationship && canonicalCandidate.workspace) { - return canonicalTopLevelCreateAgentArgsSchema.parse({ + return legacyTopLevelCreateAgentArgsSchema.parse({ ...canonicalCandidate, ...(Object.keys(settings).length > 0 ? { settings } : {}), }); @@ -1273,7 +1709,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }, }; - return canonicalTopLevelCreateAgentArgsSchema.parse({ + return legacyTopLevelCreateAgentArgsSchema.parse({ ...canonicalCandidate, relationship: { kind: "detached" }, workspace, @@ -1315,7 +1751,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase } async function resolveCreateAgentWorkspace( - workspace: AgentToAgentCreateAgentArgs["workspace"] | TopLevelCreateAgentArgs["workspace"], + workspace: + | LegacyAgentToAgentCreateAgentArgs["workspace"] + | NonNullable, firstAgentContext: FirstAgentContext | undefined, ): Promise<{ cwd: string | undefined; @@ -1995,16 +2433,17 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase .optional() .describe("IANA time zone for the cron cadence. For example: America/New_York."), name: z.string().optional(), - provider: AgentProviderEnum.optional().describe( - "Provider, or provider/model (for example: codex or codex/gpt-5.4).", + provider: (callerAgentId ? AgentProviderEnum.optional() : AgentProviderEnum).describe( + "Provider, or provider/model (for example: codex or codex/gpt-5.4). Defaults to the caller's provider in an agent-scoped session.", ), cwd: z.string().optional(), + isolation: z.enum(["local", "worktree"]).optional(), maxRuns: z.number().int().positive().optional(), expiresIn: z.string().optional(), }, outputSchema: ScheduleSummarySchema.shape, }, - async ({ prompt, cron, timezone, name, provider, cwd, maxRuns, expiresIn }) => { + async ({ prompt, cron, timezone, name, provider, cwd, isolation, maxRuns, expiresIn }) => { if (!scheduleService) { throw new Error("Schedule service is not configured"); } @@ -2016,7 +2455,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase cron, ...(timezone !== undefined ? { timezone } : {}), }), - target: resolveNewAgentScheduleTarget({ provider, cwd }), + target: resolveNewAgentScheduleTarget({ provider, cwd, isolation }), ...(name?.trim() ? { name: name.trim() } : {}), ...(maxRuns === undefined ? {} : { maxRuns }), ...(expiresAt === undefined ? {} : { expiresAt }), @@ -2078,6 +2517,27 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }, ); + registerTool( + "delete_heartbeat", + { + title: "Delete heartbeat", + description: "Delete one of your heartbeats.", + inputSchema: { id: z.string().min(1) }, + outputSchema: { success: z.boolean() }, + }, + async ({ id }) => { + if (!scheduleService) { + throw new Error("Schedule service is not configured"); + } + await requireCallerHeartbeat(id); + await scheduleService.delete(id); + return { + content: [], + structuredContent: ensureValidJson({ success: true }), + }; + }, + ); + registerTool( "list_schedules", { @@ -2093,9 +2553,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase throw new Error("Schedule service is not configured"); } - const schedules = (await scheduleService.list()).map((schedule) => - toScheduleSummary(schedule), - ); + const schedules = (await scheduleService.list()) + .filter((schedule) => schedule.target.type === "new-agent") + .map((schedule) => toScheduleSummary(schedule)); return { content: [], structuredContent: ensureValidJson({ schedules }), @@ -2118,7 +2578,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase throw new Error("Schedule service is not configured"); } - const schedule = await scheduleService.inspect(id); + const schedule = await requireScheduleTarget(id, "new-agent"); return { content: [], structuredContent: ensureValidJson(schedule), @@ -2143,6 +2603,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase throw new Error("Schedule service is not configured"); } + await requireScheduleTarget(id, "new-agent"); await scheduleService.pause(id); return { content: [], @@ -2168,6 +2629,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase throw new Error("Schedule service is not configured"); } + await requireScheduleTarget(id, "new-agent"); await scheduleService.resume(id); return { content: [], @@ -2193,6 +2655,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase throw new Error("Schedule service is not configured"); } + await requireScheduleTarget(id, "new-agent"); await scheduleService.delete(id); return { content: [], @@ -2207,54 +2670,55 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase title: "Update schedule", description: "Update an existing schedule. Only provided fields are changed; omitted fields remain unchanged.", - inputSchema: { - id: z.string(), - every: z.string().optional().describe("New interval duration string (e.g. 5m, 1h)."), - cron: z.string().optional().describe("New cron expression."), - timezone: z - .string() - .trim() - .min(1) - .optional() - .describe( - "IANA time zone for cron cadence; requires cron. For example: America/New_York.", - ), - name: z.string().nullable().optional().describe("New name (null to clear)."), - prompt: z.string().trim().min(1).optional().describe("New prompt text."), - maxRuns: z - .number() - .int() - .positive() - .nullable() - .optional() - .describe("New max runs limit (null to clear)."), - provider: z - .string() - .trim() - .min(1) - .optional() - .describe("New provider for new-agent target."), - model: z - .string() - .trim() - .min(1) - .nullable() - .optional() - .describe("New model for new-agent target (null to clear)."), - mode: z - .string() - .trim() - .min(1) - .nullable() - .optional() - .describe("New mode for new-agent target (null to clear)."), - cwd: z.string().trim().min(1).optional().describe("New cwd for new-agent target."), - expiresIn: z - .string() - .optional() - .describe("New relative expiry duration (for example: 1h, 2d)."), - clearExpires: z.boolean().optional().describe("Clear any schedule expiry."), - }, + inputSchema: z + .object({ + id: z.string(), + cron: z.string().optional().describe("New cron expression."), + timezone: z + .string() + .trim() + .min(1) + .optional() + .describe( + "IANA time zone for cron cadence; requires cron. For example: America/New_York.", + ), + name: z.string().nullable().optional().describe("New name (null to clear)."), + prompt: z.string().trim().min(1).optional().describe("New prompt text."), + maxRuns: z + .number() + .int() + .positive() + .nullable() + .optional() + .describe("New max runs limit (null to clear)."), + provider: z + .string() + .trim() + .min(1) + .optional() + .describe("New provider for new-agent target."), + model: z + .string() + .trim() + .min(1) + .nullable() + .optional() + .describe("New model for new-agent target (null to clear)."), + mode: z + .string() + .trim() + .min(1) + .nullable() + .optional() + .describe("New mode for new-agent target (null to clear)."), + cwd: z.string().trim().min(1).optional().describe("New cwd for new-agent target."), + expiresIn: z + .string() + .optional() + .describe("New relative expiry duration (for example: 1h, 2d)."), + clearExpires: z.boolean().optional().describe("Clear any schedule expiry."), + }) + .passthrough(), outputSchema: StoredScheduleSchema.shape, }, async (input) => { @@ -2262,6 +2726,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase throw new Error("Schedule service is not configured"); } + await requireScheduleTarget(input.id, "new-agent"); const schedule = await scheduleService.update(buildScheduleUpdateInput(input)); return { @@ -2288,6 +2753,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase throw new Error("Schedule service is not configured"); } + await requireScheduleTarget(id, "new-agent"); const runs = await scheduleService.logs(id); return { content: [], @@ -2296,6 +2762,27 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }, ); + registerTool( + "run_schedule_once", + { + title: "Run schedule once", + description: "Run a schedule immediately without changing its cron cadence.", + inputSchema: { id: z.string().min(1) }, + outputSchema: StoredScheduleSchema.shape, + }, + async ({ id }) => { + if (!scheduleService) { + throw new Error("Schedule service is not configured"); + } + await requireScheduleTarget(id, "new-agent"); + const schedule = await scheduleService.runOnce(id); + return { + content: [], + structuredContent: ensureValidJson(schedule), + }; + }, + ); + registerTool( "list_providers", { @@ -2407,146 +2894,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }, ); - registerTool( - "list_worktrees", - { - title: "List worktrees", - description: "List Paseo-managed git worktrees for a repository.", - inputSchema: { - cwd: z - .string() - .optional() - .describe("Optional repository cwd. Defaults to your current working directory."), - }, - outputSchema: { - worktrees: z.array(WorktreeSummarySchema), - }, - }, - async ({ cwd }) => { - const resolvedCwd = resolveScopedCwd(cwd, { required: true }); - if (!options.workspaceGitService) { - throw new Error("WorkspaceGitService is required to list worktrees"); - } - const worktrees = await listPaseoWorktreesCommand( - { workspaceGitService: options.workspaceGitService }, - { - cwd: resolvedCwd, - reason: "mcp:list-worktrees", - }, - ); - - return { - content: [], - structuredContent: ensureValidJson({ worktrees }), - }; - }, - ); - - registerTool( - "create_worktree", - { - title: "Create worktree", - description: - "Create a Paseo-managed git worktree. Branch off a new branch, check out an existing branch, or check out a GitHub PR.", - inputSchema: { - cwd: z.string().optional().describe("Repository directory. Defaults to the agent's cwd."), - target: AgentCreateWorktreeTargetInputSchema.describe("What the worktree should contain."), - }, - outputSchema: { - branchName: z.string(), - worktreePath: z.string(), - workspaceId: z.string(), - }, - }, - async ({ cwd, target }) => { - const repoRoot = resolveScopedCwd(cwd, { required: true }); - const commandResult = await createPaseoWorktreeCommand( - { - paseoHome: options.paseoHome, - worktreesRoot: options.worktreesRoot, - createPaseoWorktreeWorkflow: options.createPaseoWorktree, - }, - createMcpWorktreeCommandInput(repoRoot, target), - ); - if (!commandResult.ok) { - throw new WorktreeRequestError(commandResult.error); - } - const { worktree, workspace } = commandResult.createdWorktree; - await options.workspaceGitService?.listWorktrees?.(repoRoot, { - force: true, - reason: "mcp:create-worktree", - }); - - return { - content: [], - structuredContent: ensureValidJson({ - branchName: worktree.branchName, - worktreePath: worktree.worktreePath, - workspaceId: workspace.workspaceId, - }), - }; - }, - ); - - registerTool( - "archive_worktree", - { - title: "Archive worktree", - description: "Delete a Paseo-managed git worktree.", - inputSchema: { - cwd: z - .string() - .optional() - .describe("Optional repository cwd. Defaults to your current working directory."), - worktreePath: z.string().optional(), - worktreeSlug: z.string().optional(), - }, - outputSchema: { - success: z.boolean(), - }, - }, - async ({ cwd, worktreePath, worktreeSlug }) => { - const resolvedCwd = resolveScopedCwd(cwd, { required: true }); - if (!worktreePath && !worktreeSlug) { - throw new Error("worktreePath or worktreeSlug is required"); - } - if (!options.workspaceGitService) { - throw new Error("WorkspaceGitService is required to archive worktrees"); - } - const repoRoot = await options.workspaceGitService.resolveRepoRoot(resolvedCwd); - - const result = await archiveCommand( - archiveWorktreeDependencies(options, { - agentManager, - agentStorage, - terminalManager: terminalManager ?? null, - logger: childLogger, - }), - { - requestId: "mcp:archive_worktree", - repoRoot, - worktreePath, - worktreeSlug, - // This tool archives every workspace on the directory, then removes the - // directory. Disk removal is derived from scope + last-reference. - scope: "worktree", - }, - ); - if (!result.ok) { - throw new Error(result.message); - } - await options.workspaceGitService.listWorktrees(repoRoot, { - force: true, - reason: "mcp:archive-worktree", - }); - - return { - content: [], - structuredContent: ensureValidJson({ success: true }), - }; - }, - ); - registerTool( "get_agent_activity", { @@ -2694,11 +3041,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase return toCatalog(); } -type McpCreateWorktreeTarget = - | { kind: "branch-off"; worktreeSlug?: string; branchName?: string; baseBranch?: string } - | { kind: "checkout-branch"; branch: string } - | { kind: "checkout-pr"; githubPrNumber: number }; - interface ArchiveWorktreeCommandContext { agentManager: AgentManager; agentStorage: AgentStorage; @@ -2758,26 +3100,3 @@ function archiveWorktreeDependencies( sessionLogger: context.logger, }; } - -function createMcpWorktreeCommandInput( - repoRoot: string, - target: McpCreateWorktreeTarget, -): CreatePaseoWorktreeCommandInput { - const base = { cwd: repoRoot } as const; - switch (target.kind) { - case "branch-off": - return { - ...base, - worktreeSlug: target.worktreeSlug, - branchName: target.branchName, - action: "branch-off", - ...(target.baseBranch ? { refName: target.baseBranch } : {}), - }; - case "checkout-branch": - return { ...base, action: "checkout", refName: target.branch }; - case "checkout-pr": - return { ...base, action: "checkout", githubPrNumber: target.githubPrNumber }; - default: - throw new Error("unreachable"); - } -} 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 6a6f5d7db..9f83c1ce4 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 @@ -486,8 +486,6 @@ describe("archiveIfSafe", () => { }), { scope: { kind: "workspace", workspaceId: "ws-auto-archive" }, - repoRoot: "/tmp/repo", - paseoWorktreesBaseRoot: undefined, requestId: "auto-archive-on-merge", }, ); 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 bd6f8d720..126f615bc 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 @@ -138,8 +138,6 @@ export async function archiveIfSafe(input: { }, { scope: { kind: "workspace", workspaceId }, - repoRoot: ownership.repoRoot ?? null, - paseoWorktreesBaseRoot: options.paseoWorktreesBaseRoot, requestId: "auto-archive-on-merge", }, ); diff --git a/packages/server/src/server/bootstrap-provider-availability.test.ts b/packages/server/src/server/bootstrap-provider-availability.test.ts index 552441639..be431e0dc 100644 --- a/packages/server/src/server/bootstrap-provider-availability.test.ts +++ b/packages/server/src/server/bootstrap-provider-availability.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -18,7 +19,9 @@ describe("bootstrap provider availability", () => { process.env.PATH = originalEnv.PATH; process.env.PATHEXT = originalEnv.PATHEXT; await Promise.all( - tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + tempRoots + .splice(0) + .map((root) => rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })), ); }); @@ -26,12 +29,13 @@ describe("bootstrap provider availability", () => { const { createPaseoDaemon } = await import("./bootstrap.js"); const root = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-provider-")); tempRoots.push(root); - const binDir = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-provider-bin-")); - tempRoots.push(binDir); - process.env.PATH = binDir; - if (process.platform === "win32") { - process.env.PATHEXT = ".CMD"; - } + const gitPath = execFileSync(process.platform === "win32" ? "where" : "which", ["git"], { + encoding: "utf8", + }) + .split(/\r?\n/)[0] + .trim(); + process.env.PATH = path.dirname(gitPath); + expect(execFileSync("git", ["--version"], { encoding: "utf8" })).toMatch(/git version/i); const paseoHome = path.join(root, ".paseo"); const staticDir = path.join(root, "static"); const agentStoragePath = path.join(paseoHome, "agents"); diff --git a/packages/server/src/server/bootstrap.test.ts b/packages/server/src/server/bootstrap.test.ts new file mode 100644 index 000000000..6f705ec55 --- /dev/null +++ b/packages/server/src/server/bootstrap.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from "vitest"; + +import { fanOutReconciledWorkspaceUpdates } from "./bootstrap.js"; + +test("reconciliation emits workspace updates when observer sync fails", async () => { + const emittedWorkspaceIds: string[][] = []; + const syncFailure = new Error("workspace observer unavailable"); + + await fanOutReconciledWorkspaceUpdates({ + sessions: [ + { + syncWorkspaceGitObserversForExternalWorkspaceIds: async () => { + throw syncFailure; + }, + emitWorkspaceUpdatesForExternalWorkspaceIds: async (workspaceIds) => { + emittedWorkspaceIds.push(Array.from(workspaceIds)); + }, + }, + ], + workspaceIds: ["ws-reclassified"], + logger: { warn: () => {} }, + }); + + expect(emittedWorkspaceIds).toEqual([["ws-reclassified"]]); +}); + +test("reconciliation isolates workspace update failures between sessions", async () => { + const emittedWorkspaceIds: string[][] = []; + const warnings: unknown[] = []; + + await fanOutReconciledWorkspaceUpdates({ + sessions: [ + { + syncWorkspaceGitObserversForExternalWorkspaceIds: async () => {}, + emitWorkspaceUpdatesForExternalWorkspaceIds: async () => { + throw new Error("session closed"); + }, + }, + { + syncWorkspaceGitObserversForExternalWorkspaceIds: async () => {}, + emitWorkspaceUpdatesForExternalWorkspaceIds: async (workspaceIds) => { + emittedWorkspaceIds.push(Array.from(workspaceIds)); + }, + }, + ], + workspaceIds: ["ws-reclassified"], + logger: { + warn: (context) => { + warnings.push(context); + }, + }, + }); + + expect(emittedWorkspaceIds).toEqual([["ws-reclassified"]]); + expect(warnings).toHaveLength(1); +}); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index a1ad98dbd..4d3d48929 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -89,12 +89,42 @@ function formatListenTarget(listenTarget: ListenTarget | null): string | null { return listenTarget.path; } +export async function fanOutReconciledWorkspaceUpdates(input: { + sessions: Iterable<{ + syncWorkspaceGitObserversForExternalWorkspaceIds(workspaceIds: Iterable): Promise; + emitWorkspaceUpdatesForExternalWorkspaceIds( + workspaceIds: Iterable, + options: { skipReconcile: boolean }, + ): Promise; + }>; + workspaceIds: readonly string[]; + logger: Pick; +}): Promise { + await Promise.all( + Array.from(input.sessions, async (session) => { + try { + await session.syncWorkspaceGitObserversForExternalWorkspaceIds(input.workspaceIds); + } catch (error) { + input.logger.warn( + { err: error }, + "Failed to sync workspace Git observers after reconciliation", + ); + } + try { + await session.emitWorkspaceUpdatesForExternalWorkspaceIds(input.workspaceIds, { + skipReconcile: true, + }); + } catch (error) { + input.logger.warn({ err: error }, "Failed to emit workspace updates after reconciliation"); + } + }), + ); +} + import { VoiceAssistantWebSocketServer } from "./websocket-server.js"; import { createGitHubService } from "../services/github-service.js"; -import { - createPaseoWorktree as createRegisteredPaseoWorktree, - createLocalCheckoutWorkspace, -} from "./paseo-worktree-service.js"; +import { createPaseoWorktree as createRegisteredPaseoWorktree } from "./paseo-worktree-service.js"; +import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js"; import { createPaseoWorktreeWorkflow } from "./worktree-session.js"; import { DownloadTokenStore } from "./file-download/token-store.js"; import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js"; @@ -172,9 +202,23 @@ import { createAgentCommand, type CreateAgentCommandDependencies, } from "./agent/create-agent/create.js"; +import { archiveAgentCommand } from "./agent/lifecycle-command.js"; +import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js"; +import { + HubRelationshipController, + type HubRelationshipClock, + type HubRelationshipRetryPolicy, +} from "./hub/relationship-controller.js"; +import { + DirectHubRelationshipRemote, + type HubRelationshipRemote, +} from "./hub/relationship-remote.js"; +import { DaemonExecutions } from "./hub/daemon-executions.js"; const MAX_MCP_DEBUG_BATCH_ITEMS = 10; const REDACTED_LOG_VALUE = "[redacted]"; +const IDLE_AGENT_RUNTIME_TTL_MS = 2 * 60 * 1000; +const IDLE_AGENT_RUNTIME_SWEEP_INTERVAL_MS = 15 * 1000; const DOWNLOAD_OPEN_FLAGS = process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW; @@ -404,6 +448,13 @@ export interface PaseoDaemon { getListenTarget(): ListenTarget | null; } +export interface PaseoDaemonDependencies { + hubRelationshipRemote?: HubRelationshipRemote; + hubRelationshipClock?: HubRelationshipClock; + hubRelationshipRetryPolicy?: HubRelationshipRetryPolicy; + createHubDaemonId?: () => string; +} + function createBootstrapManagedProcessRegistry( config: Pick, logger: Logger, @@ -481,6 +532,7 @@ function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDae export async function createPaseoDaemon( config: PaseoDaemonConfig, rootLogger: Logger, + dependencies: PaseoDaemonDependencies = {}, ): Promise { const logger = rootLogger.child({ module: "bootstrap" }); const bootstrapStart = performance.now(); @@ -547,7 +599,7 @@ export async function createPaseoDaemon( serviceProxy, onChange: createScriptStatusEmitter({ sessions: () => - wsServer?.listActiveSessions().map((session) => ({ + wsServer?.listTrustedSessions().map((session) => ({ emit: (message) => session.emitServerMessage(message), })) ?? [], serviceProxy, @@ -744,6 +796,12 @@ export async function createPaseoDaemon( forgeOverrides: { github }, }, }); + const workspaceProvisioning = createWorkspaceProvisioningService({ + projectRegistry, + workspaceRegistry, + workspaceGitService, + logger, + }); const providerSnapshotLogger = logger.child({ module: "provider-snapshot-manager" }); const providerSnapshotManager = new ProviderSnapshotManager({ logger: providerSnapshotLogger, @@ -788,21 +846,19 @@ export async function createPaseoDaemon( workspaceRegistry, logger, workspaceGitService, + onProjectUpdate: (update) => wsServer?.publishProjectUpdate(update), + onWorkspacesChanged: async (workspaceIds) => { + await fanOutReconciledWorkspaceUpdates({ + sessions: wsServer?.listTrustedSessions() ?? [], + workspaceIds, + logger, + }); + }, + }); + await workspaceReconciliation.start(); + void workspaceReconciliation.runOnce().catch((error) => { + logger.warn({ err: error }, "Initial workspace reconciliation failed"); }); - void (async () => { - try { - const result = await workspaceReconciliation.runOnce(); - logger.info( - { - elapsed: elapsed(), - changeCount: result.changesApplied.length, - }, - "Workspace registries reconciled", - ); - } catch (error) { - logger.error({ err: error }, "Background workspace reconciliation failed"); - } - })(); await chatService.initialize(); logger.info({ elapsed: elapsed() }, "Chat service initialized"); const checkoutDiffManager = new CheckoutDiffManager({ @@ -811,7 +867,7 @@ export async function createPaseoDaemon( workspaceGitService, }); const archiveWorkspaceRecordExternal = async (workspaceId: string) => { - const sessions = wsServer?.listActiveSessions() ?? []; + const sessions = wsServer?.listTrustedSessions() ?? []; if (sessions.length > 0) { await Promise.all( sessions.map((session) => session.archiveWorkspaceRecordForExternalMutation(workspaceId)), @@ -833,9 +889,9 @@ export async function createPaseoDaemon( cwd: string, firstAgentContext?: FirstAgentContext, ): Promise => { - const workspace = await createLocalCheckoutWorkspace( - { cwd, title: resolveFirstAgentPromptTitle(firstAgentContext) }, - { projectRegistry, workspaceRegistry, workspaceGitService }, + const workspace = await workspaceProvisioning.createWorkspaceForDirectory( + cwd, + resolveFirstAgentPromptTitle(firstAgentContext), ); if (firstAgentContext) { workspaceAutoName.scheduleForDirectory({ @@ -854,24 +910,27 @@ export async function createPaseoDaemon( workspaceId: workspace.workspaceId, cwd: workspace.cwd, kind: workspace.kind, + worktreeRoot: workspace.worktreeRoot, + isPaseoOwnedWorktree: workspace.isPaseoOwnedWorktree, + mainRepoRoot: workspace.mainRepoRoot, })); }; const markWorkspaceArchivingExternal = (workspaceIds: Iterable, archivingAt: string) => { const workspaceIdList = Array.from(workspaceIds); - for (const session of wsServer?.listActiveSessions() ?? []) { + for (const session of wsServer?.listTrustedSessions() ?? []) { session.markWorkspaceArchivingForExternalMutation(workspaceIdList, archivingAt); } }; const clearWorkspaceArchivingExternal = (workspaceIds: Iterable) => { const workspaceIdList = Array.from(workspaceIds); - for (const session of wsServer?.listActiveSessions() ?? []) { + for (const session of wsServer?.listTrustedSessions() ?? []) { session.clearWorkspaceArchivingForExternalMutation(workspaceIdList); } }; const emitWorkspaceUpdatesExternal = async (workspaceIds: Iterable) => { const workspaceIdList = Array.from(workspaceIds); await Promise.all( - (wsServer?.listActiveSessions() ?? []).map((session) => + (wsServer?.listTrustedSessions() ?? []).map((session) => session.emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIdList), ), ); @@ -942,15 +1001,14 @@ export async function createPaseoDaemon( resolveDefaultBranch: workflowOptions.resolveDefaultBranch, } : {}), - projectRegistry, - workspaceRegistry, workspaceGitService, + workspaceProvisioning, }); }, warmWorkspaceGitData: async (workspace) => { await Promise.all( wsServer - ?.listActiveSessions() + ?.listTrustedSessions() .map((session) => session.warmWorkspaceGitDataForWorkspace(workspace)) ?? [], ); }, @@ -989,6 +1047,57 @@ export async function createPaseoDaemon( }; const createAgent = (input: Parameters[1]) => createAgentCommand(createAgentCommandDependencies, input); + const hubAgentLifecycle = new CreateAgentLifecycleDispatch({ + paseoHome: config.paseoHome, + worktreesRoot: config.worktreesRoot, + agentManager, + agentStorage, + github, + workspaceGitService, + createPaseoWorktreeWorkflow: createPaseoWorktreeForTools, + archiveAgentForClose: (agentId) => + archiveAgentCommand({ agentManager, agentStorage, logger }, agentId), + findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal, + listActiveWorkspaces: listActiveWorkspacesExternal, + archiveWorkspaceRecord: archiveWorkspaceRecordExternal, + emit: emitExternalSessionMessage, + emitAgentRemove: () => undefined, + emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal, + markWorkspaceArchiving: markWorkspaceArchivingExternal, + clearWorkspaceArchiving: clearWorkspaceArchivingExternal, + killTerminalsForWorkspace: (workspaceId) => + killTerminalsForWorkspace({ terminalManager, sessionLogger: logger }, workspaceId), + logger, + }); + const hubRelationships = new HubRelationshipController({ + paseoHome: config.paseoHome, + serverId, + daemonPublicKey: daemonKeyPair.publicKeyB64, + logger, + remote: dependencies.hubRelationshipRemote ?? new DirectHubRelationshipRemote(), + clock: dependencies.hubRelationshipClock, + retryPolicy: dependencies.hubRelationshipRetryPolicy, + createDaemonId: dependencies.createHubDaemonId, + attachSocket: async (socket, options) => { + if (!wsServer) throw new Error("WebSocket server is not running"); + await wsServer.attachHubSocket(socket, options); + }, + createExecutionAgents: (daemonId) => + new DaemonExecutions({ + daemonId, + agentManager, + agentStorage, + createAgent, + registerAutoArchive: ({ agentId, createdWorktree }) => + hubAgentLifecycle.registerAutoArchiveIfRequested({ + autoArchive: true, + agentId, + createdWorktree, + }), + cleanupFailedCreate: (input) => + hubAgentLifecycle.cleanupCreatedWorktreeAfterFailedAgentCreate(input), + }), + }); const loopService = new LoopService({ paseoHome: config.paseoHome, @@ -1003,9 +1112,9 @@ export async function createPaseoDaemon( cwd: string; firstAgentContext: FirstAgentContext; }) => { - const workspace = await createLocalCheckoutWorkspace( - { cwd: input.cwd, title: resolveFirstAgentPromptTitle(input.firstAgentContext) }, - { projectRegistry, workspaceRegistry, workspaceGitService }, + const workspace = await workspaceProvisioning.createWorkspaceForDirectory( + input.cwd, + resolveFirstAgentPromptTitle(input.firstAgentContext), ); workspaceAutoName.scheduleForDirectory({ workspaceId: workspace.workspaceId, @@ -1026,7 +1135,7 @@ export async function createPaseoDaemon( await emitWorkspaceUpdatesExternal([result.workspace.workspaceId]); return result; }; - const archiveScheduleWorkspaceExternal = async (workspaceId: string, repoRoot: string) => { + const archiveScheduleWorkspaceExternal = async (workspaceId: string) => { await archiveByScope( { paseoHome: config.paseoHome, @@ -1053,8 +1162,6 @@ export async function createPaseoDaemon( }, { scope: { kind: "workspace", workspaceId }, - repoRoot, - paseoWorktreesBaseRoot: config.worktreesRoot, requestId: "schedule-run-finish", }, ); @@ -1065,11 +1172,44 @@ export async function createPaseoDaemon( agentManager, agentStorage, createAgent, - createLocalCheckoutWorkspace: createScheduleLocalWorkspaceExternal, + createDirectoryWorkspace: createScheduleLocalWorkspaceExternal, createPaseoWorktreeWorkspace: createSchedulePaseoWorktreeExternal, archiveWorkspace: archiveScheduleWorkspaceExternal, }); await scheduleService.start(); + let inFlightIdleAgentCollection: Promise | null = null; + const collectIdleAgentRuntimes = async () => { + const protectedAgentIds = await scheduleService.listActiveAgentTargetIds(); + const cutoff = new Date(Date.now() - IDLE_AGENT_RUNTIME_TTL_MS); + const result = await agentManager.collectIdleAgents({ cutoff, protectedAgentIds }); + for (const collected of result.collected) { + logger.info(collected, "Collected idle agent runtime"); + } + for (const failure of result.failures) { + const { error, ...context } = failure; + logger.warn({ ...context, err: error }, "Failed to collect idle agent runtime"); + } + }; + const runIdleAgentCollection = () => { + if (inFlightIdleAgentCollection) { + return; + } + const collection = collectIdleAgentRuntimes() + .catch((error) => { + logger.warn({ err: error }, "Idle agent runtime sweep failed"); + }) + .finally(() => { + if (inFlightIdleAgentCollection === collection) { + inFlightIdleAgentCollection = null; + } + }); + inFlightIdleAgentCollection = collection; + }; + const idleAgentCollectionTimer = setInterval( + runIdleAgentCollection, + IDLE_AGENT_RUNTIME_SWEEP_INTERVAL_MS, + ); + idleAgentCollectionTimer.unref(); agentManager.setAgentArchivedCallback(async (agentId) => { try { await scheduleService.completeForAgent(agentId); @@ -1105,6 +1245,16 @@ export async function createPaseoDaemon( archiveWorkspaceRecord: archiveWorkspaceRecordExternal, emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal, workspaceRegistry, + projectRegistry, + createDirectoryWorkspace: async (cwd, title, projectId) => { + const workspace = await workspaceProvisioning.createWorkspaceForDirectory( + cwd, + title, + projectId, + ); + await emitWorkspaceUpdatesExternal([workspace.workspaceId]); + return workspace; + }, markWorkspaceArchiving: markWorkspaceArchivingExternal, clearWorkspaceArchiving: clearWorkspaceArchivingExternal, ensureWorkspaceForCreate: createAgentCommandDependencies.ensureWorkspaceForCreate, @@ -1383,7 +1533,9 @@ export async function createPaseoDaemon( }, serviceProxyPublicBaseUrl, browserToolsBroker, + hubRelationships, ); + await hubRelationships.start(); if (relayEnabled) { const offer = await createConnectionOfferV2({ @@ -1444,7 +1596,11 @@ export async function createPaseoDaemon( }; const stop = async () => { + await hubRelationships.stop(); + workspaceReconciliation.dispose(); scriptHealthMonitor.stop(); + clearInterval(idleAgentCollectionTimer); + await inFlightIdleAgentCollection; // Freeze both ingress and registration before taking the agent closure snapshot. wsServer?.prepareForShutdown(); agentManager.prepareForShutdown(); diff --git a/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts b/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts index b99295e46..e0524c894 100644 --- a/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts +++ b/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { DaemonClient } from "./test-utils/index.js"; import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js"; +import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; // The daemon-level workspace contract that `paseo run` depends on: each // local-backed createWorkspace for a cwd mints a fresh, distinct workspace, @@ -28,9 +29,10 @@ async function mintLocalWorkspace(client: DaemonClient, cwd: string): Promise { +test("daemon resolves human and managed CLI workspace ownership", async () => { const daemon = await createTestPaseoDaemon(); const cwd = mkdtempSync(path.join(tmpdir(), "paseo-cli-run-cwd-")); + const otherCwd = mkdtempSync(path.join(tmpdir(), "paseo-cli-run-other-cwd-")); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.1.82", @@ -56,14 +58,13 @@ test("daemon mints a distinct local workspace per run and stamps agents by id", const fetchedFirst = await client.fetchAgent({ agentId: firstAgent.id }); expect(fetchedFirst?.agent.workspaceId).toBe(firstWorkspaceId); - // A second bare run in the SAME cwd mints a DISTINCT workspace; each run - // owns its own workspace rather than reattaching to the first. - const secondWorkspaceId = await mintLocalWorkspace(client, cwd); + // A second bare run mints a distinct workspace with its own authoritative cwd. + const secondWorkspaceId = await mintLocalWorkspace(client, otherCwd); expect(secondWorkspaceId).not.toBe(firstWorkspaceId); const secondAgent = await client.createAgent({ ...getFullAccessConfig("codex"), - cwd, + cwd: otherCwd, workspaceId: secondWorkspaceId, title: "Second run agent", }); @@ -80,15 +81,48 @@ test("daemon mints a distinct local workspace per run and stamps agents by id", const idsBeforeAttach = await workspaceIds(client); const attachedAgent = await client.createAgent({ ...getFullAccessConfig("codex"), - cwd, + cwd: path.join(otherCwd, "stale-client-directory"), workspaceId: firstWorkspaceId, title: "Attached agent", }); expect(attachedAgent.workspaceId).toBe(firstWorkspaceId); + expect(attachedAgent.cwd).toBe(cwd); expect(await workspaceIds(client)).toEqual(idsBeforeAttach); + + await expect( + client.createAgent({ + ...getFullAccessConfig("codex"), + cwd, + workspaceId: "wks_missing", + title: "Missing workspace agent", + }), + ).rejects.toThrow("Workspace wks_missing not found"); + + const sameWorkspaceChild = await client.createAgent({ + ...getFullAccessConfig("codex"), + cwd: otherCwd, + callerAgentId: firstAgent.id, + title: "Same workspace child", + }); + expect(sameWorkspaceChild.workspaceId).toBe(firstWorkspaceId); + expect(sameWorkspaceChild.cwd).toBe(firstAgent.cwd); + expect(sameWorkspaceChild.labels[PARENT_AGENT_ID_LABEL]).toBe(firstAgent.id); + expect(await workspaceIds(client)).toEqual(idsBeforeAttach); + + const crossWorkspaceChild = await client.createAgent({ + ...getFullAccessConfig("codex"), + cwd, + workspaceId: secondWorkspaceId, + callerAgentId: firstAgent.id, + title: "Cross workspace child", + }); + expect(crossWorkspaceChild.workspaceId).toBe(secondWorkspaceId); + expect(crossWorkspaceChild.cwd).toBe(otherCwd); + expect(crossWorkspaceChild.labels[PARENT_AGENT_ID_LABEL]).toBe(firstAgent.id); } finally { await client.close().catch(() => undefined); await daemon.close(); rmSync(cwd, { recursive: true, force: true }); + rmSync(otherCwd, { recursive: true, force: true }); } }, 180000); diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index 0065d9e5e..44c4b51be 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -1006,6 +1006,7 @@ test("receives server_info on websocket connect", async () => { expect(serverInfo).not.toBeNull(); expect(serverInfo?.serverId.length).toBeGreaterThan(0); expect(serverInfo?.features?.["terminal-restore-modes"]).toBe(true); + expect(serverInfo?.features?.hubRelationship).toBe(true); expect(serverInfo?.features?.commitsList).toBe(true); expect(serverInfo?.desktopManaged).toBe(false); expect(serverInfo?.features?.daemonSelfUpdate).toBe(true); diff --git a/packages/server/src/server/daemon-e2e/empty-project-persists.e2e.test.ts b/packages/server/src/server/daemon-e2e/empty-project-persists.e2e.test.ts index 87f092cd6..1c5fed39e 100644 --- a/packages/server/src/server/daemon-e2e/empty-project-persists.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/empty-project-persists.e2e.test.ts @@ -62,10 +62,10 @@ test("project.add creates a project without creating a workspace", async () => { expect(added.project).not.toBeNull(); const project = added.project!; expect(project).toMatchObject({ - projectId: repoRoot, projectRootPath: repoRoot, projectKind: "git", }); + expect(project.projectId).toMatch(/^prj_[0-9a-f]{16}$/); const workspaces = await client.fetchWorkspaces({ filter: { projectId: project.projectId }, @@ -73,7 +73,6 @@ test("project.add creates a project without creating a workspace", async () => { expect(workspaces.entries).toEqual([]); expect(workspaces.emptyProjects).toEqual([ expect.objectContaining({ - projectId: repoRoot, projectRootPath: repoRoot, projectKind: "git", }), diff --git a/packages/server/src/server/daemon-e2e/open-project-worktree-reclassification.e2e.test.ts b/packages/server/src/server/daemon-e2e/open-project-worktree-reclassification.e2e.test.ts index 503707e71..ab49ecf9d 100644 --- a/packages/server/src/server/daemon-e2e/open-project-worktree-reclassification.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/open-project-worktree-reclassification.e2e.test.ts @@ -29,7 +29,7 @@ afterEach(async () => { cleanupPaths.clear(); }); -test("openProject reclassifies an existing directory workspace into its parent git project", async () => { +test("openProject preserves a worktree's exact-root project without rehoming it", async () => { const previousSupervised = process.env.PASEO_SUPERVISED; process.env.PASEO_SUPERVISED = "0"; try { @@ -113,17 +113,13 @@ test("openProject reclassifies an existing directory workspace into its parent g const persistedWorkspaces = await readRegistry(workspacesPath); expect(response.error).toBeNull(); - expect(response.workspace?.projectId).toBe(repoRoot); - expect(response.workspace?.workspaceKind).toBe("worktree"); + expect(response.workspace?.projectId).toBe(worktreeRoot); expect(persistedProjects.find((project) => project.projectId === repoRoot)?.rootPath).toBe( repoRoot, ); expect( persistedWorkspaces.find((workspace) => workspace.workspaceId === worktreeRoot)?.projectId, - ).toBe(repoRoot); - expect( - persistedWorkspaces.find((workspace) => workspace.workspaceId === worktreeRoot)?.kind, - ).toBe("worktree"); + ).toBe(worktreeRoot); } finally { process.env.PASEO_SUPERVISED = previousSupervised; } diff --git a/packages/server/src/server/daemon-e2e/project-becomes-git.e2e.test.ts b/packages/server/src/server/daemon-e2e/project-becomes-git.e2e.test.ts new file mode 100644 index 000000000..e7843f78a --- /dev/null +++ b/packages/server/src/server/daemon-e2e/project-becomes-git.e2e.test.ts @@ -0,0 +1,123 @@ +import { execFile as execFileCallback } from "node:child_process"; +import { mkdtempSync, realpathSync } from "node:fs"; +import { readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { afterEach, expect, test } from "vitest"; + +import { withTimeout } from "../../utils/promise-timeout.js"; +import { DaemonClient, type DaemonEvent } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { type PersistedProjectRecord } from "../workspace-registry.js"; + +const cleanupPaths = new Set(); +const cleanupDaemons = new Set(); +const cleanupClients = new Set(); +const cleanupListeners = new Set<() => void>(); +const execFile = promisify(execFileCallback); + +type ProjectUpdatePayload = Extract["payload"]; + +function waitForProjectUpdate( + client: DaemonClient, + predicate: (payload: ProjectUpdatePayload) => boolean, +): Promise { + return new Promise((resolve) => { + const unsubscribe = client.on("project.update", (message) => { + if (!predicate(message.payload)) return; + cleanupListeners.delete(unsubscribe); + unsubscribe(); + resolve(message.payload); + }); + cleanupListeners.add(unsubscribe); + }); +} + +afterEach(async () => { + for (const unsubscribe of cleanupListeners) unsubscribe(); + cleanupListeners.clear(); + await Promise.all(Array.from(cleanupClients, (client) => client.close().catch(() => undefined))); + cleanupClients.clear(); + await Promise.all(Array.from(cleanupDaemons, (daemon) => daemon.close().catch(() => undefined))); + cleanupDaemons.clear(); + await Promise.all( + Array.from(cleanupPaths, (target) => rm(target, { recursive: true, force: true })), + ); + cleanupPaths.clear(); +}); + +test("an empty project becomes Git without changing its identity or creating a workspace", async () => { + const projectRoot = realpathSync( + mkdtempSync(path.join(os.tmpdir(), "paseo-project-becomes-git-")), + ); + const paseoHomeRoot = realpathSync( + mkdtempSync(path.join(os.tmpdir(), "paseo-project-becomes-git-home-")), + ); + cleanupPaths.add(projectRoot); + cleanupPaths.add(paseoHomeRoot); + + const daemon = await createTestPaseoDaemon({ paseoHomeRoot, cleanup: false }); + cleanupDaemons.add(daemon); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + cleanupClients.add(client); + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "project-becomes-git" } }); + + const added = await client.addProject(projectRoot); + + expect(added).toEqual({ + requestId: expect.any(String), + project: { + projectId: expect.stringMatching(/^prj_[0-9a-f]{16}$/), + projectDisplayName: path.basename(projectRoot), + projectCustomName: null, + projectRootPath: projectRoot, + projectKind: "non_git", + }, + error: null, + }); + const project = added.project!; + const beforeGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } }); + expect(beforeGitInit).toMatchObject({ entries: [], emptyProjects: [project] }); + + const gitProjectUpdate = waitForProjectUpdate( + client, + (payload) => + payload.kind === "upsert" && + payload.project.projectId === project.projectId && + payload.project.projectRootPath === projectRoot && + payload.project.projectKind === "git", + ); + await execFile("git", ["init", "-b", "main"], { cwd: projectRoot }); + const update = await withTimeout({ + promise: gitProjectUpdate, + timeoutMs: 10_000, + label: "project.update after git init", + }); + + expect(update).toEqual({ + kind: "upsert", + project: { ...project, projectKind: "git" }, + }); + + const afterGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } }); + expect(afterGitInit).toMatchObject({ + entries: [], + emptyProjects: [{ ...project, projectKind: "git" }], + }); + + const persistedProjects = JSON.parse( + await readFile(path.join(daemon.paseoHome, "projects", "projects.json"), "utf8"), + ) as PersistedProjectRecord[]; + expect(persistedProjects).toContainEqual({ + projectId: project.projectId, + rootPath: projectRoot, + kind: "git", + displayName: project.projectDisplayName, + customName: null, + createdAt: expect.any(String), + updatedAt: expect.any(String), + archivedAt: null, + }); +}, 30_000); diff --git a/packages/server/src/server/hub/daemon-executions.test.ts b/packages/server/src/server/hub/daemon-executions.test.ts new file mode 100644 index 000000000..363f341d0 --- /dev/null +++ b/packages/server/src/server/hub/daemon-executions.test.ts @@ -0,0 +1,152 @@ +import { afterEach, expect, test } from "vitest"; +import { HubRelationshipHarness } from "./test-utils/relationship-harness.js"; + +let relationship: HubRelationshipHarness | null = null; + +afterEach(async () => { + await relationship?.close(); + relationship = null; +}); + +async function launchRelationship(): Promise { + const launched = await HubRelationshipHarness.start(); + await launched.beginConnect().result; + launched.connectLatestSocket(); + relationship = launched; + return launched; +} + +test("sequential replay after reconstruction keeps one durable owned agent", async () => { + const hub = await launchRelationship(); + const created = await hub.createOwnedConcurrently(); + + const reconstructed = await hub.reconstructAndReplay(); + + expect(reconstructed.replay.agent.id).toBe(created.first.agentId); + expect(reconstructed.replay.agent.status).toBe("closed"); + expect(reconstructed.durableAgentCount).toBe(1); +}); + +test("removing a daemon-owned agent removes its execution association", async () => { + const hub = await launchRelationship(); + const created = await hub.createOwnedConcurrently(); + + const removed = await hub.removeOwnedAgent(created.first.agentId); + + expect(removed.durableAgentCount).toBe(0); +}); + +test("a failed Hub create removes its auto-created worktree", async () => { + const hub = await launchRelationship(); + hub.beginOwnedCreate("failed-worktree-create", "failed-worktree-execution", { + modeId: "missing-mode", + worktree: { mode: "branch-off", newBranch: "failed-hub-create" }, + }); + + const response = await hub.ownedCreateResult("failed-worktree-create"); + + expect(response).toMatchObject({ + type: "hub.execution.agent.create.response", + payload: { success: false, executionId: "failed-worktree-execution" }, + }); + expect(await hub.listedWorktrees()).toHaveLength(1); + expect(await hub.durableOwnedAgentIds()).toEqual([]); +}); + +test("failed Hub auto-archive creates release their lifecycle subscriptions", async () => { + const hub = await launchRelationship(); + const subscriptionBaseline = hub.agentSubscriptionCount(); + + hub.failProviderPromptStart(); + hub.beginOwnedCreate("failed-prompt-create-1", "failed-prompt-execution-1", { + autoArchive: true, + worktree: { mode: "branch-off", newBranch: "failed-prompt-1" }, + }); + const first = await hub.ownedCreateResult("failed-prompt-create-1"); + + expect(first).toMatchObject({ + type: "hub.execution.agent.create.response", + payload: { success: false, executionId: "failed-prompt-execution-1" }, + }); + expect(hub.activeOwnedAgentIds()).toEqual([]); + expect(await hub.durableOwnedAgentIds()).toEqual([]); + expect(await hub.listedWorktrees()).toHaveLength(1); + expect(hub.agentSubscriptionCount()).toBe(subscriptionBaseline); + + hub.failProviderPromptStart(); + hub.beginOwnedCreate("failed-prompt-create-2", "failed-prompt-execution-2", { + autoArchive: true, + worktree: { mode: "branch-off", newBranch: "failed-prompt-2" }, + }); + const second = await hub.ownedCreateResult("failed-prompt-create-2"); + + expect(second).toMatchObject({ + type: "hub.execution.agent.create.response", + payload: { success: false, executionId: "failed-prompt-execution-2" }, + }); + expect(hub.activeOwnedAgentIds()).toEqual([]); + expect(await hub.durableOwnedAgentIds()).toEqual([]); + expect(await hub.listedWorktrees()).toHaveLength(1); + expect(hub.agentSubscriptionCount()).toBe(subscriptionBaseline); +}); + +test("failed Hub create cleans durable state when provider close rejects", async () => { + const hub = await launchRelationship(); + hub.failProviderPromptStart(); + hub.failNextProviderSessionClose(); + hub.beginOwnedCreate("failed-close-create", "failed-close-execution", { + worktree: { mode: "branch-off", newBranch: "failed-close-worktree" }, + }); + + const response = await hub.ownedCreateResult("failed-close-create"); + + expect(response).toMatchObject({ + type: "hub.execution.agent.create.response", + payload: { success: false, executionId: "failed-close-execution" }, + }); + expect(hub.activeOwnedAgentIds()).toEqual([]); + expect(await hub.durableOwnedAgentIds()).toEqual([]); + expect(await hub.listedWorktrees()).toHaveLength(1); +}); + +test("Hub checkout uses the requested branch ref", async () => { + const hub = await launchRelationship(); + await hub.createBranch("existing-hub-branch"); + hub.beginOwnedCreate("checkout-create", "checkout-execution", { + worktree: { mode: "checkout-branch", branch: "existing-hub-branch" }, + }); + + const response = await hub.ownedCreateResult("checkout-create"); + + expect(response).toMatchObject({ + type: "hub.execution.agent.create.response", + payload: { success: true, executionId: "checkout-execution" }, + }); + expect(await hub.currentBranch(hub.latestCreatedCwd()!)).toBe("existing-hub-branch"); +}); + +test("failed create never archives a reused worktree", async () => { + const hub = await launchRelationship(); + hub.beginOwnedCreate("original-create", "original-execution", { + worktree: { mode: "branch-off", newBranch: "shared-hub-worktree" }, + }); + const original = await hub.ownedCreateResult("original-create"); + const worktreeCwd = original.payload.agent?.cwd; + expect(worktreeCwd).toEqual(expect.any(String)); + await hub.ownedTurnCompletion(original.payload.agentId!); + + const failedPrompt = "Fail the reused worktree create"; + hub.failProviderPromptStart(failedPrompt); + hub.beginOwnedCreate("reused-create", "reused-execution", { + autoArchive: true, + prompt: failedPrompt, + worktree: { mode: "branch-off", newBranch: "shared-hub-worktree" }, + }); + const reused = await hub.ownedCreateResult("reused-create"); + + expect(reused).toMatchObject({ + type: "hub.execution.agent.create.response", + payload: { success: false, executionId: "reused-execution" }, + }); + expect(await hub.worktreeState(worktreeCwd!)).toEqual({ exists: true, listed: true }); +}); diff --git a/packages/server/src/server/hub/daemon-executions.ts b/packages/server/src/server/hub/daemon-executions.ts new file mode 100644 index 000000000..712a7ecc2 --- /dev/null +++ b/packages/server/src/server/hub/daemon-executions.ts @@ -0,0 +1,301 @@ +import type { + AgentSnapshotPayload, + AgentStreamEventPayload, + CreateAgentWorktreeTarget, +} from "@getpaseo/protocol/messages"; + +import type { AgentManager, AgentManagerEvent, ManagedAgent } from "../agent/agent-manager.js"; +import type { AgentStorage, StoredAgentRecord } from "../agent/agent-storage.js"; +import type { LifecycleRegistration } from "../agent/create-agent-lifecycle-dispatch.js"; +import type { BoundCreateAgentCommand } from "../agent/create-agent/create.js"; +import type { CreatePaseoWorktreeWorkflowResult } from "../worktree-session.js"; +import { buildStoredAgentPayload } from "../agent/agent-projections.js"; +import { serializeAgentSnapshot, serializeAgentStreamEvent } from "../messages.js"; +import { daemonExecutionKey, type DaemonAgentOwner } from "../agent/agent-owner.js"; + +export interface HubExecutionAgentCreateInput { + executionId: string; + provider: string; + cwd: string; + workspaceId?: string; + prompt: string; + model?: string; + modeId?: string; + thinkingOptionId?: string; + featureValues?: Record; + env?: Record; + worktree?: CreateAgentWorktreeTarget; + autoArchive?: boolean; +} + +export interface OwnedAgentSnapshot { + executionId: string; + agent: AgentSnapshotPayload; +} + +export type OwnedAgentEvent = + | { type: "update"; executionId: string; agent: AgentSnapshotPayload } + | { + type: "stream"; + executionId: string; + agentId: string; + event: AgentStreamEventPayload; + }; + +interface DaemonExecutionsOptions { + daemonId: string; + agentManager: AgentManager; + agentStorage: AgentStorage; + createAgent: BoundCreateAgentCommand; + registerAutoArchive?: (input: { + agentId: string; + createdWorktree: CreatePaseoWorktreeWorkflowResult | null; + }) => LifecycleRegistration; + cleanupFailedCreate?: (input: { + createdWorktree: CreatePaseoWorktreeWorkflowResult | null; + createdAgentId: string | null; + }) => Promise; +} + +export interface HubExecutionAgents { + create(input: HubExecutionAgentCreateInput): Promise; + subscribe(listener: (event: OwnedAgentEvent) => void): () => void; + invalidateAuthority(): Promise; +} + +export class DaemonExecutions implements HubExecutionAgents { + private readonly daemonId: string; + private readonly agentManager: AgentManager; + private readonly agentStorage: AgentStorage; + private readonly createAgentCommand: BoundCreateAgentCommand; + private readonly pendingCreates = new Map>(); + private authorityGeneration = 0; + private authorityActive = true; + private readonly registerAutoArchive: (input: { + agentId: string; + createdWorktree: CreatePaseoWorktreeWorkflowResult | null; + }) => LifecycleRegistration; + private readonly cleanupFailedCreate: NonNullable; + + constructor(options: DaemonExecutionsOptions) { + this.daemonId = options.daemonId; + this.agentManager = options.agentManager; + this.agentStorage = options.agentStorage; + this.createAgentCommand = options.createAgent; + this.registerAutoArchive = + options.registerAutoArchive ?? (() => ({ cancel: async () => undefined })); + this.cleanupFailedCreate = options.cleanupFailedCreate ?? (async () => undefined); + } + + create(input: HubExecutionAgentCreateInput): Promise { + if (!this.authorityActive) { + return Promise.reject(new Error("Hub relationship authority is no longer active")); + } + const owner = this.owner(input.executionId); + const key = daemonExecutionKey(owner); + const pending = this.pendingCreates.get(key); + if (pending) { + return pending; + } + + const authorityGeneration = this.authorityGeneration; + const create = this.createOrResolve(owner, input, authorityGeneration).finally(() => { + if (this.pendingCreates.get(key) === create) { + this.pendingCreates.delete(key); + } + }); + this.pendingCreates.set(key, create); + return create; + } + + async invalidateAuthority(): Promise { + this.authorityActive = false; + this.authorityGeneration++; + await Promise.allSettled(this.pendingCreates.values()); + } + + subscribe(listener: (event: OwnedAgentEvent) => void): () => void { + return this.agentManager.subscribe( + (event) => { + const owned = this.projectEvent(event); + if (owned) { + listener(owned); + } + }, + { replayState: true }, + ); + } + + private async createOrResolve( + owner: DaemonAgentOwner, + input: HubExecutionAgentCreateInput, + authorityGeneration: number, + ): Promise { + const existing = await this.agentStorage.findByDaemonExecution(owner); + if (existing) { + this.requireAuthority(authorityGeneration); + return this.resolveRecord(existing); + } + this.requireAuthority(authorityGeneration); + + let createdWorktree: CreatePaseoWorktreeWorkflowResult | null = null; + let createdAgentId: string | null = null; + let autoArchiveRegistration: LifecycleRegistration = { cancel: async () => undefined }; + let result: Awaited>; + try { + result = await this.createAgentCommand({ + kind: "mcp", + provider: input.model ? `${input.provider}/${input.model}` : input.provider, + title: input.prompt, + initialPrompt: input.prompt, + promptFailure: "throw", + cwd: input.cwd, + workspaceId: input.workspaceId, + mode: input.modeId, + thinking: input.thinkingOptionId, + features: input.featureValues, + env: input.env, + worktree: toCreateAgentWorktree(input.worktree), + background: true, + notifyOnFinish: false, + owner, + onWorktreeCreated: (worktree) => { + createdWorktree = worktree; + }, + onCreated: (created) => { + createdAgentId = created.agentId; + if (input.autoArchive === true) { + autoArchiveRegistration = this.registerAutoArchive({ + ...created, + createdWorktree: ownedCreatedWorktree(created.createdWorktree), + }); + } + }, + }); + this.requireAuthority(authorityGeneration); + } catch (error) { + try { + await autoArchiveRegistration.cancel(); + if (createdAgentId && this.agentManager.getAgent(createdAgentId)) { + try { + await this.agentManager.closeAgent(createdAgentId); + } finally { + await this.agentManager.deleteAgentState(createdAgentId); + } + } + } finally { + try { + await this.cleanupFailedCreate({ + createdWorktree: ownedCreatedWorktree(createdWorktree), + createdAgentId: null, + }); + } finally { + if (createdAgentId) { + await this.agentStorage.remove(createdAgentId); + } + } + } + throw error; + } + + return { + executionId: owner.executionId, + agent: serializeAgentSnapshot(result.liveSnapshot), + }; + } + + private resolveRecord(record: StoredAgentRecord): OwnedAgentSnapshot { + return this.projectRecord(record); + } + + private requireAuthority(authorityGeneration: number): void { + if (!this.authorityActive || authorityGeneration !== this.authorityGeneration) { + throw new Error("Hub relationship authority ended during agent creation"); + } + } + + private projectRecord(record: StoredAgentRecord): OwnedAgentSnapshot { + const owner = this.requireOwner(record); + const live = this.agentManager.getAgent(record.id); + return { + executionId: owner.executionId, + agent: live + ? serializeAgentSnapshot(live) + : { + ...buildStoredAgentPayload(record, this.agentManager.getRegisteredProviderIds()), + status: "closed", + }, + }; + } + + private projectEvent(event: AgentManagerEvent): OwnedAgentEvent | null { + if (event.type === "agent_state") { + return this.projectAgentState(event.agent); + } + if (event.type !== "agent_stream") { + return null; + } + const agent = this.agentManager.getAgent(event.agentId); + if (!this.isOwned(agent)) { + return null; + } + const serialized = serializeAgentStreamEvent(event.event); + if (!serialized) { + return null; + } + return { + type: "stream", + executionId: agent.owner.executionId, + agentId: agent.id, + event: serialized, + }; + } + + private projectAgentState(agent: ManagedAgent): OwnedAgentEvent | null { + if (!this.isOwned(agent)) { + return null; + } + return { + type: "update", + executionId: agent.owner.executionId, + agent: serializeAgentSnapshot(agent), + }; + } + + private isOwned(agent: ManagedAgent | null): agent is ManagedAgent & { owner: DaemonAgentOwner } { + return agent?.owner?.kind === "daemon" && agent.owner.daemonId === this.daemonId; + } + + private owner(executionId: string): DaemonAgentOwner { + return { kind: "daemon", daemonId: this.daemonId, executionId }; + } + + private requireOwner(record: StoredAgentRecord): DaemonAgentOwner { + const owner = record.owner; + if (owner?.kind !== "daemon" || owner.daemonId !== this.daemonId) { + throw new Error(`Agent ${record.id} is not owned by daemon ${this.daemonId}`); + } + return owner; + } +} + +function ownedCreatedWorktree( + worktree: CreatePaseoWorktreeWorkflowResult | null, +): CreatePaseoWorktreeWorkflowResult | null { + return worktree?.created === true ? worktree : null; +} + +function toCreateAgentWorktree(target: CreateAgentWorktreeTarget | undefined) { + if (!target) return undefined; + if (target.mode === "branch-off") { + return { + worktreeName: target.newBranch, + baseBranch: target.base, + action: "branch-off" as const, + }; + } + if (target.mode === "checkout-branch") { + return { refName: target.branch, action: "checkout" as const }; + } + return { githubPrNumber: target.prNumber, action: "checkout" as const }; +} diff --git a/packages/server/src/server/hub/execution-controller.test.ts b/packages/server/src/server/hub/execution-controller.test.ts new file mode 100644 index 000000000..585b63d51 --- /dev/null +++ b/packages/server/src/server/hub/execution-controller.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "vitest"; +import type { + AgentSnapshotPayload, + HubExecutionAgentCreateRequest, + SessionOutboundMessage, +} from "@getpaseo/protocol/messages"; + +import type { + HubExecutionAgents, + OwnedAgentEvent, + OwnedAgentSnapshot, +} from "./daemon-executions.js"; +import { HubExecutionController } from "./execution-controller.js"; + +interface Deferred { + promise: Promise; + resolve(value: T): void; + reject(error: Error): void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((accept, decline) => { + resolve = accept; + reject = decline; + }); + return { promise, resolve, reject }; +} + +class ControlledHubExecutionAgents implements HubExecutionAgents { + private readonly createObserved = deferred(); + private readonly createGate = deferred(); + + create(): Promise { + this.createObserved.resolve(); + return this.createGate.promise; + } + + subscribe(_listener: (event: OwnedAgentEvent) => void): () => void { + return () => undefined; + } + + async invalidateAuthority(): Promise {} + + async creationStarted(): Promise { + await this.createObserved.promise; + } + + finishCreate(): void { + this.createGate.resolve({ + executionId: "execution-shutdown", + agent: { + id: "agent-shutdown", + status: "running", + } as AgentSnapshotPayload, + }); + } +} + +describe("HubExecutionController", () => { + test("cleanup fences in-flight creates before the dead session can receive a response", async () => { + const agents = new ControlledHubExecutionAgents(); + const messages: SessionOutboundMessage[] = []; + const controller = new HubExecutionController({ + agents, + send: (message) => messages.push(message), + }); + + const create = controller.createAgent({ + type: "hub.execution.agent.create.request", + requestId: "shutdown-create", + executionId: "execution-shutdown", + provider: "codex", + cwd: "/tmp/paseo", + prompt: "sleep 30", + } satisfies HubExecutionAgentCreateRequest); + await agents.creationStarted(); + + const cleanup = controller.cleanup(); + agents.finishCreate(); + await Promise.all([create, cleanup]); + + expect(messages).toEqual([]); + }); +}); diff --git a/packages/server/src/server/hub/execution-controller.ts b/packages/server/src/server/hub/execution-controller.ts new file mode 100644 index 000000000..c9b93155e --- /dev/null +++ b/packages/server/src/server/hub/execution-controller.ts @@ -0,0 +1,129 @@ +import { isAbsolute } from "node:path"; +import type { + HubExecutionAgentCreateRequest, + SessionOutboundMessage, +} from "@getpaseo/protocol/messages"; + +import type { HubExecutionAgents, OwnedAgentEvent } from "./daemon-executions.js"; + +interface HubExecutionControllerOptions { + agents: HubExecutionAgents; + send: (message: SessionOutboundMessage) => void; +} + +export class HubExecutionController { + private readonly agents: HubExecutionAgents; + private readonly send: (message: SessionOutboundMessage) => void; + private readonly unsubscribe: () => void; + private readonly pendingCreates = new Set>(); + private cleanupPromise: Promise | null = null; + private closed = false; + + constructor(options: HubExecutionControllerOptions) { + this.agents = options.agents; + this.send = options.send; + this.unsubscribe = this.agents.subscribe((event) => this.sendOwnedEvent(event)); + } + + cleanup(): Promise { + this.cleanupPromise ??= this.cleanupOnce(); + return this.cleanupPromise; + } + + private async cleanupOnce(): Promise { + this.closed = true; + this.unsubscribe(); + await Promise.allSettled(this.pendingCreates); + } + + async createAgent(message: HubExecutionAgentCreateRequest): Promise { + if (this.closed) return; + const create = this.createAgentWithResponse(message); + this.pendingCreates.add(create); + try { + await create; + } finally { + this.pendingCreates.delete(create); + } + } + + private async createAgentWithResponse(message: HubExecutionAgentCreateRequest): Promise { + try { + requireNonBlankHubAgentField("executionId", message.executionId); + requireNonBlankHubAgentField("prompt", message.prompt); + requireNonBlankHubAgentField("cwd", message.cwd); + if (!isAbsolute(message.cwd)) throw new Error("Hub agent cwd must be absolute"); + const result = await this.agents.create({ + executionId: message.executionId, + provider: message.provider, + cwd: message.cwd, + workspaceId: message.workspaceId, + prompt: message.prompt, + model: message.model, + modeId: message.modeId, + thinkingOptionId: message.thinkingOptionId, + featureValues: message.featureValues, + env: message.env, + worktree: message.worktree, + autoArchive: message.autoArchive, + }); + if (this.closed) return; + this.send({ + type: "hub.execution.agent.create.response", + payload: { + requestId: message.requestId, + executionId: message.executionId, + agentId: result.agent.id, + agent: result.agent, + success: true, + error: null, + }, + }); + } catch (error) { + if (this.closed) return; + this.send({ + type: "hub.execution.agent.create.response", + payload: { + requestId: message.requestId, + executionId: message.executionId, + agentId: null, + agent: null, + success: false, + error: error instanceof Error ? error.message : String(error), + }, + }); + } + } + + private sendOwnedEvent(event: OwnedAgentEvent): void { + if (this.closed) return; + if (event.type === "update") { + this.send({ + type: "hub.execution.agent.update", + payload: { + executionId: event.executionId, + agentId: event.agent.id, + agent: event.agent, + }, + }); + return; + } + this.send({ + type: "hub.execution.agent.stream", + payload: { + executionId: event.executionId, + agentId: event.agentId, + event: event.event, + }, + }); + } +} + +function requireNonBlankHubAgentField( + field: "executionId" | "prompt" | "cwd", + value: string, +): void { + if (value.trim().length === 0) { + throw new Error(`Hub agent ${field} cannot be blank`); + } +} diff --git a/packages/server/src/server/hub/execution-session.websocket.test.ts b/packages/server/src/server/hub/execution-session.websocket.test.ts new file mode 100644 index 000000000..caeccd6c6 --- /dev/null +++ b/packages/server/src/server/hub/execution-session.websocket.test.ts @@ -0,0 +1,145 @@ +import { afterEach, expect, test } from "vitest"; +import { + HubRelationshipHarness, + SetupFailingArchiveWatchFiles, +} from "./test-utils/relationship-harness.js"; + +let relationship: HubRelationshipHarness | null = null; + +afterEach(async () => { + await relationship?.close(); + relationship = null; +}); + +async function launchRelationship(): Promise { + const launched = await HubRelationshipHarness.start(); + await launched.beginConnect().result; + launched.connectLatestSocket(); + relationship = launched; + return launched; +} + +test("Hub retries one durable daemon execution across concurrency and reconstruction", async () => { + const hub = await launchRelationship(); + + const created = await hub.createOwnedConcurrently(); + const update = await hub.ownedUpdate(created.first.agentId); + const stream = await hub.ownedStream(created.first.agentId); + const reconstructed = await hub.reconstructAndReplay(); + + expect(created.duplicate.agentId).toBe(created.first.agentId); + expect(update).toMatchObject({ + executionId: "execution-1", + agentId: created.first.agentId, + agent: { id: created.first.agentId }, + }); + expect(stream).toMatchObject({ executionId: "execution-1", agentId: created.first.agentId }); + expect(reconstructed.replay.agent.id).toBe(created.first.agentId); + expect(reconstructed.durableAgentCount).toBe(1); +}); + +test("Hub denies trusted steering and browser dispatch", async () => { + const hub = await launchRelationship(); + const localAgentId = await hub.createUnrelatedLocalAgent(); + + const steeringDenial = await hub.deniedSteering(localAgentId); + const browserDenial = await hub.deniedBrowserDispatch(); + + expect(steeringDenial).toEqual({ + requestId: "denied-steer", + requestType: "send_agent_message_request", + error: "Session is not authorized for send_agent_message_request", + code: "access_denied", + }); + expect(browserDenial).toEqual({ + requestId: "browser-1", + requestType: "browser.automation.execute.response", + error: "Session is not authorized for browser.automation.execute.response", + code: "access_denied", + }); + expect(hub.observedAgentIds()).not.toContain(localAgentId); + expect(hub.observedTrustedLifecycleMessages()).toEqual([]); +}); + +test("Hub sockets reject trusted hello and capabilities", async () => { + const hub = await launchRelationship(); + + expect(hub.probeTrustedHello()).toBe(4002); +}); + +test("Hub sockets reject trusted binary frames", async () => { + const hub = await launchRelationship(); + + expect(hub.probeBinaryFrame()).toBe(4002); +}); + +test("Hub does not receive trusted broadcasts", async () => { + const hub = await launchRelationship(); + + const trustedBroadcasts = await hub.trustedBroadcastCount(); + const trustedStatus = await hub.trustedDaemonStatus(); + + expect(trustedBroadcasts).toBe(0); + expect(trustedStatus).toMatchObject({ pid: process.pid, relay: { enabled: false } }); + expect(hub.observedTrustedLifecycleMessages()).toEqual([]); +}); + +test("Hub reconnects without retaining trusted session state", async () => { + const hub = await launchRelationship(); + const created = await hub.createOwnedConcurrently(); + + const reconnected = await hub.reconnectAndRetry(); + + expect(reconnected).toMatchObject({ + executionId: "execution-1", + agentId: created.first.agentId, + }); + expect(hub.observedTrustedLifecycleMessages()).toEqual([]); +}); + +test("Hub create forwards worktree and auto-archive through the existing create path", async () => { + const hub = await launchRelationship(); + hub.beginOwnedCreate("worktree-create", "execution-worktree", { + worktree: { mode: "branch-off", newBranch: "hub-created-worktree", base: "main" }, + autoArchive: true, + prompt: "sleep 30", + modeId: "always-ask", + }); + const worktreeCreated = await hub.ownedCreateResult("worktree-create"); + const worktreeCwd = hub.latestCreatedCwd(); + const permission = await hub.ownedPermissionRequest(worktreeCreated.payload.agentId!); + const duringRun = await hub.worktreeState(worktreeCwd!); + const archiveCompletion = hub.waitForOwnedArchiveCompletion(worktreeCreated.payload.agentId!); + await hub.allowOwnedPermission(worktreeCreated.payload.agentId!, permission.id); + await hub.ownedTurnCompletion(worktreeCreated.payload.agentId!); + const archive = await archiveCompletion; + const afterArchive = await hub.worktreeState(worktreeCwd!); + + expect(worktreeCreated).toMatchObject({ + type: "hub.execution.agent.create.response", + payload: { success: true, agent: { cwd: worktreeCwd } }, + }); + expect(worktreeCwd).not.toBe(hub.repoRoot()); + expect(duringRun).toEqual({ exists: true, listed: true }); + expect(afterArchive).toEqual({ exists: false, listed: false }); + expect(archive).toEqual({ + agentArchivedAt: expect.any(String), + workspaceArchivedAt: expect.any(String), + }); +}, 20_000); + +test("archive observation closes its first watcher when the second watcher cannot start", async () => { + const watchFiles = new SetupFailingArchiveWatchFiles(2); + const hub = await HubRelationshipHarness.start(watchFiles); + relationship = hub; + await hub.beginConnect().result; + hub.connectLatestSocket(); + hub.beginOwnedCreate("watch-setup-create", "watch-setup-execution"); + const created = await hub.ownedCreateResult("watch-setup-create"); + + await expect(hub.waitForOwnedArchiveCompletion(created.payload.agentId!)).rejects.toThrow( + "Cannot watch", + ); + + expect(watchFiles.activeDirectories()).toEqual([]); +}); diff --git a/packages/server/src/server/hub/relationship-controller.test.ts b/packages/server/src/server/hub/relationship-controller.test.ts new file mode 100644 index 000000000..d7bbe1f68 --- /dev/null +++ b/packages/server/src/server/hub/relationship-controller.test.ts @@ -0,0 +1,806 @@ +import { createHash } from "node:crypto"; +import { platform } from "node:os"; +import { afterEach, describe, expect, test } from "vitest"; +import { HubRelationshipHarness } from "./test-utils/relationship-harness.js"; + +async function captureUnhandledRejections(action: () => Promise): Promise { + const rejections: unknown[] = []; + const capture = (reason: unknown) => rejections.push(reason); + process.on("unhandledRejection", capture); + try { + await action(); + await new Promise((resolve) => setImmediate(resolve)); + } finally { + process.off("unhandledRejection", capture); + } + return rejections; +} + +describe("Hub relationship", () => { + let relationship: HubRelationshipHarness | null = null; + + afterEach(async () => { + await relationship?.close(); + relationship = null; + }); + + test("the CLI connects, reports status, and disconnects through the daemon", async () => { + relationship = await HubRelationshipHarness.start(); + const connected = await relationship.beginConnect().result; + relationship.connectLatestSocket(); + + const status = await relationship.status(); + const enrollment = relationship.enrollmentAttempts()[0]; + const secret = relationship.relationshipFile()?.credential?.secret; + const disconnected = await relationship.disconnect(); + + expect(connected.state).toBe("connecting"); + expect(status.state).toBe("connected"); + expect(relationship.loggableValues(status)).not.toContain(secret); + expect(relationship.loggableValues(status)).not.toContain(enrollment.credentialVerifier); + expect(relationship.loggableValues(status)).not.toContain(enrollment.token); + expect(relationship.loggableValues(status)).not.toContain(enrollment.idempotencyKey); + expect(disconnected.state).toBe("not_connected"); + }, 30_000); + + test("Hub URLs cannot persist embedded credentials", async () => { + relationship = await HubRelationshipHarness.start(); + + await expect( + relationship.beginConnect("ceremony-token", "https://user:password@hub.example").result, + ).rejects.toThrow(); + + expect(relationship.relationshipFile()).toBeNull(); + expect(relationship.enrollmentAttempts()).toEqual([]); + }); + + test("an authenticated external socket can manage the Hub relationship", async () => { + relationship = await HubRelationshipHarness.start(); + + const responses = await relationship.manageRelationshipFromExternalSocket(); + + expect(responses.map((response) => response.type)).toEqual([ + "hub.management.daemon.connect.response", + "hub.management.daemon.get_status.response", + "hub.management.daemon.disconnect.response", + ]); + expect(relationship.enrollmentAttempts()).toHaveLength(1); + expect(relationship.relationshipFile()).toBeNull(); + }); + + test("an authenticated browser socket can manage the Hub relationship", async () => { + relationship = await HubRelationshipHarness.start(); + + const response = await relationship.connectFromBrowserSocket(); + + expect(response.type).toBe("hub.management.daemon.connect.response"); + expect(relationship.enrollmentAttempts()).toHaveLength(1); + expect(relationship.relationshipFile()?.state).toBe("active"); + }); + + test("persists private generated authority before enrollment and active before dialing", async () => { + relationship = await HubRelationshipHarness.start(); + const privateFileMode = platform() === "win32" ? 0o666 : 0o600; + relationship.holdEnrollment(); + const connecting = relationship.beginConnect("one-time-token"); + + const enrollment = await relationship.enrollmentBegins(); + const pending = relationship.enrollmentInvocation(); + + expect(pending).toMatchObject({ + record: { + state: "pending", + relationship: { + daemonId: enrollment.daemonId, + idempotencyKey: enrollment.idempotencyKey, + }, + credential: { secret: expect.any(String) }, + enrollment: { token: "one-time-token" }, + identity: { serverId: expect.any(String), daemonPublicKey: expect.any(String) }, + }, + }); + expect(pending.mode).toBe(privateFileMode); + const secret = pending.record.credential?.secret; + expect(secret).toEqual(expect.any(String)); + expect(enrollment.credentialVerifier).toBe( + createHash("sha256") + .update(secret ?? "") + .digest("base64url"), + ); + relationship.completeEnrollment(); + await connecting.result; + await relationship.socketDialed(); + expect(relationship.socketInvocation()).toMatchObject({ + mode: privateFileMode, + record: { state: "active", relationship: { daemonId: enrollment.daemonId } }, + }); + }); + + test("Hub enrollment cannot widen the daemon's locally granted scopes", async () => { + relationship = await HubRelationshipHarness.start(); + relationship.returnEnrollmentScopes(["hub.execution.*", "*"]); + + await relationship.beginConnect().result; + relationship.connectLatestSocket(); + const responses = relationship.sendHubRequestOnLatest({ + type: "daemon.get_status.request", + requestId: "scope-escalation", + }); + + expect(relationship.relationshipFile()?.relationship.scopes).toEqual(["hub.execution.*"]); + expect(responses).toContainEqual({ + type: "rpc_error", + payload: { + requestId: "scope-escalation", + requestType: "daemon.get_status.request", + error: "Session is not authorized for daemon.get_status.request", + code: "access_denied", + }, + }); + }); + + test("a lost enrollment response reuses the exact ceremony", async () => { + relationship = await HubRelationshipHarness.start(); + relationship.holdEnrollment(); + const connecting = relationship.beginConnect("same-token"); + await relationship.enrollmentBegins(); + relationship.loseEnrollmentResponse(); + await connecting.result; + + await relationship.retry(); + const attempts = relationship.enrollmentAttempts(); + + expect(attempts).toHaveLength(2); + expect(attempts[1]).toEqual(attempts[0]); + expect(relationship.enrolledRelationships()).toBe(1); + }); + + test("a fresh token replaces the token for a pending enrollment ceremony", async () => { + relationship = await HubRelationshipHarness.start(); + relationship.holdEnrollment(); + const firstConnect = relationship.beginConnect("expired-token"); + const firstResult = expect(firstConnect.result).resolves.toMatchObject({ + state: "reconnecting", + }); + await relationship.enrollmentBegins(); + relationship.loseEnrollmentResponse(); + await firstResult; + + relationship.holdEnrollment(); + const secondConnect = relationship.beginConnect("fresh-token"); + const secondResult = expect(secondConnect.result).resolves.toMatchObject({ + state: "connecting", + }); + const retried = await relationship.enrollmentBegins(); + + expect(retried.token).toBe("fresh-token"); + expect(relationship.relationshipFile()?.enrollment?.token).toBe("fresh-token"); + relationship.completeEnrollment(); + await secondResult; + }); + + test("a stale enrollment rejection cannot discard a fresh pending ceremony", async () => { + relationship = await HubRelationshipHarness.start(); + relationship.holdEnrollment(); + const expiredConnect = relationship.beginConnect("expired-token"); + await relationship.enrollmentBegins(); + + relationship.holdEnrollment(); + const freshConnect = relationship.beginConnect("fresh-token"); + const freshEnrollment = await relationship.enrollmentBegins(); + relationship.rejectEnrollment(0, 403); + await expiredConnect.result; + + expect(relationship.relationshipFile()?.enrollment?.token).toBe("fresh-token"); + expect(relationship.pendingRelationshipRetries()).toBe(0); + + relationship.completeEnrollment(); + await freshConnect.result; + + expect(freshEnrollment.token).toBe("fresh-token"); + expect(relationship.relationshipFile()?.state).toBe("active"); + expect(relationship.enrollmentAttempts()).toHaveLength(2); + expect(relationship.pendingRelationshipRetries()).toBe(0); + }); + + test("a rejected pending enrollment is discarded without blocking daemon restart", async () => { + relationship = await HubRelationshipHarness.start(); + relationship.holdEnrollment(); + const connecting = relationship.beginConnect("expired-token"); + await relationship.enrollmentBegins(); + relationship.loseEnrollmentResponse(); + await connecting.result; + relationship.rejectNextEnrollment(401); + + await relationship.restartDaemon(); + + expect(await relationship.status()).toMatchObject({ state: "not_connected" }); + expect(relationship.relationshipFile()).toBeNull(); + }); + + test("a scheduled enrollment rejection is contained after removing pending authority", async () => { + relationship = await HubRelationshipHarness.start(); + relationship.holdEnrollment(); + const connecting = relationship.beginConnect("expired-token"); + await relationship.enrollmentBegins(); + relationship.loseEnrollmentResponse(); + await connecting.result; + relationship.rejectNextEnrollment(403); + + const unhandledRejections = await captureUnhandledRejections(() => relationship!.retry()); + const status = await relationship.status(); + + expect(status).toMatchObject({ state: "not_connected" }); + expect(relationship.relationshipFile()).toBeNull(); + expect(relationship.pendingRelationshipRetries()).toBe(0); + expect(unhandledRejections).toEqual([]); + }); + + test.each(["{not-json", JSON.stringify({ version: 1, state: "unknown" })])( + "an invalid relationship file is quarantined without blocking daemon startup", + async (contents) => { + relationship = await HubRelationshipHarness.start(); + await relationship.corruptRelationshipFile(contents); + + await relationship.startStoppedDaemon(); + + expect(await relationship.status()).toMatchObject({ state: "not_connected" }); + expect(relationship.relationshipFile()).toBeNull(); + expect(await relationship.quarantinedRelationshipFiles()).toHaveLength(1); + }, + ); + + test.each([ + ["a non-HTTP scheme", "ftp://hub.test"], + ["embedded credentials", "https://user:password@hub.test"], + ["a query", "https://hub.test?token=secret"], + ["a fragment", "https://hub.test#secret"], + ])("a persisted Hub origin with %s is quarantined before startup", async (_, hubOrigin) => { + relationship = await HubRelationshipHarness.start(); + await relationship.corruptRelationshipFile( + JSON.stringify({ + version: 1, + state: "pending", + relationship: { + daemonId: "daemon-1", + idempotencyKey: "ceremony-1", + hubOrigin, + createdAt: "2026-07-13T00:00:00.000Z", + scopes: ["hub.execution.*"], + }, + credential: { secret: "credential" }, + enrollment: { token: "enrollment-token" }, + identity: { serverId: "server-1", daemonPublicKey: "public-key" }, + }), + ); + + await relationship.startStoppedDaemon(); + + expect(await relationship.status()).toMatchObject({ state: "not_connected" }); + expect(relationship.relationshipFile()).toBeNull(); + expect(await relationship.quarantinedRelationshipFiles()).toHaveLength(1); + expect(relationship.enrollmentAttempts()).toEqual([]); + }); + + test("a persisted Hub relationship cannot widen its local execution scope", async () => { + relationship = await HubRelationshipHarness.start(); + await relationship.beginConnect().result; + const persisted = relationship.relationshipFile(); + expect(persisted).not.toBeNull(); + persisted!.relationship.scopes = ["*"]; + await relationship.corruptRelationshipFile(JSON.stringify(persisted)); + const socketAttemptsBeforeRestart = relationship.socketAttempts(); + + await relationship.startStoppedDaemon(); + + expect(await relationship.status()).toMatchObject({ state: "not_connected" }); + expect(relationship.relationshipFile()).toBeNull(); + expect(await relationship.quarantinedRelationshipFiles()).toHaveLength(1); + expect(relationship.socketAttempts()).toBe(socketAttemptsBeforeRestart); + }); + + test("a persisted non-WebSocket transport is quarantined before daemon startup", async () => { + relationship = await HubRelationshipHarness.start(); + await relationship.corruptRelationshipFile( + JSON.stringify({ + version: 1, + state: "active", + relationship: { + id: "relationship-1", + idempotencyKey: "ceremony-1", + hubOrigin: "https://hub.test", + createdAt: "2026-07-13T00:00:00.000Z", + scopes: ["hub.execution.*"], + }, + credential: { secret: "credential" }, + transport: { kind: "direct_websocket", webSocketUrl: "ftp://hub.test/daemon" }, + }), + ); + + await relationship.startStoppedDaemon(); + + expect(await relationship.status()).toMatchObject({ state: "not_connected" }); + expect(relationship.relationshipFile()).toBeNull(); + expect(await relationship.quarantinedRelationshipFiles()).toHaveLength(1); + expect(relationship.socketAttempts()).toBe(0); + }); + + test("a persisted WebSocket transport with a fragment is quarantined before startup", async () => { + relationship = await HubRelationshipHarness.start(); + await relationship.corruptRelationshipFile( + JSON.stringify({ + version: 1, + state: "active", + relationship: { + id: "relationship-1", + idempotencyKey: "ceremony-1", + hubOrigin: "https://hub.test", + createdAt: "2026-07-13T00:00:00.000Z", + scopes: ["hub.execution.*"], + }, + credential: { secret: "credential" }, + transport: { + kind: "direct_websocket", + webSocketUrl: "wss://hub.test/daemon#fragment", + }, + }), + ); + + await relationship.startStoppedDaemon(); + + expect(await relationship.status()).toMatchObject({ state: "not_connected" }); + expect(relationship.relationshipFile()).toBeNull(); + expect(await relationship.quarantinedRelationshipFiles()).toHaveLength(1); + expect(relationship.socketAttempts()).toBe(0); + }); + + test("disconnect revokes an ambiguous pending enrollment before removing local authority", async () => { + relationship = await HubRelationshipHarness.start(); + relationship.holdEnrollment(); + const connecting = relationship.beginConnect("one-time-token"); + const enrollment = await relationship.enrollmentBegins(); + const credential = relationship.relationshipFile()?.credential?.secret; + relationship.loseEnrollmentResponse(); + await connecting.result; + relationship.failRevocations(2); + + const disconnected = await relationship.disconnect(); + expect(disconnected.state).toBe("disconnecting"); + expect(relationship.relationshipFile()?.state).toBe("disconnecting"); + + await relationship.restartDaemon(); + await relationship.retry(); + + expect(relationship.revocationAttempts()).toBe(3); + expect(relationship.latestRevocation()).toEqual( + expect.objectContaining({ + daemonId: enrollment.daemonId, + hubOrigin: enrollment.hubOrigin, + credential, + }), + ); + expect(relationship.relationshipFile()).toBeNull(); + }); + + test("disconnect revokes only after an in-flight enrollment settles", async () => { + relationship = await HubRelationshipHarness.start(); + relationship.holdEnrollment(); + const connecting = relationship.beginConnect("one-time-token"); + const enrollment = await relationship.enrollmentBegins(); + + const disconnecting = relationship.beginDisconnect(); + await relationship.relationshipStateBecomes("disconnecting"); + expect(relationship.revocationAttempts()).toBe(0); + + relationship.completeEnrollment(); + await connecting.result; + const disconnected = await disconnecting.result; + + expect(disconnected.state).toBe("not_connected"); + expect(relationship.latestRevocation()?.daemonId).toBe(enrollment.daemonId); + expect(relationship.revocationAttempts()).toBe(1); + expect(relationship.socketAttempts()).toBe(0); + expect(relationship.relationshipFile()).toBeNull(); + }); + + test("daemon restart reconnects the same durable relationship", async () => { + relationship = await HubRelationshipHarness.start(); + await relationship.beginConnect().result; + const id = relationship.relationshipFile()?.relationship.daemonId; + relationship.connectLatestSocket(); + await relationship.socketDialed(); + + await relationship.restartDaemon(); + await relationship.socketDialed(); + + expect(relationship.relationshipFile()?.relationship.daemonId).toBe(id); + expect(relationship.socketAttempts()).toBe(2); + }); + + test("daemon restart closes an interrupted owned turn without replaying its prompt", async () => { + relationship = await HubRelationshipHarness.start(); + await relationship.beginConnect().result; + await relationship.socketDialed(); + relationship.connectLatestSocket(); + const daemonId = relationship.relationshipFile()?.relationship.daemonId; + const prompt = "sleep 30"; + relationship.beginOwnedCreate("running-create", "execution-running", { + prompt, + modeId: "full-access", + }); + const created = await relationship.ownedCreateResult("running-create"); + const running = await relationship.ownedRunningUpdate(created.payload.agentId!); + + await relationship.restartDaemon(); + await relationship.socketDialed(); + relationship.connectLatestSocket(); + relationship.beginOwnedCreate("running-duplicate", "execution-running", { prompt }); + const duplicate = await relationship.ownedCreateResult("running-duplicate"); + expect(duplicate).toMatchObject({ + payload: { + success: true, + executionId: "execution-running", + agentId: created.payload.agentId, + agent: { id: created.payload.agentId, status: "closed" }, + }, + }); + const durableAgentIds = await relationship.durableOwnedAgentIds(); + + expect(running).toMatchObject({ + executionId: "execution-running", + agentId: created.payload.agentId, + agent: { id: created.payload.agentId, status: "running" }, + }); + expect(relationship.relationshipFile()?.relationship.daemonId).toBe(daemonId); + expect(durableAgentIds).toEqual([created.payload.agentId]); + expect(relationship.providerCreations()).toBe(1); + expect(relationship.providerResumes()).toBe(0); + expect(relationship.providerPromptTexts()).toEqual([prompt]); + expect(relationship.latestOwnedTurnCompletions(created.payload.agentId!)).toBe(0); + }); + + test("an owned execution does not persist a daemon-restart prompt intent", async () => { + relationship = await HubRelationshipHarness.start(); + await relationship.beginConnect().result; + relationship.connectLatestSocket(); + relationship.beginOwnedCreate("intent-create", "execution-intent", { prompt: "sleep 30" }); + await relationship.ownedCreateResult("intent-create"); + + expect(await relationship.hubExecutionIntentFiles()).toEqual([]); + }); + + test("an ordinary duplicate create does not replay a completed owned turn", async () => { + relationship = await HubRelationshipHarness.start(); + await relationship.beginConnect().result; + relationship.connectLatestSocket(); + const prompt = "respond with exactly: completed once"; + relationship.beginOwnedCreate("completed-create", "execution-completed", { prompt }); + const created = await relationship.ownedCreateResult("completed-create"); + await relationship.ownedTurnCompletion(created.payload.agentId!); + + relationship.beginOwnedCreate("completed-duplicate", "execution-completed", { prompt }); + const duplicate = await relationship.ownedCreateResult("completed-duplicate"); + + expect(duplicate).toMatchObject({ + type: "hub.execution.agent.create.response", + payload: { + success: true, + executionId: "execution-completed", + agentId: created.payload.agentId, + agent: { status: "idle" }, + }, + }); + expect(relationship.providerPromptTexts()).toEqual([prompt]); + expect(await relationship.durableOwnedAgentIds()).toEqual([created.payload.agentId]); + expect(relationship.latestOwnedTurnCompletions(created.payload.agentId!)).toBe(1); + }); + + test("daemon restart closes a completed owned session without replaying its prompt", async () => { + relationship = await HubRelationshipHarness.start(); + await relationship.beginConnect().result; + relationship.connectLatestSocket(); + const prompt = "respond with exactly: already completed"; + relationship.beginOwnedCreate("idle-create", "execution-idle", { prompt }); + const created = await relationship.ownedCreateResult("idle-create"); + await relationship.ownedTurnCompletion(created.payload.agentId!); + + await relationship.restartDaemon(); + await relationship.socketDialed(); + relationship.connectLatestSocket(); + relationship.beginOwnedCreate("idle-retry", "execution-idle", { prompt }); + const retried = await relationship.ownedCreateResult("idle-retry"); + + expect(retried).toMatchObject({ + payload: { + executionId: "execution-idle", + agentId: created.payload.agentId, + agent: { id: created.payload.agentId, status: "closed" }, + }, + }); + expect(relationship.providerPromptTexts()).toEqual([prompt]); + expect(relationship.providerResumes()).toBe(0); + expect(relationship.latestOwnedTurnCompletions(created.payload.agentId!)).toBe(0); + }); + + test("daemon restart closes a failed owned session without replaying its prompt", async () => { + relationship = await HubRelationshipHarness.start(); + await relationship.beginConnect().result; + relationship.connectLatestSocket(); + const prompt = "emit a turn failure"; + relationship.beginOwnedCreate("failed-create", "execution-failed", { prompt }); + const created = await relationship.ownedCreateResult("failed-create"); + const failed = await relationship.ownedTurnFailure(created.payload.agentId!); + + await relationship.restartDaemon(); + await relationship.socketDialed(); + relationship.connectLatestSocket(); + relationship.beginOwnedCreate("failed-retry", "execution-failed", { prompt }); + const retried = await relationship.ownedCreateResult("failed-retry"); + + expect(failed).toMatchObject({ + executionId: "execution-failed", + agentId: created.payload.agentId, + event: { type: "turn_failed" }, + }); + expect(retried).toMatchObject({ + payload: { + executionId: "execution-failed", + agentId: created.payload.agentId, + agent: { id: created.payload.agentId, status: "closed" }, + }, + }); + expect(relationship.providerPromptTexts()).toEqual([prompt]); + expect(relationship.providerResumes()).toBe(0); + expect(relationship.latestOwnedTurnCompletions(created.payload.agentId!)).toBe(0); + }); + + test("Hub revocation leaves no execution intent artifacts", async () => { + relationship = await HubRelationshipHarness.start(); + await relationship.beginConnect().result; + relationship.connectLatestSocket(); + relationship.beginOwnedCreate("revoked-create", "execution-revoked", { prompt: "sleep 30" }); + await relationship.ownedCreateResult("revoked-create"); + + relationship.rejectRelationship(401); + + expect(await relationship.hubExecutionIntentFiles()).toEqual([]); + }); + + test("stale socket generations cannot replace or unregister the current socket", async () => { + relationship = await HubRelationshipHarness.start(); + await relationship.beginConnect().result; + relationship.closeSocket(0, 1006); + await relationship.retry(); + relationship.connectSocket(1); + + relationship.connectSocket(0); + relationship.closeSocket(0, 1000); + const messages = relationship.sendHubRequestOnLatest({ + type: "daemon.get_status.request", + requestId: "still-current", + }); + + expect(messages).toContainEqual({ + type: "rpc_error", + payload: { + requestId: "still-current", + requestType: "daemon.get_status.request", + error: "Session is not authorized for daemon.get_status.request", + code: "access_denied", + }, + }); + expect(relationship.socketAttempts()).toBe(2); + }); + + test("replayed create across socket generations shares one pending durable execution", async () => { + relationship = await HubRelationshipHarness.start(); + await relationship.beginConnect().result; + relationship.connectLatestSocket(); + relationship.holdAgentCreation(); + relationship.beginOwnedCreate("first-create"); + await relationship.agentCreationAttempts(1); + + relationship.closeLatestSocket(1006); + await relationship.retry(); + relationship.connectLatestSocket(); + relationship.beginOwnedCreate("replayed-create"); + relationship.finishAgentCreation(); + + const replayed = await relationship.ownedCreateResult("replayed-create"); + const durableAgentIds = await relationship.durableOwnedAgentIds(); + + expect(relationship.socketDeliveredResponse(0, "first-create")).toBe(false); + expect(replayed).toMatchObject({ + type: "hub.execution.agent.create.response", + payload: { + success: true, + executionId: "execution-race", + agentId: durableAgentIds[0], + }, + }); + expect(relationship.providerCreations()).toBe(1); + expect(durableAgentIds).toHaveLength(1); + }); + + test("idempotent retry waits for a pending create across socket generations", async () => { + relationship = await HubRelationshipHarness.start(); + await relationship.beginConnect().result; + relationship.connectLatestSocket(); + relationship.holdAgentCreation(); + relationship.beginOwnedCreate("pending-create", "pending-execution"); + await relationship.agentCreationAttempts(1); + + relationship.closeLatestSocket(1006); + await relationship.retry(); + relationship.connectLatestSocket(); + relationship.beginOwnedCreate("pending-retry", "pending-execution"); + relationship.finishAgentCreation(); + + await expect(relationship.ownedCreateResult("pending-retry")).resolves.toMatchObject({ + payload: { + executionId: "pending-execution", + agentId: expect.any(String), + agent: { id: expect.any(String) }, + }, + }); + expect(relationship.providerCreations()).toBe(1); + expect(await relationship.durableOwnedAgentIds()).toHaveLength(1); + }); + + test("force disconnect fences a pending create before removing authority", async () => { + relationship = await HubRelationshipHarness.start(); + await relationship.beginConnect().result; + relationship.connectLatestSocket(); + relationship.holdAgentCreation(); + relationship.beginOwnedCreate("cancelled-create", "cancelled-execution", { + worktree: { mode: "branch-off", newBranch: "cancelled-hub-create" }, + }); + await relationship.agentCreationAttempts(1); + + const disconnecting = relationship.beginDisconnect(true); + await relationship.relationshipStateBecomes(null); + relationship.finishAgentCreation(); + await disconnecting.result; + + expect(relationship.activeOwnedAgentIds()).toEqual([]); + expect(await relationship.durableOwnedAgentIds()).toEqual([]); + expect(await relationship.listedWorktrees()).toHaveLength(1); + }); + + test("re-enrollment gives the same daemon fresh execution authority", async () => { + relationship = await HubRelationshipHarness.start(); + await relationship.beginConnect().result; + relationship.connectLatestSocket(); + relationship.beginOwnedCreate("first-create", "first-execution"); + const first = await relationship.ownedCreateResult("first-create"); + const daemonId = relationship.relationshipFile()?.relationship.daemonId; + + await relationship.disconnect(); + await relationship.beginConnect("fresh-token").result; + relationship.connectLatestSocket(); + relationship.beginOwnedCreate("second-create", "second-execution"); + const second = await relationship.ownedCreateResult("second-create"); + + expect(relationship.relationshipFile()?.relationship.daemonId).toBe(daemonId); + expect(first).toMatchObject({ payload: { success: true, executionId: "first-execution" } }); + expect(second).toMatchObject({ payload: { success: true, executionId: "second-execution" } }); + expect(relationship.providerCreations()).toBe(2); + }); + + test("daemon shutdown fences a pending create before closing owned agents", async () => { + relationship = await HubRelationshipHarness.start(); + await relationship.beginConnect().result; + relationship.connectLatestSocket(); + relationship.holdAgentCreation(); + relationship.beginOwnedCreate("shutdown-create", "execution-shutdown", { prompt: "sleep 30" }); + await relationship.agentCreationAttempts(1); + + const shutdown = relationship.shutdownDaemon(); + relationship.finishAgentCreation(); + await shutdown; + + expect(relationship.socketDeliveredResponse(0, "shutdown-create")).toBe(false); + expect(await relationship.durableOwnedAgentIdsOnDisk()).toEqual([]); + }); + + test.each([ + [4403, "Hub revoked this relationship"], + [401, "Hub rejected socket authentication (401)"], + [403, "Hub rejected socket authentication (403)"], + ] as const)("authentication rejection %s revokes permanently", async (code, reason) => { + relationship = await HubRelationshipHarness.start(); + await relationship.beginConnect("private-token").result; + const enrollment = relationship.enrollmentAttempts()[0]; + const secret = relationship.relationshipFile()?.credential?.secret; + relationship.rejectRelationship(code); + + await relationship.restartDaemon(); + const status = await relationship.status(); + const persisted = relationship.relationshipFile(); + + expect(status).toMatchObject({ + state: "revoked", + daemonId: enrollment.daemonId, + hub: "https://hub.test", + scopes: "hub.execution.*", + error: reason, + }); + expect(persisted?.state).toBe("revoked"); + expect(persisted?.relationship).toMatchObject({ + daemonId: enrollment.daemonId, + hubOrigin: "https://hub.test", + scopes: ["hub.execution.*"], + }); + expect(persisted?.reason).toBe(reason); + expect(persisted).not.toHaveProperty("credential"); + expect(persisted).not.toHaveProperty("relationship.idempotencyKey"); + expect(relationship.socketAttempts()).toBe(1); + const loggable = relationship.loggableValues(status); + const reconstructed = JSON.stringify({ status, persisted }); + expect(reconstructed).not.toContain(secret); + expect(reconstructed).not.toContain(enrollment.credentialVerifier); + expect(reconstructed).not.toContain(enrollment.token); + expect(reconstructed).not.toContain(enrollment.idempotencyKey); + expect(loggable).not.toContain(secret); + expect(loggable).not.toContain(enrollment.credentialVerifier); + expect(loggable).not.toContain(enrollment.token); + expect(loggable).not.toContain(enrollment.idempotencyKey); + }); + + test("offline disconnect retries across runtime and restart without opening a socket", async () => { + relationship = await HubRelationshipHarness.start(); + await relationship.beginConnect().result; + relationship.connectLatestSocket(); + relationship.failRevocations(3); + + const disconnecting = await relationship.disconnect(); + await relationship.retry(); + await relationship.restartDaemon(); + await relationship.retry(); + + expect(disconnecting.state).toBe("disconnecting"); + expect(relationship.revocationAttempts()).toBe(4); + expect(relationship.socketAttempts()).toBe(1); + expect(relationship.relationshipFile()).toBeNull(); + }); + + test("successful disconnect clears a transient revocation error", async () => { + relationship = await HubRelationshipHarness.start(); + await relationship.beginConnect().result; + relationship.failRevocations(1); + + const disconnecting = await relationship.disconnect(); + await relationship.retry(); + + expect(disconnecting).toMatchObject({ state: "disconnecting", error: expect.any(String) }); + expect(await relationship.status()).toMatchObject({ state: "not_connected", error: null }); + }); + + test("force disconnect removes local authority and reports the remote warning", async () => { + relationship = await HubRelationshipHarness.start(); + await relationship.beginConnect().result; + relationship.failRevocations(1); + await relationship.disconnect(); + + const forced = await relationship.disconnect(true); + + expect(forced.state).toBe("not_connected"); + expect(forced.warning).toContain("remote revocation"); + expect(relationship.relationshipFile()).toBeNull(); + }); + + test("disconnect leaves no intent artifacts and shutdown closes the owned agent", async () => { + relationship = await HubRelationshipHarness.start(); + await relationship.beginConnect().result; + relationship.connectLatestSocket(); + relationship.beginOwnedCreate("orphan-create", "execution-orphan", { prompt: "sleep 30" }); + const created = await relationship.ownedCreateResult("orphan-create"); + expect(created).toMatchObject({ payload: { agent: { status: "running" } } }); + + await relationship.disconnect(true); + expect(await relationship.hubExecutionIntentFiles()).toEqual([]); + await relationship.restartDaemon(); + + expect(await relationship.storedOwnedStatus(created.payload.agentId!)).toBe("closed"); + }); +}); diff --git a/packages/server/src/server/hub/relationship-controller.ts b/packages/server/src/server/hub/relationship-controller.ts new file mode 100644 index 000000000..457a5b701 --- /dev/null +++ b/packages/server/src/server/hub/relationship-controller.ts @@ -0,0 +1,556 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { existsSync, readFileSync, renameSync, rmSync } from "node:fs"; +import path from "node:path"; +import type pino from "pino"; +import { z } from "zod"; +import { ensurePrivateFile, writePrivateFileAtomicSync } from "../private-files.js"; +import type { WebSocketLike } from "../websocket-server.js"; +import type { HubExecutionAgents } from "./daemon-executions.js"; +import type { + HubRelationshipRemote, + HubSocketConnection, + HubSocketEvents, +} from "./relationship-remote.js"; +import { HubEnrollmentRejectedError } from "./relationship-remote.js"; +import { BoundedExponentialHubRetryPolicy } from "./relationship-retry.js"; + +const FILE_NAME = "hub-relationship.json"; +const HUB_EXECUTION_SCOPE = "hub.execution.*"; +const SCOPES = [HUB_EXECUTION_SCOPE] as const; +const HubOriginSchema = z + .string() + .url() + .superRefine((value, context) => { + try { + normalizeHubUrl(value); + } catch (error) { + context.addIssue({ + code: "custom", + message: error instanceof Error ? error.message : "Invalid Hub URL", + }); + } + }); + +const RelationshipSchema = z.object({ + daemonId: z.string().min(1), + idempotencyKey: z.string().min(1), + hubOrigin: HubOriginSchema, + createdAt: z.string(), + scopes: z.tuple([z.literal(HUB_EXECUTION_SCOPE)]), +}); +const SanitizedRelationshipSchema = RelationshipSchema.omit({ idempotencyKey: true }); +const CredentialSchema = z.object({ secret: z.string().min(1) }); +const TransportSchema = z.object({ + kind: z.literal("direct_websocket"), + webSocketUrl: z + .string() + .url() + .refine((value) => ["ws:", "wss:"].includes(new URL(value).protocol)) + .refine((value) => new URL(value).hash === ""), +}); +const PendingSchema = z.object({ + version: z.literal(1), + state: z.literal("pending"), + relationship: RelationshipSchema, + credential: CredentialSchema, + enrollment: z.object({ token: z.string().min(1) }), + identity: z.object({ serverId: z.string().min(1), daemonPublicKey: z.string().min(1) }), +}); +const ActiveSchema = z.object({ + version: z.literal(1), + state: z.literal("active"), + relationship: RelationshipSchema, + credential: CredentialSchema, + transport: TransportSchema, +}); +const DisconnectingSchema = z.object({ + version: z.literal(1), + state: z.literal("disconnecting"), + relationship: RelationshipSchema, + credential: CredentialSchema, + transport: TransportSchema.optional(), +}); +const RevokedSchema = z.object({ + version: z.literal(1), + state: z.literal("revoked"), + relationship: SanitizedRelationshipSchema, + transport: TransportSchema.optional(), + reason: z.string().optional(), +}); +const RecordSchema = z + .discriminatedUnion("state", [PendingSchema, ActiveSchema, DisconnectingSchema, RevokedSchema]) + .superRefine((record, context) => { + if (!("transport" in record) || !record.transport) return; + const hub = new URL(record.relationship.hubOrigin); + const socket = new URL(record.transport.webSocketUrl); + const expectedProtocol = hub.protocol === "https:" ? "wss:" : "ws:"; + if (socket.protocol === expectedProtocol && socket.host === hub.host) return; + context.addIssue({ + code: "custom", + path: ["transport", "webSocketUrl"], + message: "Hub WebSocket URL must match the Hub origin", + }); + }); +type PendingRecord = z.infer; +type ActiveRecord = z.infer; +type DisconnectingRecord = z.infer; +type RevokedRecord = z.infer; +type HubRelationshipRecord = z.infer; + +export type HubConnectionState = + | "not_connected" + | "connecting" + | "connected" + | "reconnecting" + | "disconnecting" + | "revoked"; + +export interface HubRelationshipStatus { + state: HubConnectionState; + daemonId: string | null; + hubOrigin: string | null; + scopes: string[]; + connectedAt: string | null; + lastError: string | null; +} + +export interface HubRelationshipManagement { + connect(input: { hubUrl: string; token: string }): Promise; + status(): HubRelationshipStatus; + disconnect(input: { + force: boolean; + }): Promise<{ status: HubRelationshipStatus; warning?: string }>; +} + +export interface ScheduledRelationshipTask { + cancel(): void; +} + +export interface HubRelationshipClock { + now(): Date; + schedule(delayMs: number, task: () => void): ScheduledRelationshipTask; +} + +export interface HubRelationshipRetryPolicy { + delay(attempt: number): number; +} + +export interface HubRelationshipControllerOptions { + paseoHome: string; + serverId: string; + daemonPublicKey: string; + logger: pino.Logger; + remote: HubRelationshipRemote; + clock?: HubRelationshipClock; + retryPolicy?: HubRelationshipRetryPolicy; + createDaemonId?: () => string; + attachSocket: ( + socket: WebSocketLike, + options: { daemonId: string; scopes: readonly string[]; agents: HubExecutionAgents }, + ) => Promise; + createExecutionAgents: (daemonId: string) => HubExecutionAgents; +} + +const systemClock: HubRelationshipClock = { + now: () => new Date(), + schedule(delayMs, task) { + const timer = setTimeout(task, delayMs); + timer.unref?.(); + return { cancel: () => clearTimeout(timer) }; + }, +}; + +function normalizeHubUrl(value: string): string { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Hub URL must use HTTP or HTTPS"); + } + if (url.username || url.password) { + throw new Error("Hub URL cannot include credentials"); + } + if (url.search || url.hash) { + throw new Error("Hub URL cannot include a query or fragment"); + } + url.pathname = url.pathname.replace(/\/$/, ""); + return url.toString().replace(/\/$/, ""); +} + +export class HubRelationshipController implements HubRelationshipManagement { + private readonly filePath: string; + private readonly clock: HubRelationshipClock; + private readonly retryPolicy: HubRelationshipRetryPolicy; + private record: HubRelationshipRecord | null; + private state: HubConnectionState = "not_connected"; + private connectedAt: string | null = null; + private lastError: string | null = null; + private socket: HubSocketConnection | null = null; + private retry: ScheduledRelationshipTask | null = null; + private generation = 0; + private enrollmentGeneration = 0; + private retryAttempt = 0; + private readonly inFlightEnrollments = new Set>(); + private executionAgents: { daemonId: string; value: HubExecutionAgents } | null = null; + + constructor(private readonly options: HubRelationshipControllerOptions) { + this.filePath = path.join(options.paseoHome, FILE_NAME); + this.clock = options.clock ?? systemClock; + this.retryPolicy = options.retryPolicy ?? new BoundedExponentialHubRetryPolicy(); + this.record = this.load(); + if (this.record?.state === "revoked") { + this.state = "revoked"; + this.lastError = this.record.reason ?? null; + } else if (this.record?.state === "disconnecting") this.state = "disconnecting"; + else if (this.record) this.state = "connecting"; + } + + async start(): Promise { + if (this.record?.state === "active") this.openSocket(this.record, false); + if (this.record?.state === "pending") { + const enrollmentGeneration = this.beginEnrollmentAttempt(); + try { + await this.tryEnrollment(this.record, enrollmentGeneration); + } catch (error) { + if (!(error instanceof HubEnrollmentRejectedError)) throw error; + this.options.logger.warn( + { statusCode: error.statusCode }, + "Discarded rejected pending Hub enrollment during startup", + ); + } + } + if (this.record?.state === "disconnecting") await this.tryRevocation(this.record); + } + + async stop(): Promise { + const pendingExecutionCleanup = this.retireExecutionAgents(); + this.cancelLifecycle(); + this.socket?.close(); + this.socket = null; + await pendingExecutionCleanup; + } + + status(): HubRelationshipStatus { + return { + state: this.state, + daemonId: this.record?.relationship.daemonId ?? null, + hubOrigin: this.record?.relationship.hubOrigin ?? null, + scopes: this.record?.relationship.scopes.slice() ?? [], + connectedAt: this.connectedAt, + lastError: this.lastError, + }; + } + + async connect(input: { hubUrl: string; token: string }): Promise { + if (this.record?.state === "pending") { + if (normalizeHubUrl(input.hubUrl) !== this.record.relationship.hubOrigin) { + throw new Error("A pending Hub enrollment already exists for a different Hub"); + } + this.record = { ...this.record, enrollment: { token: input.token } }; + const enrollmentGeneration = this.beginEnrollmentAttempt(); + this.persist(this.record); + this.state = "connecting"; + this.lastError = null; + await this.tryEnrollment(this.record, enrollmentGeneration); + return this.status(); + } + if (this.record && this.record.state !== "revoked") { + throw new Error("This daemon already has a Hub relationship"); + } + const pending: PendingRecord = { + version: 1, + state: "pending", + relationship: { + daemonId: this.options.createDaemonId?.() ?? randomUUID(), + idempotencyKey: randomUUID(), + hubOrigin: normalizeHubUrl(input.hubUrl), + createdAt: this.clock.now().toISOString(), + scopes: [...SCOPES], + }, + credential: { secret: randomBytes(32).toString("base64url") }, + enrollment: { token: input.token }, + identity: { serverId: this.options.serverId, daemonPublicKey: this.options.daemonPublicKey }, + }; + this.persist(pending); + this.record = pending; + this.state = "connecting"; + this.lastError = null; + await this.tryEnrollment(pending, this.beginEnrollmentAttempt()); + return this.status(); + } + + async disconnect(input: { + force: boolean; + }): Promise<{ status: HubRelationshipStatus; warning?: string }> { + const waitForEnrollment = this.record?.state === "pending"; + const pendingCreateCleanup = this.retireExecutionAgents(); + this.cancelLifecycle(); + this.socket?.close(); + this.socket = null; + if (!this.record || this.record.state === "revoked") { + this.remove(); + await pendingCreateCleanup; + return { status: this.status() }; + } + if (input.force) { + this.remove(); + await pendingCreateCleanup; + return { + status: this.status(), + warning: "Local Hub credential removed; remote revocation may remain pending.", + }; + } + const disconnecting: DisconnectingRecord = { + version: 1, + state: "disconnecting", + relationship: this.record.relationship, + credential: this.record.credential, + ...(this.record.state === "active" ? { transport: this.record.transport } : {}), + }; + this.persist(disconnecting); + this.record = disconnecting; + this.state = "disconnecting"; + if (waitForEnrollment) { + await Promise.all(this.inFlightEnrollments); + } + await this.tryRevocation(disconnecting); + await pendingCreateCleanup; + return { status: this.status() }; + } + + private async tryEnrollment(pending: PendingRecord, enrollmentGeneration: number): Promise { + if (enrollmentGeneration !== this.enrollmentGeneration) return; + const verifier = createHash("sha256").update(pending.credential.secret).digest("base64url"); + const request = this.options.remote.enroll({ + daemonId: pending.relationship.daemonId, + idempotencyKey: pending.relationship.idempotencyKey, + hubOrigin: pending.relationship.hubOrigin, + token: pending.enrollment.token, + serverId: pending.identity.serverId, + daemonPublicKey: pending.identity.daemonPublicKey, + credentialVerifier: verifier, + scopes: pending.relationship.scopes, + }); + const settled = request.then( + () => undefined, + () => undefined, + ); + this.inFlightEnrollments.add(settled); + try { + const enrollment = await request; + if (enrollmentGeneration !== this.enrollmentGeneration) return; + if ( + enrollment.daemonId !== pending.relationship.daemonId || + !enrollment.scopes.includes(HUB_EXECUTION_SCOPE) + ) { + throw new Error("Hub enrollment response did not match the pending relationship"); + } + const active: ActiveRecord = { + version: 1, + state: "active", + relationship: pending.relationship, + credential: pending.credential, + transport: { kind: "direct_websocket", webSocketUrl: enrollment.webSocketUrl }, + }; + this.persist(active); + this.record = active; + this.retry = null; + this.retryAttempt = 0; + this.openSocket(active, false); + } catch (error) { + if (enrollmentGeneration !== this.enrollmentGeneration) return; + if (error instanceof HubEnrollmentRejectedError) { + this.remove(); + throw error; + } + this.lastError = error instanceof Error ? error.message : String(error); + this.scheduleEnrollment(pending, enrollmentGeneration); + } finally { + this.inFlightEnrollments.delete(settled); + } + } + + private openSocket(record: ActiveRecord, reconnecting: boolean): void { + const generation = ++this.generation; + this.state = reconnecting ? "reconnecting" : "connecting"; + const events: HubSocketEvents = { + connected: (socket) => this.socketConnected(generation, record, socket), + rejected: (statusCode) => this.socketRejected(generation, statusCode), + closed: (code) => this.socketClosed(generation, record, code), + failed: (error) => this.socketFailed(generation, record, error), + }; + this.socket = this.options.remote.openSocket( + { + daemonId: record.relationship.daemonId, + webSocketUrl: record.transport.webSocketUrl, + credential: record.credential.secret, + }, + events, + ); + } + + private socketConnected(generation: number, record: ActiveRecord, socket: WebSocketLike): void { + if (generation !== this.generation) { + socket.close(); + return; + } + this.retryAttempt = 0; + this.state = "connected"; + this.connectedAt = this.clock.now().toISOString(); + this.lastError = null; + void this.options.attachSocket(socket, { + daemonId: record.relationship.daemonId, + scopes: record.relationship.scopes, + agents: this.executionAgentsFor(record.relationship.daemonId), + }); + } + + private executionAgentsFor(daemonId: string): HubExecutionAgents { + if (this.executionAgents?.daemonId === daemonId) return this.executionAgents.value; + const value = this.options.createExecutionAgents(daemonId); + this.executionAgents = { daemonId, value }; + return value; + } + + private retireExecutionAgents(): Promise { + const executionAgents = this.executionAgents; + this.executionAgents = null; + return executionAgents?.value.invalidateAuthority() ?? Promise.resolve(); + } + + private socketRejected(generation: number, statusCode: 401 | 403): void { + if (generation !== this.generation) return; + this.revoke(`Hub rejected socket authentication (${statusCode})`); + } + + private socketClosed(generation: number, record: ActiveRecord, code: number): void { + if (generation !== this.generation) return; + if (code === 4403) { + this.revoke("Hub revoked this relationship"); + return; + } + if (this.record?.state === "active") this.scheduleSocket(record); + } + + private socketFailed(generation: number, record: ActiveRecord, error: Error): void { + if (generation !== this.generation) return; + this.lastError = error.message; + if (this.record?.state === "active") this.scheduleSocket(record); + } + + private scheduleSocket(record: ActiveRecord): void { + this.state = "reconnecting"; + this.schedule(() => this.openSocket(record, true)); + } + + private scheduleEnrollment(record: PendingRecord, enrollmentGeneration: number): void { + if (enrollmentGeneration !== this.enrollmentGeneration) return; + this.state = "reconnecting"; + this.retry?.cancel(); + const delay = this.retryPolicy.delay(this.retryAttempt++); + this.retry = this.clock.schedule(delay, () => { + if (enrollmentGeneration !== this.enrollmentGeneration) return; + void this.tryEnrollment(record, enrollmentGeneration).catch((error: unknown) => { + if (error instanceof HubEnrollmentRejectedError) return; + this.options.logger.error({ err: error }, "Scheduled Hub enrollment retry failed"); + }); + }); + } + + private async tryRevocation(record: DisconnectingRecord): Promise { + const generation = this.generation; + try { + await this.options.remote.revoke({ + daemonId: record.relationship.daemonId, + hubOrigin: record.relationship.hubOrigin, + credential: record.credential.secret, + }); + if (generation !== this.generation) return; + this.remove(); + } catch (error) { + if (generation !== this.generation) return; + this.lastError = error instanceof Error ? error.message : String(error); + this.state = "disconnecting"; + this.schedule(() => void this.tryRevocation(record)); + } + } + + private schedule(task: () => void): void { + this.retry?.cancel(); + const generation = this.generation; + const delay = this.retryPolicy.delay(this.retryAttempt++); + this.retry = this.clock.schedule(delay, () => { + if (generation === this.generation) task(); + }); + } + + private revoke(reason: string): void { + void this.retireExecutionAgents(); + this.cancelLifecycle(); + if (!this.record) return; + const revoked: RevokedRecord = { + version: 1, + state: "revoked", + relationship: { + daemonId: this.record.relationship.daemonId, + hubOrigin: this.record.relationship.hubOrigin, + createdAt: this.record.relationship.createdAt, + scopes: this.record.relationship.scopes, + }, + transport: "transport" in this.record ? this.record.transport : undefined, + reason, + }; + this.persist(revoked); + this.record = revoked; + this.state = "revoked"; + this.lastError = reason; + } + + private cancelLifecycle(): void { + ++this.generation; + ++this.enrollmentGeneration; + this.retry?.cancel(); + this.retry = null; + } + + private beginEnrollmentAttempt(): number { + this.retry?.cancel(); + this.retry = null; + this.retryAttempt = 0; + return ++this.enrollmentGeneration; + } + + private persist(record: HubRelationshipRecord): void { + writePrivateFileAtomicSync(this.filePath, `${JSON.stringify(record, null, 2)}\n`); + } + + private remove(): void { + void this.retireExecutionAgents(); + this.cancelLifecycle(); + rmSync(this.filePath, { force: true }); + this.record = null; + this.state = "not_connected"; + this.connectedAt = null; + this.lastError = null; + } + + private load(): HubRelationshipRecord | null { + if (!existsSync(this.filePath)) return null; + let record: HubRelationshipRecord; + try { + record = RecordSchema.parse(JSON.parse(readFileSync(this.filePath, "utf8"))); + } catch (error) { + const quarantinePath = path.join( + path.dirname(this.filePath), + `hub-relationship.invalid-${this.clock.now().getTime()}-${randomUUID()}.json`, + ); + renameSync(this.filePath, quarantinePath); + ensurePrivateFile(quarantinePath); + this.options.logger.error( + { err: error, quarantinePath }, + "Quarantined invalid Hub relationship authority", + ); + return null; + } + ensurePrivateFile(this.filePath); + return record; + } +} diff --git a/packages/server/src/server/hub/relationship-remote.test.ts b/packages/server/src/server/hub/relationship-remote.test.ts new file mode 100644 index 000000000..e4950adab --- /dev/null +++ b/packages/server/src/server/hub/relationship-remote.test.ts @@ -0,0 +1,551 @@ +import { createServer } from "node:http"; +import { mkdtemp, rm } from "node:fs/promises"; +import type { AddressInfo, Socket } from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; +import { afterEach, expect, test } from "vitest"; +import { WebSocket } from "ws"; +import type { HubExecutionAgents } from "./daemon-executions.js"; +import { + HubRelationshipController, + type HubRelationshipClock, + type HubRelationshipRetryPolicy, + type ScheduledRelationshipTask, +} from "./relationship-controller.js"; +import { + DirectHubRelationshipRemote, + HubEnrollmentRejectedError, + type HubSocketConnection, + type HubSocketEvents, +} from "./relationship-remote.js"; + +const openServers: ReturnType[] = []; +const openUpgradeHubs: UpgradeRejectingHub[] = []; +const openPaseoHomes: string[] = []; + +afterEach(async () => { + for (const hub of openUpgradeHubs.splice(0)) hub.destroyConnections(); + await Promise.all(openServers.splice(0).map((server) => closeServer(server))); + await Promise.all(openPaseoHomes.splice(0).map((home) => rm(home, { recursive: true }))); +}); + +test.each([401, 403, 404])( + "revocation status %s clears already-invalid local authority", + async (status) => { + const hubOrigin = await startHubReturning(status); + const remote = new DirectHubRelationshipRemote(); + + await expect( + remote.revoke({ daemonId: "daemon-1", hubOrigin, credential: "invalid" }), + ).resolves.toBeUndefined(); + }, +); + +test("transient revocation failures remain retryable", async () => { + const hubOrigin = await startHubReturning(503); + const remote = new DirectHubRelationshipRemote(); + + await expect( + remote.revoke({ daemonId: "daemon-1", hubOrigin, credential: "credential" }), + ).rejects.toThrow("Hub revocation failed (503)"); +}); + +test.each([408, 429])("transient enrollment status %s remains retryable", async (status) => { + const hubOrigin = await startHubReturning(status); + const remote = new DirectHubRelationshipRemote(); + + const error = await remote + .enroll({ + daemonId: "daemon-1", + idempotencyKey: "ceremony-1", + hubOrigin, + token: "token", + serverId: "server-1", + daemonPublicKey: "public-key", + credentialVerifier: "verifier", + scopes: ["hub.execution.*"], + }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(HubEnrollmentRejectedError); + expect((error as Error).message).toBe(`Hub enrollment failed (${status})`); +}); + +test("enrollment rejects a transport URL that cannot open a WebSocket", async () => { + const hubOrigin = await startEnrollmentHub("ftp://hub.test/daemon"); + const remote = new DirectHubRelationshipRemote(); + + await expect( + remote.enroll({ + daemonId: "daemon-1", + idempotencyKey: "ceremony-1", + hubOrigin, + token: "token", + serverId: "server-1", + daemonPublicKey: "public-key", + credentialVerifier: "verifier", + scopes: ["hub.execution.*"], + }), + ).rejects.toThrow("Hub WebSocket URL must use ws or wss"); +}); + +test("enrollment rejects a WebSocket URL with a fragment", async () => { + const hubOrigin = await startEnrollmentHub("ws://hub.test/daemon#fragment"); + const remote = new DirectHubRelationshipRemote(); + + await expect( + remote.enroll({ + daemonId: "daemon-1", + idempotencyKey: "ceremony-1", + hubOrigin, + token: "token", + serverId: "server-1", + daemonPublicKey: "public-key", + credentialVerifier: "verifier", + scopes: ["hub.execution.*"], + }), + ).rejects.toThrow("Hub WebSocket URL cannot include a fragment"); +}); + +test("enrollment rejects a WebSocket outside the enrolled Hub authority", async () => { + const hubOrigin = await startEnrollmentHub("ws://other-hub.test/daemon"); + const remote = new DirectHubRelationshipRemote(); + + await expect( + remote.enroll({ + daemonId: "daemon-1", + idempotencyKey: "ceremony-1", + hubOrigin, + token: "token", + serverId: "server-1", + daemonPublicKey: "public-key", + credentialVerifier: "verifier", + scopes: ["hub.execution.*"], + }), + ).rejects.toThrow("Hub WebSocket URL must match the Hub origin"); +}); + +test.each(["enrollment", "revocation"])("%s HTTP calls are bounded", async (operation) => { + const hubOrigin = await startStalledHub(); + const remote = new DirectHubRelationshipRemote({ requestTimeoutMs: 25 }); + + const request = + operation === "enrollment" + ? remote.enroll({ + daemonId: "daemon-1", + idempotencyKey: "ceremony-1", + hubOrigin, + token: "token", + serverId: "server-1", + daemonPublicKey: "public-key", + credentialVerifier: "verifier", + scopes: ["hub.execution.*"], + }) + : remote.revoke({ + daemonId: "daemon-1", + hubOrigin, + credential: "credential", + }); + + await expect(request).rejects.toThrow("Hub request timed out"); +}); + +test.each([401, 403] as const)( + "socket authentication status %s is terminal and releases the failed upgrade", + async (status) => { + const hub = await UpgradeRejectingHub.start([status]); + const outcomes = new SocketOutcomes(); + const remote = new DirectHubRelationshipRemote(); + + const socket = remote.openSocket( + { + daemonId: "daemon-1", + webSocketUrl: hub.webSocketUrl, + credential: "invalid", + }, + outcomes.events, + ) as HubSocketConnection & WebSocket; + + expect(await outcomes.next()).toEqual({ type: "rejected", status }); + await hub.expectAttemptReleased(1); + expect(await outcomes.afterCleanup()).toEqual([{ type: "rejected", status }]); + expect(socket.readyState).toBe(WebSocket.CLOSED); + expect(hub.openConnections()).toBe(0); + }, +); + +test("a transient socket response releases the failed upgrade and reports one connection loss", async () => { + const hub = await UpgradeRejectingHub.start([503]); + const outcomes = new SocketOutcomes(); + const remote = new DirectHubRelationshipRemote(); + + const socket = remote.openSocket( + { + daemonId: "daemon-1", + webSocketUrl: hub.webSocketUrl, + credential: "credential", + }, + outcomes.events, + ) as HubSocketConnection & WebSocket; + + expect(await outcomes.next()).toEqual({ type: "closed", code: 1006 }); + await hub.expectAttemptReleased(1); + expect(await outcomes.afterCleanup()).toEqual([{ type: "closed", code: 1006 }]); + expect(socket.readyState).toBe(WebSocket.CLOSED); + expect(hub.openConnections()).toBe(0); +}); + +test("a stalled socket opening handshake times out and releases the connection", async () => { + const hub = await UpgradeRejectingHub.start(["stall"]); + const outcomes = new SocketOutcomes(); + const remote = new DirectHubRelationshipRemote({ requestTimeoutMs: 25 }); + + const socket = remote.openSocket( + { + daemonId: "daemon-1", + webSocketUrl: hub.webSocketUrl, + credential: "credential", + }, + outcomes.events, + ) as HubSocketConnection & WebSocket; + + expect(await outcomes.next()).toEqual({ + type: "failed", + message: "Opening handshake has timed out", + }); + expect(await outcomes.afterCleanup()).toEqual([ + { type: "failed", message: "Opening handshake has timed out" }, + ]); + expect(socket.readyState).toBe(WebSocket.CLOSED); +}); + +test.each([401, 403] as const)( + "controller treats socket authentication status %s as terminal without redialing", + async (status) => { + const hub = await UpgradeRejectingHub.start([status]); + const clock = new ManualRelationshipClock(); + const controller = await connectController(hub, clock); + + await hub.expectAttemptReleased(1); + + expect(controller.status()).toMatchObject({ + state: "revoked", + lastError: `Hub rejected socket authentication (${status})`, + }); + expect(hub.attemptCount()).toBe(1); + expect(clock.pendingTasks()).toBe(0); + expect(hub.openConnections()).toBe(0); + }, +); + +test("controller redials after a transient socket response and releases both failed upgrades", async () => { + const hub = await UpgradeRejectingHub.start([503, 401]); + const clock = new ManualRelationshipClock(); + const controller = await connectController(hub, clock); + + await hub.expectAttemptReleased(1); + expect(controller.status().state).toBe("reconnecting"); + expect(clock.pendingTasks()).toBe(1); + + clock.runNext(); + await hub.expectAttemptReleased(2); + + expect(hub.attemptCount()).toBe(2); + expect(controller.status().state).toBe("revoked"); + expect(clock.pendingTasks()).toBe(0); + expect(hub.openConnections()).toBe(0); +}); + +test("controller redials once after a failed upgrade without also handling its close", async () => { + const hub = await UpgradeRejectingHub.start([0, 401]); + const clock = new ManualRelationshipClock(); + const controller = await connectController(hub, clock); + + await hub.expectAttemptReleased(1); + + expect(controller.status().state).toBe("reconnecting"); + expect(clock.scheduledAttempts()).toEqual([0]); + expect(clock.pendingTasks()).toBe(1); + + clock.runNext(); + await hub.expectAttemptReleased(2); + + expect(hub.attemptCount()).toBe(2); + expect(controller.status().state).toBe("revoked"); + expect(clock.pendingTasks()).toBe(0); +}); + +async function startHubReturning(status: number): Promise { + const server = createServer((_request, response) => { + response.writeHead(status).end(); + }); + openServers.push(server); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address() as AddressInfo; + return `http://127.0.0.1:${address.port}`; +} + +async function startEnrollmentHub(webSocketUrl: string): Promise { + const server = createServer(async (request, response) => { + let body = ""; + for await (const chunk of request) body += chunk; + const enrollment = JSON.parse(body) as { daemonId: string }; + response.writeHead(200, { "content-type": "application/json" }).end( + JSON.stringify({ + daemonId: enrollment.daemonId, + scopes: ["hub.execution.*"], + webSocketUrl, + }), + ); + }); + openServers.push(server); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address() as AddressInfo; + return `http://127.0.0.1:${address.port}`; +} + +async function startStalledHub(): Promise { + const server = createServer(() => undefined); + openServers.push(server); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address() as AddressInfo; + return `http://127.0.0.1:${address.port}`; +} + +function closeServer(server: ReturnType): Promise { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +type SocketOutcome = + | { type: "connected" } + | { type: "rejected"; status: 401 | 403 } + | { type: "closed"; code: number } + | { type: "failed"; message: string }; + +interface Deferred { + promise: Promise; + resolve(value: T): void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((accept) => { + resolve = accept; + }); + return { promise, resolve }; +} + +class SocketOutcomes { + private readonly outcomes: SocketOutcome[] = []; + private nextOutcome = deferred(); + + readonly events: HubSocketEvents = { + connected: () => this.record({ type: "connected" }), + rejected: (status) => this.record({ type: "rejected", status }), + closed: (code) => this.record({ type: "closed", code }), + failed: (error) => this.record({ type: "failed", message: error.message }), + }; + + next(): Promise { + return withDeadline(this.nextOutcome.promise, "Hub socket produced no outcome"); + } + + async afterCleanup(): Promise { + await new Promise((resolve) => setImmediate(resolve)); + return this.outcomes.slice(); + } + + private record(outcome: SocketOutcome): void { + this.outcomes.push(outcome); + this.nextOutcome.resolve(outcome); + this.nextOutcome = deferred(); + } +} + +class UpgradeRejectingHub { + private readonly server = createServer(async (request, response) => { + let body = ""; + for await (const chunk of request) body += chunk; + const enrollment = JSON.parse(body) as { daemonId: string }; + response.writeHead(200, { "content-type": "application/json" }).end( + JSON.stringify({ + daemonId: enrollment.daemonId, + scopes: ["hub.execution.*"], + webSocketUrl: this.webSocketUrl, + }), + ); + }); + private readonly sockets = new Set(); + private readonly releasedAttempts = new Map>(); + private attemptObserved = deferred(); + private attempts = 0; + + private constructor(private readonly statuses: Array) { + this.server.on("upgrade", (_request, socket) => { + const attempt = ++this.attempts; + this.attemptObserved.resolve(); + this.attemptObserved = deferred(); + const released = deferred(); + this.releasedAttempts.set(attempt, released); + this.sockets.add(socket); + socket.once("close", () => { + this.sockets.delete(socket); + released.resolve(); + }); + const status = this.statuses[attempt - 1] ?? this.statuses.at(-1) ?? 503; + if (status === "stall") { + socket.resume(); + return; + } + if (status === 0) { + socket.destroy(); + return; + } + socket.end( + `HTTP/1.1 ${status} Rejected\r\nContent-Length: 4\r\nConnection: close\r\n\r\ndeny`, + ); + }); + } + + static async start(statuses: Array): Promise { + const hub = new UpgradeRejectingHub(statuses); + openUpgradeHubs.push(hub); + openServers.push(hub.server); + await new Promise((resolve, reject) => { + hub.server.once("error", reject); + hub.server.listen(0, "127.0.0.1", resolve); + }); + return hub; + } + + get webSocketUrl(): string { + const address = this.server.address() as AddressInfo; + return `ws://127.0.0.1:${address.port}/daemon`; + } + + get origin(): string { + const address = this.server.address() as AddressInfo; + return `http://127.0.0.1:${address.port}`; + } + + attemptCount(): number { + return this.attempts; + } + + openConnections(): number { + return this.sockets.size; + } + + destroyConnections(): void { + for (const socket of this.sockets) socket.destroy(); + } + + async expectAttemptReleased(attempt: number): Promise { + while (this.attempts < attempt) { + await withDeadline( + this.attemptObserved.promise, + `Hub did not receive socket attempt ${attempt}`, + ); + } + const released = this.releasedAttempts.get(attempt); + if (!released) throw new Error(`Hub did not receive socket attempt ${attempt}`); + await withDeadline(released.promise, `Hub socket attempt ${attempt} remained open`); + await new Promise((resolve) => setImmediate(resolve)); + } +} + +class ManualRelationshipClock implements HubRelationshipClock, HubRelationshipRetryPolicy { + private readonly tasks: Array<{ cancelled: boolean; task: () => void }> = []; + private readonly attempts: number[] = []; + + now(): Date { + return new Date("2026-07-13T00:00:00.000Z"); + } + + delay(attempt: number): number { + this.attempts.push(attempt); + return 0; + } + + schedule(_delayMs: number, task: () => void): ScheduledRelationshipTask { + const scheduled = { cancelled: false, task }; + this.tasks.push(scheduled); + return { cancel: () => (scheduled.cancelled = true) }; + } + + pendingTasks(): number { + return this.tasks.filter((task) => !task.cancelled).length; + } + + scheduledAttempts(): number[] { + return this.attempts.slice(); + } + + runNext(): void { + const scheduled = this.tasks.shift(); + if (!scheduled || scheduled.cancelled) throw new Error("No Hub reconnect is scheduled"); + scheduled.task(); + } +} + +const unusedExecutionAgents: HubExecutionAgents = { + create: async () => { + throw new Error("Unexpected Hub agent create"); + }, + subscribe: () => () => undefined, + invalidateAuthority: async () => undefined, +}; + +async function connectController( + hub: UpgradeRejectingHub, + clock: ManualRelationshipClock, +): Promise { + const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-hub-socket-")); + openPaseoHomes.push(paseoHome); + const controller = new HubRelationshipController({ + paseoHome, + serverId: "server-1", + daemonPublicKey: "daemon-public-key", + logger: pino({ level: "silent" }), + remote: new DirectHubRelationshipRemote(), + clock, + retryPolicy: clock, + attachSocket: async () => undefined, + createExecutionAgents: () => unusedExecutionAgents, + }); + await controller.connect({ hubUrl: hub.origin, token: "enrollment-token" }); + return controller; +} + +async function withDeadline(promise: Promise, message: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(message)), 2_000); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} diff --git a/packages/server/src/server/hub/relationship-remote.ts b/packages/server/src/server/hub/relationship-remote.ts new file mode 100644 index 000000000..82172a411 --- /dev/null +++ b/packages/server/src/server/hub/relationship-remote.ts @@ -0,0 +1,187 @@ +import { WebSocket } from "ws"; +import { z } from "zod"; +import type { WebSocketLike } from "../websocket-server.js"; + +export interface HubEnrollment { + daemonId: string; + idempotencyKey: string; + hubOrigin: string; + token: string; + serverId: string; + daemonPublicKey: string; + credentialVerifier: string; + scopes: string[]; +} + +export interface HubEnrollmentResult { + daemonId: string; + scopes: string[]; + webSocketUrl: string; +} + +export interface HubRevocation { + daemonId: string; + hubOrigin: string; + credential: string; +} + +export interface HubSocketCredentials { + daemonId: string; + webSocketUrl: string; + credential: string; +} + +export interface HubSocketEvents { + connected(socket: WebSocketLike): void; + rejected(statusCode: 401 | 403): void; + closed(code: number): void; + failed(error: Error): void; +} + +export interface HubSocketConnection { + close(): void; +} + +export interface HubRelationshipRemote { + enroll(input: HubEnrollment): Promise; + revoke(input: HubRevocation): Promise; + openSocket(input: HubSocketCredentials, events: HubSocketEvents): HubSocketConnection; +} + +export class HubEnrollmentRejectedError extends Error { + constructor(readonly statusCode: number) { + super(`Hub enrollment failed (${statusCode})`); + this.name = "HubEnrollmentRejectedError"; + } +} + +const EnrollmentResultSchema = z.object({ + daemonId: z.string(), + scopes: z.array(z.string()), + webSocketUrl: z + .string() + .url() + .refine((value) => ["ws:", "wss:"].includes(new URL(value).protocol), { + message: "Hub WebSocket URL must use ws or wss", + }) + .refine((value) => new URL(value).hash === "", { + message: "Hub WebSocket URL cannot include a fragment", + }), +}); + +function ensureWebSocketMatchesHubOrigin(hubOrigin: string, webSocketUrl: string): void { + const hub = new URL(hubOrigin); + const socket = new URL(webSocketUrl); + const expectedProtocol = hub.protocol === "https:" ? "wss:" : "ws:"; + if (socket.protocol !== expectedProtocol || socket.host !== hub.host) { + throw new Error("Hub WebSocket URL must match the Hub origin"); + } +} + +export class DirectHubRelationshipRemote implements HubRelationshipRemote { + private readonly requestTimeoutMs: number; + + constructor(options: { requestTimeoutMs?: number } = {}) { + this.requestTimeoutMs = options.requestTimeoutMs ?? 15_000; + } + + async enroll(input: HubEnrollment): Promise { + return this.withRequestTimeout(async (signal) => { + const response = await fetch(`${input.hubOrigin}/api/daemons/enroll`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${input.token}`, + }, + body: JSON.stringify({ + daemonId: input.daemonId, + idempotencyKey: input.idempotencyKey, + serverId: input.serverId, + daemonPublicKey: input.daemonPublicKey, + credentialVerifier: input.credentialVerifier, + scopes: input.scopes, + }), + signal, + }); + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + throw new HubEnrollmentRejectedError(response.status); + } + throw new Error(`Hub enrollment failed (${response.status})`); + } + const enrollment = EnrollmentResultSchema.parse(await response.json()); + ensureWebSocketMatchesHubOrigin(input.hubOrigin, enrollment.webSocketUrl); + return enrollment; + }); + } + + async revoke(input: HubRevocation): Promise { + await this.withRequestTimeout(async (signal) => { + const response = await fetch( + `${input.hubOrigin}/api/daemons/${encodeURIComponent(input.daemonId)}`, + { + method: "DELETE", + headers: { authorization: `Bearer ${input.credential}` }, + signal, + }, + ); + if (!response.ok && ![401, 403, 404].includes(response.status)) { + throw new Error(`Hub revocation failed (${response.status})`); + } + }); + } + + openSocket(input: HubSocketCredentials, events: HubSocketEvents): HubSocketConnection { + const socket = new WebSocket(input.webSocketUrl, { + handshakeTimeout: this.requestTimeoutMs, + headers: { + authorization: `Bearer ${input.credential}`, + "x-paseo-daemon-id": input.daemonId, + }, + }); + let settled = false; + socket.once("open", () => { + if (!settled) events.connected(socket as WebSocketLike); + }); + socket.once("unexpected-response", (_request, response) => { + if (settled) { + response.destroy(); + return; + } + settled = true; + response.destroy(); + socket.terminate(); + if (response.statusCode === 401 || response.statusCode === 403) { + events.rejected(response.statusCode); + return; + } + events.closed(1006); + }); + socket.once("close", (code) => { + if (settled) return; + settled = true; + events.closed(code); + }); + socket.once("error", (error) => { + if (settled) return; + settled = true; + socket.terminate(); + events.failed(error); + }); + return socket; + } + + private async withRequestTimeout(operation: (signal: AbortSignal) => Promise): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.requestTimeoutMs); + timeout.unref?.(); + try { + return await operation(controller.signal); + } catch (error) { + if (controller.signal.aborted) throw new Error("Hub request timed out", { cause: error }); + throw error; + } finally { + clearTimeout(timeout); + } + } +} diff --git a/packages/server/src/server/hub/relationship-retry.test.ts b/packages/server/src/server/hub/relationship-retry.test.ts new file mode 100644 index 000000000..8331df1d5 --- /dev/null +++ b/packages/server/src/server/hub/relationship-retry.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "vitest"; +import { BoundedExponentialHubRetryPolicy } from "./relationship-retry.js"; + +describe("Hub relationship retry policy", () => { + test("keeps jitter within the lower and upper bounds", () => { + const lower = new BoundedExponentialHubRetryPolicy(() => 0); + const upper = new BoundedExponentialHubRetryPolicy(() => 1); + + expect(lower.delay(0)).toBe(375); + expect(upper.delay(0)).toBe(625); + }); + + test("grows exponentially and caps the base delay", () => { + const retry = new BoundedExponentialHubRetryPolicy(() => 0.5); + + expect([0, 1, 2, 3, 4, 5, 6, 20].map((attempt) => retry.delay(attempt))).toEqual([ + 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000, + ]); + }); + + test("never schedules a zero-delay tight loop", () => { + const retry = new BoundedExponentialHubRetryPolicy(() => 0); + + expect([0, 1, 2].map((attempt) => retry.delay(attempt))).toEqual([375, 750, 1_500]); + }); +}); diff --git a/packages/server/src/server/hub/relationship-retry.ts b/packages/server/src/server/hub/relationship-retry.ts new file mode 100644 index 000000000..005aff89f --- /dev/null +++ b/packages/server/src/server/hub/relationship-retry.ts @@ -0,0 +1,16 @@ +import type { HubRelationshipRetryPolicy } from "./relationship-controller.js"; + +const INITIAL_DELAY_MS = 500; +const MAX_BASE_DELAY_MS = 30_000; +const MIN_JITTER_FACTOR = 0.75; +const JITTER_RANGE = 0.5; + +export class BoundedExponentialHubRetryPolicy implements HubRelationshipRetryPolicy { + constructor(private readonly random: () => number = Math.random) {} + + delay(attempt: number): number { + const base = Math.min(MAX_BASE_DELAY_MS, INITIAL_DELAY_MS * 2 ** attempt); + const jitter = MIN_JITTER_FACTOR + this.random() * JITTER_RANGE; + return Math.max(1, Math.round(base * jitter)); + } +} diff --git a/packages/server/src/server/hub/test-utils/relationship-harness.ts b/packages/server/src/server/hub/test-utils/relationship-harness.ts new file mode 100644 index 000000000..6603bff87 --- /dev/null +++ b/packages/server/src/server/hub/test-utils/relationship-harness.ts @@ -0,0 +1,1409 @@ +import { execFile } from "node:child_process"; +import { execFileSync } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { existsSync, readFileSync, realpathSync, statSync, watch } from "node:fs"; +import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { networkInterfaces, platform, tmpdir } from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { Writable } from "node:stream"; +import pino from "pino"; +import { WebSocket } from "ws"; +import type { + AgentSnapshotPayload, + HubExecutionAgentCreateResponse, + HubExecutionAgentStream, + HubExecutionAgentUpdate, + RpcErrorMessage, + CreateAgentWorktreeTarget, + SessionOutboundMessage, +} from "../../messages.js"; +import { createPaseoDaemon, type PaseoDaemon, type PaseoDaemonConfig } from "../../bootstrap.js"; +import type { WebSocketLike } from "../../websocket-server.js"; +import type { + AgentClient, + AgentCreateSessionOptions, + AgentLaunchContext, + AgentPromptInput, + AgentPersistenceHandle, + AgentSession, + AgentSessionConfig, + FetchCatalogOptions, + ProviderCatalog, +} from "../../agent/agent-sdk-types.js"; +import { createTestAgentClients } from "../../test-utils/fake-agent-client.js"; +import { DaemonClient } from "../../test-utils/daemon-client.js"; +import { AgentStorage } from "../../agent/agent-storage.js"; +import { AgentManager } from "../../agent/agent-manager.js"; +import { DaemonExecutions } from "../daemon-executions.js"; +import { + createAgentCommand, + type CreateAgentCommandDependencies, +} from "../../agent/create-agent/create.js"; +import type { + HubRelationshipClock, + HubRelationshipRetryPolicy, + ScheduledRelationshipTask, +} from "../relationship-controller.js"; +import type { + HubEnrollment, + HubEnrollmentResult, + HubRelationshipRemote, + HubRevocation, + HubSocketConnection, + HubSocketCredentials, + HubSocketEvents, +} from "../relationship-remote.js"; +import { HubEnrollmentRejectedError } from "../relationship-remote.js"; + +const execFileAsync = promisify(execFile); +const HUB_ORIGIN = "https://hub.test"; +const SOCKET_URL = "wss://hub.test/daemon"; +const REPOSITORY_ROOT = path.resolve(import.meta.dirname, "../../../../../.."); + +export interface ArchiveWatcher { + close(): void; + onError(listener: (error: Error) => void): void; +} + +export interface ArchiveWatchFiles { + watchDirectory(path: string, onChange: () => void): ArchiveWatcher; +} + +const nodeArchiveWatchFiles: ArchiveWatchFiles = { + watchDirectory(directory, onChange) { + const watcher = watch(directory, onChange); + return { + close: () => watcher.close(), + onError: (listener) => watcher.on("error", listener), + }; + }, +}; + +export class SetupFailingArchiveWatchFiles implements ArchiveWatchFiles { + private attempts = 0; + private readonly openDirectories = new Set(); + + constructor(private readonly failOnAttempt: number) {} + + watchDirectory(directory: string): ArchiveWatcher { + this.attempts++; + if (this.attempts === this.failOnAttempt) { + throw new Error(`Cannot watch ${directory}`); + } + this.openDirectories.add(directory); + let open = true; + return { + close: () => { + if (!open) return; + open = false; + this.openDirectories.delete(directory); + }, + onError: () => {}, + }; + } + + activeDirectories(): string[] { + return [...this.openDirectories]; + } +} + +interface PersistedRelationship { + version: number; + state: string; + reason?: string; + relationship: { + daemonId: string; + idempotencyKey?: string; + hubOrigin: string; + scopes: string[]; + }; + credential?: { secret: string }; + enrollment?: { token: string }; + identity?: { serverId: string; daemonPublicKey: string }; +} + +export interface RelationshipInvocationSnapshot { + mode: number; + record: PersistedRelationship; +} + +interface Deferred { + promise: Promise; + resolve(value: T): void; + reject(error: Error): void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((accept, decline) => { + resolve = accept; + reject = decline; + }); + return { promise, resolve, reject }; +} + +class TestRelationshipClock implements HubRelationshipClock, HubRelationshipRetryPolicy { + private current = new Date("2026-07-13T00:00:00.000Z"); + private tasks: Array<{ cancelled: boolean; task: () => void }> = []; + + now(): Date { + return this.current; + } + + delay(attempt: number): number { + return Math.min(8_000, 1_000 * 2 ** attempt); + } + + schedule(_delayMs: number, task: () => void): ScheduledRelationshipTask { + const scheduled = { cancelled: false, task }; + this.tasks.push(scheduled); + return { cancel: () => (scheduled.cancelled = true) }; + } + + async runNext(): Promise { + while (true) { + const scheduled = this.tasks.shift(); + if (!scheduled) throw new Error("No relationship retry is scheduled"); + if (!scheduled.cancelled) { + scheduled.task(); + await Promise.resolve(); + return; + } + } + } + + pendingTasks(): number { + return this.tasks.filter((task) => !task.cancelled).length; + } +} + +class MemoryHubSocket extends EventEmitter implements WebSocketLike, HubSocketConnection { + readyState = 1; + bufferedAmount = 0; + sent: SessionOutboundMessage[] = []; + closed = false; + closeCode: number | null = null; + private messageObserved = deferred(); + + send(data: string | Uint8Array | ArrayBuffer): void { + if (typeof data !== "string") return; + const frame = JSON.parse(data) as { type: "session"; message: SessionOutboundMessage }; + this.sent.push(frame.message); + this.messageObserved.resolve(); + } + + close(code = 1000): void { + this.closed = true; + this.readyState = 3; + this.closeCode = code; + this.emit("close", code); + } + + receive(message: unknown): void { + this.emit("message", JSON.stringify({ type: "session", message }), false); + } + + receiveEnvelope(message: unknown): void { + this.emit("message", JSON.stringify(message), false); + } + + receiveBinary(): void { + this.emit("message", new Uint8Array([1, 2, 3]), true); + } + + async messageFor(requestId: string): Promise { + while (true) { + const message = this.sent.find( + (candidate) => + "payload" in candidate && + "requestId" in candidate.payload && + candidate.payload.requestId === requestId, + ); + if (message) return message; + await this.messageObserved.promise; + this.messageObserved = deferred(); + } + } + + async messageMatching( + predicate: (message: SessionOutboundMessage) => message is TMessage, + ): Promise { + while (true) { + const message = this.sent.find(predicate); + if (message) return message; + await this.messageObserved.promise; + this.messageObserved = deferred(); + } + } +} + +class ControlledAgentClient implements AgentClient { + readonly provider; + readonly capabilities; + private gate: Deferred | null = null; + private creationObserved = deferred(); + creations = 0; + resumes = 0; + createdConfigs: AgentSessionConfig[] = []; + + constructor(private readonly client: AgentClient) { + this.provider = client.provider; + this.capabilities = client.capabilities; + } + + holdCreation(): void { + this.gate = deferred(); + } + + async creationAt(count: number): Promise { + while (this.creations < count) { + await this.creationObserved.promise; + this.creationObserved = deferred(); + } + } + + finishCreation(): void { + if (!this.gate) throw new Error("Agent creation is not held"); + this.gate.resolve(); + this.gate = null; + } + + async createSession( + config: AgentSessionConfig, + launchContext?: AgentLaunchContext, + options?: AgentCreateSessionOptions, + ): Promise { + this.creations++; + this.createdConfigs.push({ ...config }); + this.creationObserved.resolve(); + await this.gate?.promise; + return this.client.createSession(config, launchContext, options); + } + + resumeSession( + handle: AgentPersistenceHandle, + overrides?: Partial, + launchContext?: AgentLaunchContext, + ): Promise { + this.resumes++; + return this.client.resumeSession(handle, overrides, launchContext); + } + + fetchCatalog(options: FetchCatalogOptions): Promise { + return this.client.fetchCatalog(options); + } + + isAvailable(): Promise { + return this.client.isAvailable(); + } +} + +interface SocketAttempt { + input: HubSocketCredentials; + events: HubSocketEvents; + socket: MemoryHubSocket; +} + +class InMemoryHubRelationships implements HubRelationshipRemote { + enrollments: HubEnrollment[] = []; + revocations: HubRevocation[] = []; + sockets: SocketAttempt[] = []; + private readonly enrollmentGates: Array> = []; + private readonly heldEnrollments: Array<{ + input: HubEnrollment; + gate: Deferred; + }> = []; + private enrollmentObserved = deferred(); + private socketObserved = deferred(); + private enrollmentRejection: 401 | 403 | null = null; + private enrollmentScopes = ["hub.execution.*"]; + private revokeFailures = 0; + private readonly relationships = new Set(); + readonly enrollmentSnapshots: RelationshipInvocationSnapshot[] = []; + readonly socketSnapshots: RelationshipInvocationSnapshot[] = []; + + constructor(private readonly captureRelationship: () => RelationshipInvocationSnapshot) {} + + holdEnrollment(): void { + this.enrollmentGates.push(deferred()); + } + + rejectNextEnrollment(statusCode: 401 | 403): void { + this.enrollmentRejection = statusCode; + } + + returnEnrollmentScopes(scopes: string[]): void { + this.enrollmentScopes = scopes.slice(); + } + + async enroll(input: HubEnrollment): Promise { + this.enrollmentSnapshots.push(this.captureRelationship()); + this.enrollments.push({ ...input, scopes: input.scopes.slice() }); + this.relationships.add(input.idempotencyKey); + this.enrollmentObserved.resolve(); + if (this.enrollmentRejection) { + const statusCode = this.enrollmentRejection; + this.enrollmentRejection = null; + throw new HubEnrollmentRejectedError(statusCode); + } + const gate = this.enrollmentGates.shift(); + if (gate) { + this.heldEnrollments.push({ input, gate }); + return gate.promise; + } + return this.enrollmentResult(input); + } + + completeEnrollment(): void { + const held = this.heldEnrollments.at(-1); + if (!held) throw new Error("No enrollment is waiting"); + held.gate.resolve(this.enrollmentResult(held.input)); + this.heldEnrollments.splice(this.heldEnrollments.indexOf(held), 1); + } + + loseEnrollmentResponse(): void { + const held = this.heldEnrollments.at(-1); + if (!held) throw new Error("No enrollment is waiting"); + held.gate.reject(new Error("Enrollment response was lost")); + this.heldEnrollments.splice(this.heldEnrollments.indexOf(held), 1); + } + + rejectEnrollment(index: number, statusCode: 401 | 403): void { + const held = this.heldEnrollments[index]; + if (!held) throw new Error(`Enrollment ${index} is not waiting`); + held.gate.reject(new HubEnrollmentRejectedError(statusCode)); + this.heldEnrollments.splice(index, 1); + } + + async enrollmentAt(index: number): Promise { + while (!this.enrollments[index]) { + await this.enrollmentObserved.promise; + this.enrollmentObserved = deferred(); + } + return this.enrollments[index]; + } + + failRevocations(count: number): void { + this.revokeFailures = count; + } + + async revoke(input: HubRevocation): Promise { + this.revocations.push({ ...input }); + if (this.revokeFailures > 0) { + this.revokeFailures--; + throw new Error("Hub is offline"); + } + } + + openSocket(input: HubSocketCredentials, events: HubSocketEvents): HubSocketConnection { + this.socketSnapshots.push(this.captureRelationship()); + const attempt = { input: { ...input }, events, socket: new MemoryHubSocket() }; + this.sockets.push(attempt); + this.socketObserved.resolve(); + return attempt.socket; + } + + async socketAt(index: number): Promise { + while (!this.sockets[index]) { + await this.socketObserved.promise; + this.socketObserved = deferred(); + } + return this.sockets[index]; + } + + relationshipCount(): number { + return this.relationships.size; + } + + private enrollmentResult(input: HubEnrollment): HubEnrollmentResult { + return { + daemonId: input.daemonId, + scopes: this.enrollmentScopes.slice(), + webSocketUrl: SOCKET_URL, + }; + } +} + +interface CliProcess { + result: Promise>; +} + +type AcceptedCreate = Omit< + HubExecutionAgentCreateResponse["payload"], + "agentId" | "agent" | "success" +> & { + agentId: string; + agent: AgentSnapshotPayload; + success: true; +}; + +const providerCatalog = { + async resolveCreateConfig(input) { + return { modeId: input.requestedMode, featureValues: input.featureValues }; + }, +} satisfies CreateAgentCommandDependencies["providerSnapshotManager"]; + +export class HubRelationshipHarness { + private readonly clock = new TestRelationshipClock(); + private readonly remote = new InMemoryHubRelationships(() => this.captureRelationship()); + private daemon: PaseoDaemon | null = null; + private config!: PaseoDaemonConfig; + private root = ""; + private paseoHome = ""; + private host = ""; + private readonly logs: string[] = []; + private readonly providerPrompts: AgentPromptInput[] = []; + private readonly cliProcesses = new Set>(); + private readonly claimedCliSockets = new Set(); + private readonly promptsToFail = new Set(); + private failNextSessionClose = false; + private observedEnrollments = 0; + private observedSockets = 0; + private readonly codex = new ControlledAgentClient( + createTestAgentClients({ + closeSession: async () => { + if (!this.failNextSessionClose) return; + this.failNextSessionClose = false; + throw new Error("Requested provider session close failure"); + }, + onStartTurn: (prompt) => { + const promptText = typeof prompt === "string" ? prompt : JSON.stringify(prompt); + if (this.promptsToFail.delete(promptText)) { + throw new Error("Requested provider prompt startup failure"); + } + this.providerPrompts.push(prompt); + }, + }).codex, + ); + + private constructor(private readonly archiveWatchFiles: ArchiveWatchFiles) {} + + static async start( + archiveWatchFiles: ArchiveWatchFiles = nodeArchiveWatchFiles, + ): Promise { + const harness = new HubRelationshipHarness(archiveWatchFiles); + await harness.createHome(); + await harness.startDaemon(); + return harness; + } + + holdEnrollment(): void { + this.remote.holdEnrollment(); + } + + beginConnect(token = "ceremony-token", hubUrl = HUB_ORIGIN): CliProcess { + return { result: this.runCli(["hub", "connect", hubUrl, "--token", token]) }; + } + + async status(): Promise> { + return this.runCli(["hub", "status"]); + } + + async disconnect(force = false): Promise> { + return this.runCli(["hub", "disconnect", ...(force ? ["--force"] : [])]); + } + + beginDisconnect(force = false): CliProcess { + return { result: this.runCli(["hub", "disconnect", ...(force ? ["--force"] : [])]) }; + } + + async relationshipStateBecomes(expected: string | null): Promise { + const observed = deferred(); + const watcher = watch(this.paseoHome, () => { + if ((this.relationshipFile()?.state ?? null) === expected) observed.resolve(); + }); + if ((this.relationshipFile()?.state ?? null) === expected) observed.resolve(); + try { + await observed.promise; + } finally { + watcher.close(); + } + } + + async manageRelationshipFromExternalSocket(): Promise { + const address = Object.values(networkInterfaces()) + .flat() + .find((candidate) => candidate?.family === "IPv4" && !candidate.internal)?.address; + if (!address) + throw new Error("No non-loopback IPv4 address is available for the external test"); + const target = this.daemon?.getListenTarget(); + if (!target || target.type !== "tcp") throw new Error("Daemon did not bind TCP"); + const socket = await this.openClaimedCliSocket(`ws://${address}:${target.port}/ws`); + const messages = [ + { + type: "hub.management.daemon.connect.request", + requestId: "external-hub-connect", + hubUrl: HUB_ORIGIN, + token: "external-token", + }, + { type: "hub.management.daemon.get_status.request", requestId: "external-hub-status" }, + { + type: "hub.management.daemon.disconnect.request", + requestId: "external-hub-disconnect", + force: false, + }, + ]; + const responses: SessionOutboundMessage[] = []; + for (const message of messages) { + socket.send(JSON.stringify({ type: "session", message })); + responses.push((await this.nextSocketEnvelope(socket)).message); + } + await this.closeClaimedCliSocket(socket); + return responses; + } + + async connectFromBrowserSocket(): Promise { + const target = this.daemon?.getListenTarget(); + if (!target || target.type !== "tcp") throw new Error("Daemon did not bind TCP"); + const socket = await this.openClaimedCliSocket(`ws://127.0.0.1:${target.port}/ws`, { + origin: `http://127.0.0.1:${target.port}`, + }); + socket.send( + JSON.stringify({ + type: "session", + message: { + type: "hub.management.daemon.connect.request", + requestId: "browser-hub-connect", + hubUrl: HUB_ORIGIN, + token: "browser-token", + }, + }), + ); + const response = (await this.nextSocketEnvelope(socket)).message; + await this.closeClaimedCliSocket(socket); + return response; + } + + async enrollmentBegins(): Promise { + return this.remote.enrollmentAt(this.observedEnrollments++); + } + + completeEnrollment(): void { + this.remote.completeEnrollment(); + } + + returnEnrollmentScopes(scopes: string[]): void { + this.remote.returnEnrollmentScopes(scopes); + } + + loseEnrollmentResponse(): void { + this.remote.loseEnrollmentResponse(); + } + + rejectEnrollment(index: number, statusCode: 401 | 403): void { + this.remote.rejectEnrollment(index, statusCode); + } + + pendingRelationshipRetries(): number { + return this.clock.pendingTasks(); + } + + rejectNextEnrollment(statusCode: 401 | 403): void { + this.remote.rejectNextEnrollment(statusCode); + } + + failRevocations(count: number): void { + this.remote.failRevocations(count); + } + + failProviderPromptStart(prompt = "Create through the Hub"): void { + this.promptsToFail.add(prompt); + } + + failNextProviderSessionClose(): void { + this.failNextSessionClose = true; + } + + async socketDialed(): Promise { + await this.remote.socketAt(this.observedSockets++); + } + + connectLatestSocket(): void { + const socket = this.latestSocket(); + socket.events.connected(socket.socket); + } + + rejectLatestSocket(statusCode: 401 | 403): void { + this.latestSocket().events.rejected(statusCode); + } + + rejectRelationship(code: 4403 | 401 | 403): void { + if (code === 4403) { + this.closeLatestSocket(code); + return; + } + this.rejectLatestSocket(code); + } + + closeLatestSocket(code: number): void { + const socket = this.latestSocket(); + socket.events.closed(code); + socket.socket.close(code); + } + + connectSocket(index: number): void { + const socket = this.remote.sockets[index]; + if (!socket) throw new Error(`Socket ${index} does not exist`); + socket.events.connected(socket.socket); + } + + closeSocket(index: number, code: number): void { + const socket = this.remote.sockets[index]; + if (!socket) throw new Error(`Socket ${index} does not exist`); + socket.events.closed(code); + socket.socket.close(code); + } + + sendHubRequestOnLatest(message: unknown): SessionOutboundMessage[] { + const socket = this.latestSocket().socket; + socket.receive(message); + return socket.sent.slice(); + } + + holdAgentCreation(): void { + this.codex.holdCreation(); + } + + beginOwnedCreate( + requestId: string, + executionId = "execution-race", + options: { + worktree?: CreateAgentWorktreeTarget; + autoArchive?: boolean; + prompt?: string; + modeId?: string; + } = {}, + ): void { + const { prompt = "Create through the Hub", ...requestOptions } = options; + this.latestSocket().socket.receive({ + type: "hub.execution.agent.create.request", + requestId, + executionId, + provider: "codex", + cwd: this.root, + workspaceId: "hub-workspace", + prompt, + ...requestOptions, + }); + } + + async agentCreationAttempts(count: number): Promise { + await this.codex.creationAt(count); + } + + finishAgentCreation(): void { + this.codex.finishCreation(); + } + + async ownedCreateResult(requestId: string): Promise { + return this.latestSocket().socket.messageFor(requestId); + } + + async durableOwnedAgentIds(): Promise { + return (await this.daemon!.agentStorage.list()) + .filter((record) => record.owner?.kind === "daemon") + .map((record) => record.id); + } + + async durableOwnedAgentIdsOnDisk(): Promise { + const storage = new AgentStorage( + path.join(this.paseoHome, "agents"), + pino({ level: "silent" }), + ); + return (await storage.list()) + .filter((record) => record.owner?.kind === "daemon") + .map((record) => record.id); + } + + activeOwnedAgentIds(): string[] { + return this.daemon!.agentManager.listAgents() + .filter((agent) => agent.owner?.kind === "daemon") + .map((agent) => agent.id); + } + + agentSubscriptionCount(): number { + return this.daemon!.agentManager.subscriptionCount(); + } + + async hubExecutionIntentFiles(): Promise { + const directory = path.join(this.paseoHome, "hub-executions"); + return existsSync(directory) ? readdir(directory) : []; + } + + socketDeliveredResponse(socketIndex: number, requestId: string): boolean { + const socket = this.remote.sockets[socketIndex]; + if (!socket) throw new Error(`Socket ${socketIndex} does not exist`); + return socket.socket.sent.some( + (message) => + "payload" in message && + "requestId" in message.payload && + message.payload.requestId === requestId, + ); + } + + providerCreations(): number { + return this.codex.creations; + } + + providerResumes(): number { + return this.codex.resumes; + } + + providerPromptTexts(): string[] { + return this.providerPrompts.map((prompt) => + typeof prompt === "string" ? prompt : JSON.stringify(prompt), + ); + } + + latestCreatedCwd(): string | null { + return this.codex.createdConfigs.at(-1)?.cwd ?? null; + } + + repoRoot(): string { + return this.root; + } + + async waitForOwnedArchiveCompletion( + agentId: string, + ): Promise<{ agentArchivedAt: string; workspaceArchivedAt: string }> { + const created = await this.daemon!.agentStorage.get(agentId); + if (!created) throw new Error(`Owned agent ${agentId} does not exist`); + if (!created.workspaceId) throw new Error(`Owned agent ${agentId} has no workspace`); + return new Promise<{ agentArchivedAt: string; workspaceArchivedAt: string }>( + (resolve, reject) => { + const watchers: ArchiveWatcher[] = []; + let settled = false; + const timeout = setTimeout( + () => finish(new Error(`Timed out waiting for owned agent ${agentId} to archive`)), + 15_000, + ); + timeout.unref?.(); + const closeWatchers = () => { + clearTimeout(timeout); + for (const watcher of watchers) watcher.close(); + }; + const finish = ( + result: Error | { agentArchivedAt: string; workspaceArchivedAt: string }, + ) => { + if (settled) return; + settled = true; + closeWatchers(); + if (result instanceof Error) reject(result); + else resolve(result); + }; + const observeCompletion = async () => { + try { + const archived = await this.daemon!.agentStorage.get(agentId); + const workspaceArchivedAt = this.workspaceArchivedAt(created.workspaceId!); + if (!archived?.archivedAt || !workspaceArchivedAt || existsSync(created.cwd)) return; + finish({ agentArchivedAt: archived.archivedAt, workspaceArchivedAt }); + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))); + } + }; + try { + const projectsWatcher = this.archiveWatchFiles.watchDirectory( + path.join(this.paseoHome, "projects"), + observeCompletion, + ); + watchers.push(projectsWatcher); + projectsWatcher.onError(finish); + const worktreeWatcher = this.archiveWatchFiles.watchDirectory( + path.dirname(created.cwd), + observeCompletion, + ); + watchers.push(worktreeWatcher); + worktreeWatcher.onError(finish); + void observeCompletion(); + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))); + } + }, + ); + } + + private workspaceArchivedAt(workspaceId: string): string | null { + const records = JSON.parse( + readFileSync(path.join(this.paseoHome, "projects", "workspaces.json"), "utf8"), + ) as Array<{ workspaceId: string; archivedAt?: string | null }>; + return records.find((workspace) => workspace.workspaceId === workspaceId)?.archivedAt ?? null; + } + + async worktreeState(worktreePath: string): Promise<{ exists: boolean; listed: boolean }> { + const { stdout } = await execFileAsync("git", [ + "-C", + this.root, + "worktree", + "list", + "--porcelain", + ]); + const listedPaths = stdout + .split("\n") + .filter((line) => line.startsWith("worktree ")) + .map((line) => this.comparablePath(line.slice("worktree ".length))); + return { + exists: existsSync(worktreePath), + listed: listedPaths.includes(this.comparablePath(worktreePath)), + }; + } + + async createBranch(branch: string): Promise { + await execFileAsync("git", ["-C", this.root, "branch", branch]); + } + + async currentBranch(cwd: string): Promise { + const { stdout } = await execFileAsync("git", ["-C", cwd, "branch", "--show-current"]); + return stdout.trim(); + } + + async ownedPermissionRequest(agentId: string) { + const message = await this.latestSocket().socket.messageMatching( + (candidate): candidate is HubExecutionAgentStream => + candidate.type === "hub.execution.agent.stream" && + candidate.payload.agentId === agentId && + candidate.payload.event.type === "permission_requested", + ); + if (message.payload.event.type !== "permission_requested") { + throw new Error(`Owned agent ${agentId} did not request permission`); + } + return message.payload.event.request; + } + + async allowOwnedPermission(agentId: string, requestId: string): Promise { + await this.daemon!.agentManager.respondToPermission(agentId, requestId, { behavior: "allow" }); + } + + async listedWorktrees(): Promise { + const { stdout } = await execFileAsync("git", [ + "-C", + this.root, + "worktree", + "list", + "--porcelain", + ]); + return stdout + .split("\n") + .filter((line) => line.startsWith("worktree ")) + .map((line) => line.slice("worktree ".length)); + } + + async createOwnedConcurrently(executionId = "execution-1"): Promise<{ + first: AcceptedCreate; + duplicate: AcceptedCreate; + }> { + this.beginOwnedCreate("create-1", executionId); + this.beginOwnedCreate("create-2", executionId); + const first = await this.acceptedCreate("create-1"); + const duplicate = await this.acceptedCreate("create-2"); + return { first, duplicate }; + } + + async ownedUpdate(agentId: string): Promise { + const message = await this.latestSocket().socket.messageMatching( + (candidate): candidate is HubExecutionAgentUpdate => + candidate.type === "hub.execution.agent.update" && candidate.payload.agentId === agentId, + ); + return message.payload; + } + + async ownedRunningUpdate(agentId: string): Promise { + const message = await this.latestSocket().socket.messageMatching( + (candidate): candidate is HubExecutionAgentUpdate => + candidate.type === "hub.execution.agent.update" && + candidate.payload.agentId === agentId && + candidate.payload.agent.status === "running", + ); + return message.payload; + } + + async ownedTurnCompletion(agentId: string): Promise { + const socket = this.latestSocket().socket; + const completed = await socket.messageMatching( + (candidate): candidate is HubExecutionAgentStream => + candidate.type === "hub.execution.agent.stream" && + candidate.payload.agentId === agentId && + candidate.payload.event.type === "turn_completed", + ); + return completed.payload; + } + + async ownedTurnFailure(agentId: string): Promise { + const failed = await this.latestSocket().socket.messageMatching( + (candidate): candidate is HubExecutionAgentStream => + candidate.type === "hub.execution.agent.stream" && + candidate.payload.agentId === agentId && + candidate.payload.event.type === "turn_failed", + ); + return failed.payload; + } + + latestOwnedTurnCompletions(agentId: string): number { + return this.latestSocket().socket.sent.filter( + (message) => + message.type === "hub.execution.agent.stream" && + message.payload.agentId === agentId && + message.payload.event.type === "turn_completed", + ).length; + } + + async ownedStream(agentId: string): Promise { + const message = await this.latestSocket().socket.messageMatching( + (candidate): candidate is HubExecutionAgentStream => + candidate.type === "hub.execution.agent.stream" && candidate.payload.agentId === agentId, + ); + return message.payload; + } + + async createUnrelatedLocalAgent(): Promise { + const agent = await this.daemon!.agentManager.createAgent( + { provider: "codex", cwd: this.root }, + undefined, + { workspaceId: "local-workspace" }, + ); + return agent.id; + } + + async deniedSteering(agentId: string): Promise { + const requestId = "denied-steer"; + this.latestSocket().socket.receive({ + type: "send_agent_message_request", + requestId, + agentId, + text: "This must not run", + }); + return ((await this.latestSocket().socket.messageFor(requestId)) as RpcErrorMessage).payload; + } + + async deniedBrowserDispatch(): Promise { + const requestId = "browser-1"; + this.latestSocket().socket.receive({ + type: "browser.automation.execute.response", + payload: { + requestId, + ok: false, + error: { code: "browser_denied", message: "denied", retryable: false }, + }, + }); + return ((await this.latestSocket().socket.messageFor(requestId)) as RpcErrorMessage).payload; + } + + observedAgentIds(): string[] { + return this.remote.sockets.flatMap(({ socket }) => + socket.sent.flatMap((message) => { + if (message.type === "hub.execution.agent.update") return [message.payload.agentId]; + if (message.type === "hub.execution.agent.stream") return [message.payload.agentId]; + return []; + }), + ); + } + + observedTrustedLifecycleMessages(): string[] { + return this.remote.sockets.flatMap(({ socket }) => + socket.sent + .filter((message) => message.type === "status") + .map((message) => message.payload.status), + ); + } + + probeTrustedHello(): number | null { + const socket = this.latestSocket().socket; + socket.receiveEnvelope({ + type: "hello", + clientId: "hub-must-not-resume", + clientType: "browser", + protocolVersion: 1, + capabilities: { voice: true, pushNotifications: true }, + }); + return socket.closeCode; + } + + probeBinaryFrame(): number | null { + const socket = this.latestSocket().socket; + socket.receiveBinary(); + return socket.closeCode; + } + + async trustedBroadcastCount(): Promise { + const before = this.daemonConfigBroadcasts(); + const client = await this.trustedClient(); + try { + await client.patchDaemonConfig({ appendSystemPrompt: "hub-broadcast-isolation" }); + this.beginOwnedCreate("broadcast-barrier", "broadcast-barrier"); + await this.acceptedCreate("broadcast-barrier"); + return this.daemonConfigBroadcasts() - before; + } finally { + await client.close(); + } + } + + async trustedDaemonStatus() { + const client = await this.trustedClient(); + try { + return await client.getDaemonStatus(); + } finally { + await client.close(); + } + } + + async reconnectAndRetry(executionId = "execution-1") { + this.closeLatestSocket(1006); + await this.retry(); + this.connectLatestSocket(); + this.beginOwnedCreate("reconnect-retry", executionId); + return this.acceptedCreate("reconnect-retry"); + } + + async reconstructAndReplay(executionId = "execution-1") { + const storage = new AgentStorage( + path.join(this.paseoHome, "agents"), + pino({ level: "silent" }), + ); + const manager = new AgentManager({ + clients: createTestAgentClients(), + registry: storage, + logger: pino({ level: "silent" }), + }); + const executions = this.executionsForReconstruction(manager, storage); + const replay = await executions.create(this.ownedCreateInput(executionId)); + const durableAgentCount = (await storage.list()).filter( + (record) => record.owner?.kind === "daemon", + ).length; + return { replay, durableAgentCount }; + } + + async removeOwnedAgent(agentId: string) { + await this.daemon!.agentStorage.remove(agentId); + const storage = new AgentStorage( + path.join(this.paseoHome, "agents"), + pino({ level: "silent" }), + ); + return { + durableAgentCount: (await storage.list()).filter((record) => record.owner?.kind === "daemon") + .length, + }; + } + + socketAttempts(): number { + return this.remote.sockets.length; + } + + revocationAttempts(): number { + return this.remote.revocations.length; + } + + latestRevocation(): HubRevocation | null { + return this.remote.revocations.at(-1) ?? null; + } + + enrollmentAttempts(): HubEnrollment[] { + return this.remote.enrollments.map((input) => ({ ...input, scopes: input.scopes.slice() })); + } + + enrollmentInvocation(index = 0): RelationshipInvocationSnapshot { + const snapshot = this.remote.enrollmentSnapshots[index]; + if (!snapshot) throw new Error(`Enrollment invocation ${index} does not exist`); + return snapshot; + } + + socketInvocation(index = 0): RelationshipInvocationSnapshot { + const snapshot = this.remote.socketSnapshots[index]; + if (!snapshot) throw new Error(`Socket invocation ${index} does not exist`); + return snapshot; + } + + enrolledRelationships(): number { + return this.remote.relationshipCount(); + } + + loggableValues(status: Record): string { + return JSON.stringify({ status, logs: this.logs }); + } + + relationshipFile(): PersistedRelationship | null { + const file = path.join(this.paseoHome, "hub-relationship.json"); + if (!existsSync(file)) return null; + return JSON.parse(readFileSync(file, "utf8")) as PersistedRelationship; + } + + relationshipFileMode(): number { + return statSync(path.join(this.paseoHome, "hub-relationship.json")).mode & 0o777; + } + + async corruptRelationshipFile(contents = "{not-json"): Promise { + await this.stopDaemon(); + await writeFile(path.join(this.paseoHome, "hub-relationship.json"), contents, "utf8"); + } + + async quarantinedRelationshipFiles(): Promise { + return (await readdir(this.paseoHome)).filter((file) => + file.startsWith("hub-relationship.invalid-"), + ); + } + + async startStoppedDaemon(): Promise { + await this.startDaemon(); + } + + async storedOwnedStatus(agentId: string): Promise { + return (await this.daemon!.agentStorage.get(agentId))?.lastStatus ?? null; + } + + private captureRelationship(): RelationshipInvocationSnapshot { + const record = this.relationshipFile(); + if (!record) throw new Error("Relationship authority was not persisted before invocation"); + return { mode: this.relationshipFileMode(), record }; + } + + async retry(): Promise { + await this.clock.runNext(); + } + + async restartDaemon(): Promise { + await this.stopDaemon(); + await this.startDaemon(); + } + + async shutdownDaemon(): Promise { + await this.stopDaemon(); + } + + async close(): Promise { + await this.stopDaemon(); + await Promise.allSettled(this.cliProcesses); + await Promise.all( + [...this.claimedCliSockets].map((socket) => this.closeClaimedCliSocket(socket)), + ); + await this.removeRoot(); + } + + private async createHome(): Promise { + this.root = await mkdtemp(path.join(tmpdir(), "paseo-hub-relationship-")); + this.paseoHome = path.join(this.root, ".paseo"); + const staticDir = path.join(this.root, "static"); + await Promise.all([mkdir(this.paseoHome, { recursive: true }), mkdir(staticDir)]); + execFileSync("git", ["init", "-b", "main", this.root], { stdio: "ignore" }); + execFileSync("git", ["-C", this.root, "config", "user.email", "hub@test.invalid"]); + execFileSync("git", ["-C", this.root, "config", "user.name", "Hub Test"]); + execFileSync("git", ["-C", this.root, "commit", "--allow-empty", "-m", "initial"], { + stdio: "ignore", + }); + this.config = { + listen: "0.0.0.0:0", + paseoHome: this.paseoHome, + corsAllowedOrigins: [], + hostnames: true, + mcpEnabled: false, + staticDir, + mcpDebug: false, + agentClients: { + ...createTestAgentClients(), + codex: this.codex, + }, + agentStoragePath: path.join(this.paseoHome, "agents"), + relayEnabled: false, + relayEndpoint: "relay.paseo.sh:443", + appBaseUrl: "https://app.paseo.sh", + }; + } + + private async startDaemon(): Promise { + const destination = new Writable({ + write: (chunk, _encoding, done) => { + this.logs.push(String(chunk)); + done(); + }, + }); + this.daemon = await createPaseoDaemon(this.config, pino({ level: "trace" }, destination), { + hubRelationshipRemote: this.remote, + hubRelationshipClock: this.clock, + hubRelationshipRetryPolicy: this.clock, + createHubDaemonId: () => "daemon-test", + }); + await this.daemon.start(); + const target = this.daemon.getListenTarget(); + if (!target || target.type !== "tcp") throw new Error("Daemon did not bind TCP"); + this.host = `127.0.0.1:${target.port}`; + } + + private async stopDaemon(): Promise { + await this.daemon?.stop(); + this.daemon = null; + } + + private runCli(args: string[]): Promise> { + return this.trackCli(this.executeCli(args)); + } + + private async executeCli(args: string[]): Promise> { + const entrypoint = path.join(import.meta.dirname, "../../test-utils/hub-cli-entry.ts"); + const { stdout } = await execFileAsync( + process.execPath, + [ + "--conditions=source", + "--import", + "tsx", + entrypoint, + ...args, + "--host", + this.host, + "--json", + ], + { cwd: REPOSITORY_ROOT, env: { ...process.env, NO_COLOR: "1" } }, + ); + const parsed = JSON.parse(stdout) as unknown; + if (Array.isArray(parsed)) return parsed[0] as Record; + return parsed as Record; + } + + private trackCli(process: Promise): Promise { + this.cliProcesses.add(process); + void process.then( + () => this.cliProcesses.delete(process), + () => this.cliProcesses.delete(process), + ); + return process; + } + + private nextSocketEnvelope(socket: WebSocket): Promise<{ message: SessionOutboundMessage }> { + return new Promise((resolve, reject) => { + socket.once("message", (data) => { + try { + resolve(JSON.parse(data.toString()) as { message: SessionOutboundMessage }); + } catch (error) { + reject(error); + } + }); + socket.once("error", reject); + }); + } + + private async openClaimedCliSocket( + url: string, + options: { origin?: string } = {}, + ): Promise { + const socket = new WebSocket(url, options); + this.claimedCliSockets.add(socket); + await new Promise((resolve, reject) => { + socket.once("open", resolve); + socket.once("error", reject); + }); + socket.send( + JSON.stringify({ + type: "hello", + clientId: "external-claimed-cli", + clientType: "cli", + protocolVersion: 1, + }), + ); + await this.nextSocketEnvelope(socket); + return socket; + } + + private async closeClaimedCliSocket(socket: WebSocket): Promise { + this.claimedCliSockets.delete(socket); + if (socket.readyState === WebSocket.CLOSED) return; + const closed = new Promise((resolve) => socket.once("close", () => resolve())); + socket.terminate(); + await closed; + } + + private comparablePath(value: string): string { + const resolved = path.resolve(value); + let canonical = resolved; + try { + canonical = realpathSync.native(resolved); + } catch { + // An archived worktree no longer exists, so compare its normalized target path. + } + const normalized = path.normalize(canonical); + return platform() === "win32" ? normalized.toLowerCase() : normalized; + } + + private async removeRoot(): Promise { + let retryableCode: string | null = null; + if (platform() === "win32") retryableCode = "EBUSY"; + if (platform() === "darwin") retryableCode = "ENOTEMPTY"; + const attempts = retryableCode ? 10 : 1; + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + await rm(this.root, { recursive: true, force: true }); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== retryableCode || attempt === attempts) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 100 * attempt)); + } + } + } + + private latestSocket(): SocketAttempt { + const socket = this.remote.sockets.at(-1); + if (!socket) throw new Error("No Hub socket has been opened"); + return socket; + } + + private async acceptedCreate(requestId: string): Promise { + const message = (await this.latestSocket().socket.messageFor( + requestId, + )) as HubExecutionAgentCreateResponse; + if (!message.payload.success || !message.payload.agentId || !message.payload.agent) { + throw new Error(message.payload.error ?? "Hub agent creation failed"); + } + return { + ...message.payload, + success: true, + agentId: message.payload.agentId, + agent: message.payload.agent, + }; + } + + private ownedCreateInput(executionId: string) { + return { + executionId, + provider: "codex", + cwd: this.root, + workspaceId: "hub-workspace", + prompt: "Create through the Hub", + }; + } + + private executionsForReconstruction(manager: AgentManager, storage: AgentStorage) { + return new DaemonExecutions({ + daemonId: this.relationshipFile()!.relationship.daemonId, + agentManager: manager, + agentStorage: storage, + createAgent: (input) => + createAgentCommand( + { + agentManager: manager, + agentStorage: storage, + logger: pino({ level: "silent" }), + providerSnapshotManager: providerCatalog, + }, + input, + ), + }); + } + + private async trustedClient(): Promise { + const client = new DaemonClient({ + url: `ws://${this.host}/ws`, + appVersion: "0.1.106", + }); + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "hub-relationship-trusted" } }); + return client; + } + + private daemonConfigBroadcasts(): number { + return this.remote.sockets + .flatMap(({ socket }) => socket.sent) + .filter( + (message) => + message.type === "status" && message.payload.status === "daemon_config_changed", + ).length; + } +} diff --git a/packages/server/src/server/logger.test.ts b/packages/server/src/server/logger.test.ts index 04a54b3ee..f7f42a7ef 100644 --- a/packages/server/src/server/logger.test.ts +++ b/packages/server/src/server/logger.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; +import { resolveDaemonVersion } from "./daemon-version.js"; import { resolveLogConfig } from "./logger.js"; import { loadConfig } from "./config.js"; import type { PersistedConfig } from "./persisted-config.js"; @@ -153,6 +154,24 @@ describe("loadConfig logger config", () => { }); describe("createRootLogger", () => { + it("includes the daemon version in child logger entries", async () => { + const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-logger-version-")); + + const { stdout } = await runLoggerFixture(` + const logger = createRootLogger(undefined, { paseoHome: ${JSON.stringify(paseoHome)} }); + logger.child({ name: "fixture" }).info("versioned child logger"); + logger.flush(); + `); + + expect(JSON.parse(stdout)).toMatchObject({ + pid: expect.any(Number), + hostname: expect.any(String), + daemonVersion: resolveDaemonVersion(), + name: "fixture", + msg: "versioned child logger", + }); + }); + it("writes JSON to stdout by default and does not initialize file logging", async () => { const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-logger-default-")); const missingLogDir = path.join(paseoHome, "logs"); diff --git a/packages/server/src/server/logger.ts b/packages/server/src/server/logger.ts index 5c1c0522b..118ce9bbe 100644 --- a/packages/server/src/server/logger.ts +++ b/packages/server/src/server/logger.ts @@ -2,6 +2,7 @@ import { mkdirSync } from "node:fs"; import path from "node:path"; import pino from "pino"; import pretty from "pino-pretty"; +import { resolveDaemonVersion } from "./daemon-version.js"; import type { PersistedConfig } from "./persisted-config.js"; import { resolvePaseoHome } from "./paseo-home.js"; @@ -180,13 +181,15 @@ export function createRootLogger( }) : pino.destination({ dest: config.file?.path ?? 1, sync: false }); - return pino( + const logger = pino( { level: config.file?.level ?? config.console.level, redact: { paths: REDACT_PATHS, remove: true }, }, stream, ); + + return logger.child({ daemonVersion: resolveDaemonVersion() }); } export function createChildLogger(parent: pino.Logger, name: string): pino.Logger { diff --git a/packages/server/src/server/loop-service.test.ts b/packages/server/src/server/loop-service.test.ts index 37280535e..917a04f1e 100644 --- a/packages/server/src/server/loop-service.test.ts +++ b/packages/server/src/server/loop-service.test.ts @@ -32,7 +32,7 @@ import { AgentStorage } from "./agent/agent-storage.js"; import { AgentManager } from "./agent/agent-manager.js"; import { createAgentCommand } from "./agent/create-agent/create.js"; import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; -import { createLocalCheckoutWorkspace } from "./paseo-worktree-service.js"; +import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js"; import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js"; import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js"; import { LoopService } from "./loop-service.js"; @@ -108,12 +108,17 @@ async function createRegistryBackedWorkspaceEnsure(rootDir: string): Promise<{ await workspaceRegistry.initialize(); await projectRegistry.initialize(); const workspaceGitService = createNoopWorkspaceGitService(); + const workspaceProvisioning = createWorkspaceProvisioningService({ + projectRegistry, + workspaceRegistry, + workspaceGitService, + }); return { workspaceRegistry, ensureWorkspaceForCreate: async (cwd, firstAgentContext) => { - const workspace = await createLocalCheckoutWorkspace( - { cwd, title: firstAgentContext?.prompt ?? null }, - { projectRegistry, workspaceRegistry, workspaceGitService }, + const workspace = await workspaceProvisioning.createWorkspaceForDirectory( + cwd, + firstAgentContext?.prompt ?? null, ); return workspace.workspaceId; }, diff --git a/packages/server/src/server/loop-service.ts b/packages/server/src/server/loop-service.ts index 99e5f8ff3..43474796c 100644 --- a/packages/server/src/server/loop-service.ts +++ b/packages/server/src/server/loop-service.ts @@ -241,6 +241,7 @@ type LoopAgentManager = Pick< | "archiveAgent" | "cancelAgentRun" | "closeAgent" + | "deleteAgentState" | "getAgent" | "runAgent" | "subscribe" @@ -570,7 +571,7 @@ export class LoopService { await this.options.agentManager.archiveAgent(agentId); return; } - await this.options.agentManager.closeAgent(agentId); + await this.closeInternalAgent(agentId); } catch (error) { if (!isUnknownLoopAgentError(error, agentId)) { throw error; @@ -578,6 +579,14 @@ export class LoopService { } } + private async closeInternalAgent(agentId: string): Promise { + try { + await this.options.agentManager.closeAgent(agentId); + } finally { + await this.options.agentManager.deleteAgentState(agentId); + } + } + private async executeLoop(loopId: string, signal: AbortSignal): Promise { const loop = this.requireLoop(loopId); const deadline = loop.maxTimeMs ? Date.now() + loop.maxTimeMs : null; @@ -775,7 +784,7 @@ export class LoopService { if (loop.archive) { await this.options.agentManager.archiveAgent(agent.id); } else { - await this.options.agentManager.closeAgent(agent.id); + await this.closeInternalAgent(agent.id); } } catch { // Ignore cleanup errors for internal loop workers. @@ -917,7 +926,7 @@ export class LoopService { if (loop.archive) { await this.options.agentManager.archiveAgent(verifierAgent.id); } else { - await this.options.agentManager.closeAgent(verifierAgent.id); + await this.closeInternalAgent(verifierAgent.id); } } catch { // Ignore cleanup errors for internal loop verifiers. diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 527e4e7d1..13bb5ec79 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, expect, test, vi } from "vitest"; @@ -10,17 +10,19 @@ import type { PersistedProjectRecord, PersistedWorkspaceRecord, ProjectRegistry, + WorkspaceRegistry, } from "./workspace-registry.js"; +import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js"; +import { createTestLogger } from "../test-utils/test-logger.js"; import { attemptFirstAgentBranchAutoName, - createLocalCheckoutWorkspace, createPaseoWorktree, type CreatePaseoWorktreeDeps, } from "./paseo-worktree-service.js"; import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js"; -import { createWorktree } from "../utils/worktree.js"; +import { createWorktree, getPaseoWorktreesRoot } from "../utils/worktree.js"; import { isPlatform } from "../test-utils/platform.js"; -import { existsSync } from "node:fs"; +import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../utils/path.js"; const cleanupPaths: string[] = []; @@ -55,6 +57,7 @@ test("creates a worktree and registers it in the source workspace project withou { cwd: repoDir, worktreeSlug: "feature-one", + title: "Feature One", runSetup: false, paseoHome: path.join(tempDir, ".paseo"), }, @@ -68,11 +71,309 @@ test("creates a worktree and registers it in the source workspace project withou expect(result.workspace.projectId).toBe("remote:github.com/acme/repo"); expect(result.workspace.displayName).toBe("feature-one"); expect(result.workspace.baseBranch).toBe("main"); + expect(result.workspace.title).toBe("Feature One"); expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled(); - expect(events).toEqual([ - "project:remote:github.com/acme/repo", - `workspace:${result.workspace.workspaceId}`, - ]); + expect(deps.projects.get(sourceProject.projectId)).toEqual(sourceProject); + expect(events).toEqual([`workspace:${result.workspace.workspaceId}`]); +}); + +test("refreshes a source project that became Git while creating a worktree", async () => { + const { repoDir, tempDir } = createGitRepo(); + cleanupPaths.push(tempDir); + const deps = createDeps(); + const sourceProject = { + ...createPersistedProjectRecordForTest({ + projectId: "prj_existing-source", + rootPath: repoDir, + displayName: "Repository", + }), + kind: "non_git" as const, + customName: "My project", + }; + const sourceWorkspace = createPersistedWorkspaceRecordForTest({ + workspaceId: "ws-main-checkout", + projectId: sourceProject.projectId, + cwd: repoDir, + kind: "local_checkout", + displayName: "main", + }); + deps.projects.set(sourceProject.projectId, sourceProject); + deps.workspaces.set(sourceWorkspace.workspaceId, sourceWorkspace); + + const result = await createPaseoWorktree( + { + cwd: repoDir, + worktreeSlug: "project-became-git", + runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), + }, + deps, + ); + + expect(result.workspace.projectId).toBe(sourceProject.projectId); + expect(deps.projects.get(sourceProject.projectId)).toMatchObject({ + projectId: sourceProject.projectId, + rootPath: repoDir, + kind: "git", + displayName: "Repository", + customName: "My project", + archivedAt: null, + }); +}); + +test("repairs a legacy source workspace whose project record is missing", async () => { + const { repoDir, tempDir } = createGitRepo(); + cleanupPaths.push(tempDir); + const deps = createDeps(); + const sourceWorkspace = createPersistedWorkspaceRecordForTest({ + workspaceId: "ws-missing-project", + projectId: "project-missing", + cwd: repoDir, + kind: "local_checkout", + displayName: "main", + }); + deps.workspaces.set(sourceWorkspace.workspaceId, sourceWorkspace); + + const result = await createPaseoWorktree( + { + cwd: repoDir, + worktreeSlug: "repaired-source", + runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), + }, + deps, + ); + + expect(result.workspace.projectId).toMatch(/^prj_[0-9a-f]{16}$/); + expect(result.workspace.projectId).not.toBe(sourceWorkspace.projectId); + const repairedProject = deps.projects.get(result.workspace.projectId); + expect(repairedProject).toMatchObject({ + projectId: result.workspace.projectId, + kind: "git", + archivedAt: null, + }); + expect(createRealpathAwarePathMatcher(repoDir)(repairedProject?.rootPath ?? "")).toBe(true); +}); + +test("uses an equivalent source workspace path when creating a worktree", async () => { + const { repoDir, tempDir } = createGitRepo(); + cleanupPaths.push(tempDir); + const sourceDir = path.join(repoDir, "app"); + mkdirSync(sourceDir); + writeFileSync(path.join(repoDir, "app", ".gitkeep"), ""); + commitAll(repoDir, "add app"); + const deps = createDeps(); + const sourceProject = createPersistedProjectRecordForTest({ + projectId: "prj_source-folder", + rootPath: sourceDir, + displayName: "app", + }); + const sourceWorkspace = createPersistedWorkspaceRecordForTest({ + workspaceId: "ws-source-folder", + projectId: sourceProject.projectId, + cwd: `${sourceDir}${path.sep}`, + kind: "local_checkout", + displayName: "app", + }); + deps.projects.set(sourceProject.projectId, sourceProject); + deps.workspaces.set(sourceWorkspace.workspaceId, sourceWorkspace); + + const result = await createPaseoWorktree( + { + cwd: sourceDir, + worktreeSlug: "equivalent-source", + runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), + }, + deps, + ); + + expect(result.workspace.projectId).toBe(sourceProject.projectId); +}); + +test("creates a worktree workspace at the selected project subdirectory", async () => { + const { repoDir, tempDir } = createGitRepo(); + cleanupPaths.push(tempDir); + const sourceDir = path.join(repoDir, "packages", "app"); + mkdirSync(sourceDir, { recursive: true }); + writeFileSync(path.join(sourceDir, "package.json"), "{}\n"); + commitAll(repoDir, "add subproject"); + const deps = createDeps(); + const project = createPersistedProjectRecordForTest({ + projectId: "prj_selected-subdirectory", + rootPath: sourceDir, + displayName: "app", + }); + deps.projects.set(project.projectId, project); + + const result = await createPaseoWorktree( + { + cwd: sourceDir, + projectId: project.projectId, + worktreeSlug: "selected-subdirectory", + runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), + }, + deps, + ); + + expect(result.workspace).toMatchObject({ + projectId: project.projectId, + cwd: path.join(result.worktree.worktreePath, "packages", "app"), + worktreeRoot: result.worktree.worktreePath, + kind: "worktree", + }); +}); + +test("seeds an uncommitted exact-project config into the mapped worktree directory", async () => { + const { repoDir, tempDir } = createGitRepo(); + cleanupPaths.push(tempDir); + const sourceDir = path.join(repoDir, "packages", "app"); + mkdirSync(sourceDir, { recursive: true }); + writeFileSync(path.join(sourceDir, "package.json"), "{}\n"); + commitAll(repoDir, "add subproject"); + const config = JSON.stringify({ worktree: { setup: ["npm install"] } }); + writeFileSync(path.join(sourceDir, "paseo.json"), config); + + const result = await createPaseoWorktree( + { + cwd: sourceDir, + worktreeSlug: "seed-nested-config", + runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), + }, + createDeps(), + ); + + expect(readFileSync(path.join(result.workspace.cwd, "paseo.json"), "utf8")).toBe(config); + expect(existsSync(path.join(result.worktree.worktreePath, "paseo.json"))).toBe(false); +}); + +test("removes a new worktree when its ref does not contain the selected project directory", async () => { + const { repoDir, tempDir } = createGitRepo(); + cleanupPaths.push(tempDir); + execFileSync("git", ["branch", "without-subproject"], { cwd: repoDir, stdio: "pipe" }); + const sourceDir = path.join(repoDir, "packages", "app"); + mkdirSync(sourceDir, { recursive: true }); + writeFileSync(path.join(sourceDir, "package.json"), "{}\n"); + commitAll(repoDir, "add subproject"); + const deps = createDeps(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktreePath = path.join( + await getPaseoWorktreesRoot(repoDir, paseoHome), + "missing-subproject", + ); + + await expect( + createPaseoWorktree( + { + cwd: sourceDir, + action: "checkout", + refName: "without-subproject", + worktreeSlug: "missing-subproject", + runSetup: false, + paseoHome, + }, + deps, + ), + ).rejects.toThrow("Selected project directory is missing from the worktree"); + + expect(deps.workspaces.size).toBe(0); + expect(existsSync(worktreePath)).toBe(false); + expect( + execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: repoDir, stdio: "pipe" }) + .toString() + .includes("missing-subproject"), + ).toBe(false); +}); + +test("removes a new worktree when workspace persistence fails", async () => { + const { repoDir, tempDir } = createGitRepo(); + cleanupPaths.push(tempDir); + const paseoHome = path.join(tempDir, ".paseo"); + const worktreePath = path.join( + await getPaseoWorktreesRoot(repoDir, paseoHome), + "persistence-failure", + ); + + await expect( + createPaseoWorktree( + { + cwd: repoDir, + projectId: "missing-project", + worktreeSlug: "persistence-failure", + runSetup: false, + paseoHome, + }, + createDeps(), + ), + ).rejects.toThrow("Unknown project: missing-project"); + + expect(existsSync(worktreePath)).toBe(false); + expect( + execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: repoDir, stdio: "pipe" }) + .toString() + .includes("persistence-failure"), + ).toBe(false); +}); + +test("maps a nested cwd from an existing Paseo worktree into the next worktree", async () => { + const { repoDir, tempDir } = createGitRepo(); + cleanupPaths.push(tempDir); + const paseoHome = path.join(tempDir, ".paseo"); + const projectDir = path.join(repoDir, "packages", "app"); + mkdirSync(projectDir, { recursive: true }); + writeFileSync(path.join(projectDir, "package.json"), "{}\n"); + commitAll(repoDir, "add subproject"); + const deps = createDeps(); + const source = await createPaseoWorktree( + { + cwd: repoDir, + worktreeSlug: "source-worktree", + runSetup: false, + paseoHome, + }, + deps, + ); + const sourceCwd = path.join(source.worktree.worktreePath, "packages", "app"); + + const created = await createPaseoWorktree( + { + cwd: sourceCwd, + worktreeSlug: "nested-worktree", + runSetup: false, + paseoHome, + }, + deps, + ); + + expect(created.workspace.cwd).toBe(path.join(created.worktree.worktreePath, "packages", "app")); + expect(deps.workspaces.get(created.workspace.workspaceId)).toEqual(created.workspace); +}); + +test("rejects source checkout planning before creating a worktree", async () => { + const { repoDir, tempDir } = createGitRepo(); + cleanupPaths.push(tempDir); + const paseoHome = path.join(tempDir, ".paseo"); + const deps = createDeps(); + deps.workspaceGitService.getCheckout = async () => { + throw new Error("source checkout unavailable"); + }; + + await expect( + createPaseoWorktree( + { + cwd: repoDir, + worktreeSlug: "must-not-create", + runSetup: false, + paseoHome, + }, + deps, + ), + ).rejects.toThrow("source checkout unavailable"); + + expect(existsSync(path.join(paseoHome, "worktrees"))).toBe(false); + expect(Array.from(deps.workspaces.values())).toEqual([]); }); test("registers a new worktree in the existing root project after the main checkout workspace is removed", async () => { @@ -109,6 +410,35 @@ test("registers a new worktree in the existing root project after the main check expect(Array.from(deps.projects.keys()).sort()).toEqual(["remote:github.com/acme/repo"]); }); +test("an explicit project FK remains unchanged when its worktree comes from another checkout", async () => { + const { repoDir, tempDir } = createGitRepo(); + cleanupPaths.push(tempDir); + const deps = createDeps(); + const project = { + ...createPersistedProjectRecordForTest({ + projectId: "prj_explicitproject", + rootPath: path.join(tempDir, "unrelated"), + displayName: "unrelated", + }), + kind: "non_git" as const, + }; + deps.projects.set(project.projectId, project); + + const result = await createPaseoWorktree( + { + cwd: repoDir, + projectId: project.projectId, + worktreeSlug: "attached-worktree", + runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), + }, + deps, + ); + + expect(result.workspace.projectId).toBe(project.projectId); + expect(deps.projects.get(project.projectId)).toEqual(project); +}); + // POSIX-only: Windows git worktree paths need separate canonicalization coverage. test.skipIf(isPlatform("win32"))( "reuses an existing worktree and still upserts the workspace", @@ -152,19 +482,6 @@ test.skipIf(isPlatform("win32"))( }, ); -test("creates a distinct local checkout workspace for the same cwd on every call", async () => { - const { repoDir, tempDir } = createGitRepo(); - cleanupPaths.push(tempDir); - const deps = createDeps(); - - const first = await createLocalCheckoutWorkspace({ cwd: repoDir }, deps); - const second = await createLocalCheckoutWorkspace({ cwd: repoDir }, deps); - - expect(first.cwd).toBe(second.cwd); - expect(first.workspaceId).not.toBe(second.workspaceId); - expect(deps.workspaces.size).toBe(2); -}); - test("renames an eligible unnamed branch-off worktree once on first agent context", async () => { const { repoDir, tempDir } = createGitRepo(); cleanupPaths.push(tempDir); @@ -679,7 +996,6 @@ test.skipIf(isPlatform("win32"))( ); interface TestDeps extends CreatePaseoWorktreeDeps { - projectRegistry: Pick; projects: Map; workspaces: Map; } @@ -692,28 +1008,73 @@ function createDeps(options?: { const events = options?.events ?? []; const projects = options?.projects ?? new Map(); const workspaces = options?.workspaces ?? new Map(); + const projectRegistry: ProjectRegistry = { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => Array.from(projects.values()), + get: async (projectId) => projects.get(projectId) ?? null, + getOrCreateActiveByRoot: async (input) => { + const existing = Array.from(projects.values()).find( + (project) => !project.archivedAt && areEquivalentPaths(project.rootPath, input.rootPath), + ); + if (existing) return existing; + const project = createPersistedProjectRecordForTest({ + projectId: `prj_${projects.size.toString().padStart(16, "0")}`, + rootPath: input.rootPath, + displayName: input.displayName, + }); + projects.set(project.projectId, project); + return project; + }, + upsert: async (project) => { + projects.set(project.projectId, project); + }, + archive: async (projectId, archivedAt) => { + const project = projects.get(projectId); + if (project) projects.set(projectId, { ...project, archivedAt }); + }, + remove: async (projectId) => { + projects.delete(projectId); + }, + }; + const workspaceRegistry: WorkspaceRegistry = { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => Array.from(workspaces.values()), + get: async (workspaceId) => workspaces.get(workspaceId) ?? null, + update: async (workspaceId, updater) => { + const workspace = workspaces.get(workspaceId); + if (!workspace) return null; + const updated = updater(workspace); + workspaces.set(workspaceId, updated); + return updated; + }, + upsert: async (record) => { + events.push(`workspace:${record.workspaceId}`); + workspaces.set(record.workspaceId, record); + }, + archive: async (workspaceId, archivedAt) => { + const workspace = workspaces.get(workspaceId); + if (workspace) workspaces.set(workspaceId, { ...workspace, archivedAt }); + }, + remove: async (workspaceId) => { + workspaces.delete(workspaceId); + }, + }; + const workspaceGitService = createWorkspaceGitServiceStub(); + const workspaceProvisioning = createWorkspaceProvisioningService({ + projectRegistry, + workspaceRegistry, + workspaceGitService, + logger: createTestLogger(), + }); return { github: createGitHubServiceStub(), projects, workspaces, - projectRegistry: { - get: async (projectId) => projects.get(projectId) ?? null, - list: async () => Array.from(projects.values()), - upsert: async (record) => { - events.push(`project:${record.projectId}`); - projects.set(record.projectId, record); - }, - }, - workspaceRegistry: { - get: async (workspaceId) => workspaces.get(workspaceId) ?? null, - list: async () => Array.from(workspaces.values()), - upsert: async (record) => { - events.push(`workspace:${record.workspaceId}`); - workspaces.set(record.workspaceId, record); - }, - }, - workspaceGitService: createWorkspaceGitServiceStub(), + workspaceGitService, + workspaceProvisioning, }; } @@ -808,15 +1169,30 @@ function createWorkspaceGitServiceStub(): WorkspaceGitService { unsubscribe: () => {}, }), peekSnapshot: (cwd) => createWorkspaceGitSnapshot(cwd), - getCheckout: async (cwd) => ({ - cwd, - isGit: false, - currentBranch: null, - remoteUrl: null, - worktreeRoot: null, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }), + getCheckout: async (cwd) => { + try { + const snapshot = createWorkspaceGitSnapshot(cwd); + return { + cwd, + isGit: snapshot.git.isGit, + currentBranch: snapshot.git.currentBranch, + remoteUrl: snapshot.git.remoteUrl, + worktreeRoot: snapshot.git.repoRoot, + isPaseoOwnedWorktree: snapshot.git.isPaseoOwnedWorktree, + mainRepoRoot: snapshot.git.mainRepoRoot, + }; + } catch { + return { + cwd, + isGit: false, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }; + } + }, getSnapshot: async (cwd) => createWorkspaceGitSnapshot(cwd), resolveForge: async () => null, resolveRepoRoot: async (cwd) => { @@ -902,6 +1278,11 @@ function createGitRepo(): { tempDir: string; repoDir: string } { return { tempDir, repoDir }; } +function commitAll(repoDir: string, message: string): void { + execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", message], { cwd: repoDir, stdio: "pipe" }); +} + function createGitHubPrRemoteRepo(): { tempDir: string; repoDir: string } { const { tempDir, repoDir } = createGitRepo(); execFileSync("git", ["checkout", "-b", "pr-123"], { cwd: repoDir, stdio: "pipe" }); diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index debc91463..818d98753 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -1,23 +1,22 @@ -import type { WorkspaceGitService } from "./workspace-git-service.js"; +import { stat } from "node:fs/promises"; import { resolve } from "node:path"; -import { - type PersistedWorkspaceRecord, - type ProjectRegistry, - type WorkspaceRegistry, - createPersistedProjectRecord, - createPersistedWorkspaceRecord, -} from "./workspace-registry.js"; -import { - classifyDirectoryForProjectMembership, - deriveProjectGroupingName, - generateWorkspaceId, -} from "./workspace-registry-model.js"; + +import type { WorkspaceGitService } from "./workspace-git-service.js"; +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, } from "./worktree-core.js"; -import { validateBranchSlug, type WorktreeConfig } from "../utils/worktree.js"; +import { + mapWorkspaceRelativeCwdToWorktree, + rollbackCreatedPaseoWorktree, + seedPaseoConfigFile, + validateBranchSlug, + type WorktreeConfig, +} from "../utils/worktree.js"; import { getCurrentBranch, localBranchExists, renameCurrentBranch } from "../utils/checkout-git.js"; import { markPaseoWorktreeFirstAgentBranchAutoNameAttempted, @@ -32,6 +31,7 @@ import type { FirstAgentContext } from "@getpaseo/protocol/messages"; export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput { projectId?: string; + title?: string; } export interface CreatePaseoWorktreeResult { @@ -56,36 +56,89 @@ export interface AttemptFirstAgentBranchAutoNameResult { } export interface CreatePaseoWorktreeDeps extends CreateWorktreeCoreDeps { - projectRegistry: Pick; - workspaceRegistry: Pick; workspaceGitService: WorkspaceGitService; + workspaceProvisioning: Pick; } export async function createPaseoWorktree( input: CreatePaseoWorktreeInput, deps: CreatePaseoWorktreeDeps, ): Promise { + const workspaceCwdPlan = await planWorkspaceCwdForWorktree(input.cwd, deps.workspaceGitService); const createdWorktree = await createWorktreeCore(input, deps); - maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree }); - const workspace = await upsertWorkspaceForWorktree({ - inputCwd: input.cwd, - projectId: input.projectId, - repoRoot: createdWorktree.repoRoot, - worktree: createdWorktree.worktree, - baseBranch: resolveIntentBaseBranch(createdWorktree.intent), - title: resolveFirstAgentPromptTitle(input.firstAgentContext), - deps, - }); + try { + maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree }); + const workspaceCwd = mapWorkspaceRelativeCwdToWorktree({ + relativeWorkspaceCwd: workspaceCwdPlan.relativeWorkspaceCwd, + targetWorktreePath: createdWorktree.worktree.worktreePath, + }); + if (!(await isDirectory(workspaceCwd))) { + throw new Error(`Selected project directory is missing from the worktree: ${workspaceCwd}`); + } - deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath }); + if (createdWorktree.created) { + await seedPaseoConfigFile({ + sourceCwd: workspaceCwdPlan.inputCwd, + targetCwd: workspaceCwd, + }); + } + 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), + }); - return { - worktree: createdWorktree.worktree, - intent: createdWorktree.intent, - workspace, - repoRoot: createdWorktree.repoRoot, - created: createdWorktree.created, - }; + deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath }); + + return { + worktree: createdWorktree.worktree, + intent: createdWorktree.intent, + workspace, + repoRoot: createdWorktree.repoRoot, + created: createdWorktree.created, + }; + } 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, + ); + } +} + +async function isDirectory(targetPath: string): Promise { + try { + return (await stat(targetPath)).isDirectory(); + } catch { + return false; + } +} + +async function planWorkspaceCwdForWorktree( + inputCwd: string, + workspaceGitService: Pick, +): Promise<{ inputCwd: string; relativeWorkspaceCwd: string }> { + const normalizedInputCwd = resolve(inputCwd); + const sourceCheckout = await workspaceGitService.getCheckout(normalizedInputCwd); + const sourceWorktreePath = sourceCheckout.worktreeRoot ?? normalizedInputCwd; + const relativeWorkspaceCwd = getRealpathAwareRelativePath(sourceWorktreePath, normalizedInputCwd); + if (relativeWorkspaceCwd === null) { + throw new Error(`Workspace cwd is outside its source worktree: ${normalizedInputCwd}`); + } + return { inputCwd: normalizedInputCwd, relativeWorkspaceCwd }; } export async function attemptFirstAgentBranchAutoName(options: { @@ -213,257 +266,3 @@ function resolveIntentBaseBranch(intent: WorktreeCreationIntent): string | null return null; } } - -async function upsertWorkspaceForWorktree(options: { - inputCwd: string; - projectId?: string; - repoRoot: string; - worktree: WorktreeConfig; - baseBranch?: string | null; - title?: string | null; - deps: Pick< - CreatePaseoWorktreeDeps, - "projectRegistry" | "workspaceRegistry" | "workspaceGitService" - >; -}): Promise { - const normalizedCwd = resolve(options.worktree.worktreePath); - const normalizedInputCwd = resolve(options.inputCwd); - const normalizedRepoRoot = resolve(options.repoRoot); - // Creation never deduplicates by directory: a worktree directory may back - // more than one workspace. We still resolve the source project from the - // originating checkout, but always mint a fresh workspace record. - const sourceProject = await resolveSourceProjectForWorktree({ - inputCwd: normalizedInputCwd, - projectId: options.projectId, - repoRoot: normalizedRepoRoot, - existingWorkspace: null, - deps: options.deps, - }); - const workspaceId = generateWorkspaceId(); - const now = new Date().toISOString(); - - await options.deps.projectRegistry.upsert( - createPersistedProjectRecord({ - projectId: sourceProject.projectId, - rootPath: sourceProject.rootPath, - kind: sourceProject.kind, - displayName: sourceProject.displayName, - customName: sourceProject.customName, - createdAt: sourceProject.createdAt ?? now, - updatedAt: now, - archivedAt: null, - }), - ); - - const workspace = createPersistedWorkspaceRecord({ - workspaceId, - projectId: sourceProject.projectId, - cwd: normalizedCwd, - kind: "worktree", - displayName: options.worktree.branchName || normalizedCwd, - branch: options.worktree.branchName || null, - baseBranch: options.baseBranch ?? null, - title: options.title ?? null, - createdAt: now, - updatedAt: now, - archivedAt: null, - }); - - await options.deps.workspaceRegistry.upsert(workspace); - return (await options.deps.workspaceRegistry.get(workspace.workspaceId)) ?? workspace; -} - -export interface CreateLocalCheckoutWorkspaceDeps { - projectRegistry: Pick; - workspaceRegistry: Pick; - workspaceGitService: Pick; -} - -// Always create a NEW workspace record backed by the existing directory `cwd`. -// Never reuses a same-cwd record: a directory may back any number of -// workspaces. Used by explicit user creation. -export async function createLocalCheckoutWorkspace( - options: { cwd: string; title?: string | null }, - deps: CreateLocalCheckoutWorkspaceDeps, -): Promise { - const normalizedCwd = resolve(options.cwd); - const checkout = await deps.workspaceGitService.getCheckout(normalizedCwd); - const membership = classifyDirectoryForProjectMembership({ cwd: normalizedCwd, checkout }); - const now = new Date().toISOString(); - const projectRecord = await resolveProjectRecordForMembership({ - membership, - timestamp: now, - projectRegistry: deps.projectRegistry, - }); - await deps.projectRegistry.upsert(projectRecord); - - const trimmedTitle = options.title?.trim(); - // Persist the live git branch into the dedicated `branch` field so - // buildWorkspaceCheckout reports the real branch for directory/local_checkout - // workspaces too (it reads workspace.branch). Same source deriveWorkspaceDisplayName - // reads. HEAD/detached resolves to null — there is no branch to report. - const currentBranch = checkout.currentBranch?.trim() ?? null; - const branch = currentBranch && currentBranch.toUpperCase() !== "HEAD" ? currentBranch : null; - const workspace = createPersistedWorkspaceRecord({ - workspaceId: generateWorkspaceId(), - projectId: projectRecord.projectId, - cwd: normalizedCwd, - kind: membership.workspaceKind, - displayName: membership.workspaceDisplayName, - branch, - title: trimmedTitle ? trimmedTitle : null, - createdAt: now, - updatedAt: now, - }); - await deps.workspaceRegistry.upsert(workspace); - return workspace; -} - -async function resolveProjectRecordForMembership(options: { - membership: ReturnType; - timestamp: string; - projectRegistry: Pick; -}) { - const rootPath = options.membership.projectRootPath; - const projects = await options.projectRegistry.list(); - const existingProject = - projects.find((project) => !project.archivedAt && project.rootPath === rootPath) ?? - projects.find((project) => project.rootPath === rootPath) ?? - null; - - if (!existingProject) { - return createPersistedProjectRecord({ - projectId: options.membership.projectKey, - rootPath, - kind: options.membership.projectKind, - displayName: options.membership.projectName, - createdAt: options.timestamp, - updatedAt: options.timestamp, - }); - } - - return { - ...existingProject, - rootPath, - kind: options.membership.projectKind, - archivedAt: null, - updatedAt: options.timestamp, - }; -} - -interface SourceProjectForWorktree { - projectId: string; - rootPath: string; - kind: "git"; - displayName: string; - customName: string | null; - createdAt: string | null; -} - -function sourceProjectFromRecord(record: { - projectId: string; - rootPath: string; - displayName: string; - customName?: string | null; - createdAt?: string | null; -}): SourceProjectForWorktree { - return { - projectId: record.projectId, - rootPath: record.rootPath, - kind: "git", - displayName: record.displayName, - customName: record.customName ?? null, - createdAt: record.createdAt ?? null, - }; -} - -async function resolveExplicitProjectForWorktree(options: { - projectId: string; - projectRegistry: Pick; -}): Promise { - const project = await options.projectRegistry.get(options.projectId); - if (!project || project.archivedAt) { - throw new Error(`Project not found for worktree: ${options.projectId}`); - } - return sourceProjectFromRecord(project); -} - -async function resolveWorkspaceProjectForWorktree(options: { - sourceWorkspace: PersistedWorkspaceRecord; - repoRoot: string; - projectRegistry: Pick; -}): Promise { - const sourceProject = await options.projectRegistry.get(options.sourceWorkspace.projectId); - return sourceProjectFromRecord({ - projectId: options.sourceWorkspace.projectId, - rootPath: sourceProject?.rootPath ?? options.repoRoot, - displayName: - sourceProject?.displayName ?? deriveProjectGroupingName(options.sourceWorkspace.projectId), - customName: sourceProject?.customName ?? null, - createdAt: sourceProject?.createdAt ?? null, - }); -} - -async function resolveFallbackProjectForWorktree(options: { - repoRoot: string; - projectRegistry: Pick; -}): Promise { - const existingFallbackProject = await options.projectRegistry.get(options.repoRoot); - return sourceProjectFromRecord({ - projectId: options.repoRoot, - rootPath: existingFallbackProject?.rootPath ?? options.repoRoot, - displayName: - existingFallbackProject?.displayName ?? deriveProjectGroupingName(options.repoRoot), - customName: existingFallbackProject?.customName ?? null, - createdAt: existingFallbackProject?.createdAt ?? null, - }); -} - -async function resolveSourceProjectForWorktree(options: { - inputCwd: string; - projectId?: string; - repoRoot: string; - existingWorkspace: PersistedWorkspaceRecord | null; - deps: Pick; -}): Promise { - if (options.projectId) { - return resolveExplicitProjectForWorktree({ - projectId: options.projectId, - projectRegistry: options.deps.projectRegistry, - }); - } - - const sourceWorkspace = - options.existingWorkspace ?? - (await findWorkspaceForSource({ - inputCwd: options.inputCwd, - repoRoot: options.repoRoot, - workspaceRegistry: options.deps.workspaceRegistry, - })); - - if (sourceWorkspace) { - return resolveWorkspaceProjectForWorktree({ - sourceWorkspace, - repoRoot: options.repoRoot, - projectRegistry: options.deps.projectRegistry, - }); - } - - return resolveFallbackProjectForWorktree({ - repoRoot: options.repoRoot, - projectRegistry: options.deps.projectRegistry, - }); -} - -async function findWorkspaceForSource(options: { - inputCwd: string; - repoRoot: string; - workspaceRegistry: Pick; -}): Promise { - const workspaces = await options.workspaceRegistry.list(); - return ( - workspaces.find((workspace) => workspace.cwd === options.inputCwd && !workspace.archivedAt) ?? - workspaces.find((workspace) => workspace.cwd === options.repoRoot && !workspace.archivedAt) ?? - null - ); -} diff --git a/packages/server/src/server/persistence-hooks.ts b/packages/server/src/server/persistence-hooks.ts index 5fc511372..ba220eefa 100644 --- a/packages/server/src/server/persistence-hooks.ts +++ b/packages/server/src/server/persistence-hooks.ts @@ -110,6 +110,7 @@ export function extractTimestamps(record: StoredAgentRecord): { lastUserMessageAt: Date | null; labels?: Record; workspaceId?: string; + owner?: StoredAgentRecord["owner"]; } { return { createdAt: new Date(record.createdAt), @@ -117,6 +118,7 @@ export function extractTimestamps(record: StoredAgentRecord): { lastUserMessageAt: record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null, labels: record.labels, workspaceId: record.workspaceId, + owner: record.owner, }; } diff --git a/packages/server/src/server/schedule/cron.ts b/packages/server/src/server/schedule/cron.ts index ab24746a5..2ac2e7897 100644 --- a/packages/server/src/server/schedule/cron.ts +++ b/packages/server/src/server/schedule/cron.ts @@ -87,6 +87,8 @@ export function validateScheduleCadence(cadence: ScheduleCadence): void { export function computeNextRunAt(cadence: ScheduleCadence, after: Date): Date { if (cadence.type === "every") { + // COMPAT(scheduleEveryMs): execute legacy persisted rolling intervals until the + // compatibility floor reaches v0.2.0. Added in v0.2.0; remove after 2027-01-17. return new Date(after.getTime() + cadence.everyMs); } diff --git a/packages/server/src/server/schedule/service.test.ts b/packages/server/src/server/schedule/service.test.ts index e61d28286..b713641e0 100644 --- a/packages/server/src/server/schedule/service.test.ts +++ b/packages/server/src/server/schedule/service.test.ts @@ -23,7 +23,7 @@ import type { import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; import { createTestLogger } from "../../test-utils/test-logger.js"; import type { ProviderSnapshotManager } from "../agent/provider-snapshot-manager.js"; -import { createLocalCheckoutWorkspace } from "../paseo-worktree-service.js"; +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 { @@ -66,15 +66,12 @@ let workspaceArchiveInProgress = false; type TestScheduleServiceOptions = Omit< ScheduleServiceOptions, - | "createAgent" - | "createLocalCheckoutWorkspace" - | "createPaseoWorktreeWorkspace" - | "archiveWorkspace" + "createAgent" | "createDirectoryWorkspace" | "createPaseoWorktreeWorkspace" | "archiveWorkspace" > & { agentManager: AgentManager; providerSnapshotManager: Pick; createAgent?: ScheduleServiceOptions["createAgent"]; - createLocalCheckoutWorkspace?: ScheduleServiceOptions["createLocalCheckoutWorkspace"]; + createDirectoryWorkspace?: ScheduleServiceOptions["createDirectoryWorkspace"]; createPaseoWorktreeWorkspace?: ScheduleServiceOptions["createPaseoWorktreeWorkspace"]; archiveWorkspace?: ScheduleServiceOptions["archiveWorkspace"]; }; @@ -83,7 +80,7 @@ function createScheduleService(options: TestScheduleServiceOptions): ScheduleSer let workspaceCounter = 0; const workspaces = new Map(); const workspaceGitService = createNoopWorkspaceGitService(); - const createDefaultWorkspace: ScheduleServiceOptions["createLocalCheckoutWorkspace"] = async ( + const createDefaultWorkspace: ScheduleServiceOptions["createDirectoryWorkspace"] = async ( input, ) => { const timestamp = new Date().toISOString(); @@ -141,7 +138,6 @@ function createScheduleService(options: TestScheduleServiceOptions): ScheduleSer }, { scope: { kind: "workspace", workspaceId }, - repoRoot: null, requestId: "schedule-service-test", }, ); @@ -163,7 +159,7 @@ function createScheduleService(options: TestScheduleServiceOptions): ScheduleSer }, input, )), - createLocalCheckoutWorkspace: options.createLocalCheckoutWorkspace ?? createDefaultWorkspace, + createDirectoryWorkspace: options.createDirectoryWorkspace ?? createDefaultWorkspace, createPaseoWorktreeWorkspace: options.createPaseoWorktreeWorkspace ?? (async (input) => { @@ -182,7 +178,7 @@ function createScheduleService(options: TestScheduleServiceOptions): ScheduleSer async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promise<{ workspaceRegistry: FileBackedWorkspaceRegistry; - createLocalCheckoutWorkspace: ScheduleServiceOptions["createLocalCheckoutWorkspace"]; + createDirectoryWorkspace: ScheduleServiceOptions["createDirectoryWorkspace"]; createArchiveWorkspace: (input: { agentManager: AgentManager; agentStorage: AgentStorage; @@ -200,12 +196,17 @@ async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promi await workspaceRegistry.initialize(); await projectRegistry.initialize(); const workspaceGitService = createNoopWorkspaceGitService(); + const workspaceProvisioning = createWorkspaceProvisioningService({ + projectRegistry, + workspaceRegistry, + workspaceGitService, + }); return { workspaceRegistry, - createLocalCheckoutWorkspace: async (input) => { - return createLocalCheckoutWorkspace( - { cwd: input.cwd, title: input.firstAgentContext.prompt }, - { projectRegistry, workspaceRegistry, workspaceGitService }, + createDirectoryWorkspace: async (input) => { + return workspaceProvisioning.createWorkspaceForDirectory( + input.cwd, + input.firstAgentContext.prompt, ); }, createArchiveWorkspace: @@ -240,7 +241,6 @@ async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promi }, { scope: { kind: "workspace", workspaceId }, - repoRoot: null, requestId: "schedule-service-test", }, ); @@ -376,6 +376,47 @@ describe("ScheduleService", () => { expect(resumed.nextRunAt).toBe("2026-01-01T00:04:00.000Z"); }); + test("lists only active schedules that target existing agents", async () => { + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + }); + const activeAgentId = "00000000-0000-4000-8000-000000000201"; + const pausedAgentId = "00000000-0000-4000-8000-000000000202"; + const completedAgentId = "00000000-0000-4000-8000-000000000203"; + const cadence = { type: "every" as const, everyMs: 60_000 }; + + await service.create({ + prompt: "Keep active agent resident", + cadence, + target: { type: "agent", agentId: activeAgentId }, + }); + const paused = await service.create({ + prompt: "Paused heartbeat", + cadence, + target: { type: "agent", agentId: pausedAgentId }, + }); + await service.pause(paused.id); + await service.create({ + prompt: "Completed heartbeat", + cadence, + target: { type: "agent", agentId: completedAgentId }, + }); + await service.completeForAgent(completedAgentId); + await service.create({ + prompt: "Fresh agent each run", + cadence, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + }); + + await expect(service.listActiveAgentTargetIds()).resolves.toEqual(new Set([activeAgentId])); + }); + test("completes schedules when max runs is reached", async () => { const service = createScheduleService({ paseoHome: tempDir, @@ -493,7 +534,7 @@ describe("ScheduleService", () => { }); test("new-agent schedule records create no workspace until run time", async () => { - const { workspaceRegistry, createLocalCheckoutWorkspace: createScheduleLocalWorkspace } = + const { workspaceRegistry, createDirectoryWorkspace: createScheduleDirectoryWorkspace } = await createRegistryBackedScheduleWorkspaceDeps(tempDir); const service = createScheduleService({ paseoHome: tempDir, @@ -501,7 +542,7 @@ describe("ScheduleService", () => { agentManager: new AgentManager({ logger: createTestLogger() }), agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, - createLocalCheckoutWorkspace: createScheduleLocalWorkspace, + createDirectoryWorkspace: createScheduleDirectoryWorkspace, now: () => now, runner: async () => ({ agentId: null, output: "ok" }), }); @@ -531,7 +572,7 @@ describe("ScheduleService", () => { test("archiveOnFinish=false local runs create one active workspace per run", async () => { const { workspaceRegistry, - createLocalCheckoutWorkspace: createScheduleLocalWorkspace, + createDirectoryWorkspace: createScheduleDirectoryWorkspace, createArchiveWorkspace, } = await createRegistryBackedScheduleWorkspaceDeps(tempDir); const manager = new AgentManager({ @@ -545,7 +586,7 @@ describe("ScheduleService", () => { agentManager: manager, agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, - createLocalCheckoutWorkspace: createScheduleLocalWorkspace, + createDirectoryWorkspace: createScheduleDirectoryWorkspace, archiveWorkspace: createArchiveWorkspace({ agentManager: manager, agentStorage, @@ -599,7 +640,7 @@ describe("ScheduleService", () => { test("archiveOnFinish=true archives the run workspace through workspace archive", async () => { const { workspaceRegistry, - createLocalCheckoutWorkspace: createScheduleLocalWorkspace, + createDirectoryWorkspace: createScheduleDirectoryWorkspace, createArchiveWorkspace, } = await createRegistryBackedScheduleWorkspaceDeps(tempDir); const manager = new AgentManager({ @@ -620,7 +661,7 @@ describe("ScheduleService", () => { agentManager: manager, agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, - createLocalCheckoutWorkspace: createScheduleLocalWorkspace, + createDirectoryWorkspace: createScheduleDirectoryWorkspace, archiveWorkspace: createArchiveWorkspace({ agentManager: manager, agentStorage, @@ -663,7 +704,7 @@ describe("ScheduleService", () => { test("archives the run workspace when scheduled agent creation fails before archive opt-out can preserve an agent", async () => { const { workspaceRegistry, - createLocalCheckoutWorkspace: createScheduleLocalWorkspace, + createDirectoryWorkspace: createScheduleDirectoryWorkspace, createArchiveWorkspace, } = await createRegistryBackedScheduleWorkspaceDeps(tempDir); const manager = new AgentManager({ @@ -678,7 +719,7 @@ describe("ScheduleService", () => { agentManager: manager, agentStorage, providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, - createLocalCheckoutWorkspace: createScheduleLocalWorkspace, + createDirectoryWorkspace: createScheduleDirectoryWorkspace, archiveWorkspace: createArchiveWorkspace({ agentManager: manager, agentStorage, @@ -1904,7 +1945,7 @@ describe("ScheduleService", () => { ], })); - const archiveCalls: Array<{ workspaceId: string; repoRoot: string }> = []; + const archiveCalls: string[] = []; now = new Date("2026-01-01T00:10:00.000Z"); const service2 = createScheduleService({ paseoHome: tempDir, @@ -1914,13 +1955,13 @@ describe("ScheduleService", () => { providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, runner: async () => ({ agentId: null, output: "ok" }), - archiveWorkspace: async (archivedWorkspaceId, repoRoot) => { - archiveCalls.push({ workspaceId: archivedWorkspaceId, repoRoot }); + archiveWorkspace: async (archivedWorkspaceId) => { + archiveCalls.push(archivedWorkspaceId); }, }); await service2.start(); - expect(archiveCalls).toEqual([{ workspaceId, repoRoot: tempDir }]); + expect(archiveCalls).toEqual([workspaceId]); const inspected = await service2.inspect(created.id); expect(inspected.runs[0]).toMatchObject({ status: "failed", @@ -1972,7 +2013,7 @@ describe("ScheduleService", () => { ], })); - const archiveCalls: Array<{ workspaceId: string; repoRoot: string }> = []; + const archiveCalls: string[] = []; now = new Date("2026-01-01T00:10:00.000Z"); const service2 = createScheduleService({ paseoHome: tempDir, @@ -1982,13 +2023,13 @@ describe("ScheduleService", () => { providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, now: () => now, runner: async () => ({ agentId: null, output: "ok" }), - archiveWorkspace: async (archivedWorkspaceId, repoRoot) => { - archiveCalls.push({ workspaceId: archivedWorkspaceId, repoRoot }); + archiveWorkspace: async (archivedWorkspaceId) => { + archiveCalls.push(archivedWorkspaceId); }, }); await service2.start(); - expect(archiveCalls).toEqual([{ workspaceId, repoRoot: tempDir }]); + expect(archiveCalls).toEqual([workspaceId]); const inspected = await service2.inspect(created.id); expect(inspected.runs[0]).toMatchObject({ status: "failed", diff --git a/packages/server/src/server/schedule/service.ts b/packages/server/src/server/schedule/service.ts index bc5d63b9e..6bcaed724 100644 --- a/packages/server/src/server/schedule/service.ts +++ b/packages/server/src/server/schedule/service.ts @@ -204,7 +204,9 @@ type ScheduleAgentManager = Pick< | "hydrateTimelineFromProvider" | "resumeAgentFromPersistence" | "runAgent" + | "touchAgentActivity" | "waitForAgentEvent" + | "waitForAgentClose" >; interface ScheduleWorkspaceCreateInput { @@ -218,13 +220,13 @@ export interface ScheduleServiceOptions { agentManager: ScheduleAgentManager; agentStorage: AgentStorage; createAgent: BoundCreateAgentCommand; - createLocalCheckoutWorkspace: ( + createDirectoryWorkspace: ( input: ScheduleWorkspaceCreateInput, ) => Promise; createPaseoWorktreeWorkspace: ( input: ScheduleWorkspaceCreateInput, ) => Promise; - archiveWorkspace: (workspaceId: string, repoRoot: string) => Promise; + archiveWorkspace: (workspaceId: string) => Promise; now?: () => Date; runner?: (schedule: StoredSchedule, runId: string) => Promise; } @@ -235,13 +237,13 @@ export class ScheduleService { private readonly agentManager: ScheduleAgentManager; private readonly agentStorage: AgentStorage; private readonly createAgent: BoundCreateAgentCommand; - private readonly createLocalCheckoutWorkspace: ( + private readonly createDirectoryWorkspace: ( input: ScheduleWorkspaceCreateInput, ) => Promise; private readonly createPaseoWorktreeWorkspace: ( input: ScheduleWorkspaceCreateInput, ) => Promise; - private readonly archiveWorkspace: (workspaceId: string, repoRoot: string) => Promise; + private readonly archiveWorkspace: (workspaceId: string) => Promise; private readonly now: () => Date; private readonly runner: ( schedule: StoredSchedule, @@ -256,7 +258,7 @@ export class ScheduleService { this.agentManager = options.agentManager; this.agentStorage = options.agentStorage; this.createAgent = options.createAgent; - this.createLocalCheckoutWorkspace = options.createLocalCheckoutWorkspace; + this.createDirectoryWorkspace = options.createDirectoryWorkspace; this.createPaseoWorktreeWorkspace = options.createPaseoWorktreeWorkspace; this.archiveWorkspace = options.archiveWorkspace; this.now = options.now ?? (() => new Date()); @@ -368,6 +370,17 @@ export class ScheduleService { return this.store.list(); } + async listActiveAgentTargetIds(): Promise> { + const schedules = await this.store.list(); + const agentIds = new Set(); + for (const schedule of schedules) { + if (schedule.status === "active" && schedule.target.type === "agent") { + agentIds.add(schedule.target.agentId); + } + } + return agentIds; + } + async inspect(id: string): Promise { const schedule = await this.store.get(id); if (!schedule) { @@ -579,7 +592,6 @@ export class ScheduleService { private async recoverInterruptedSchedule(scheduleId: string, now: Date): Promise { const interruptedWorkspaces: Array<{ workspaceId: string; - repoRoot: string; agentId: string | null; runId: string; }> = []; @@ -601,7 +613,6 @@ export class ScheduleService { ) { interruptedWorkspaces.push({ workspaceId: runningRun.workspaceId, - repoRoot: updated.target.config.cwd, agentId: runningRun.agentId, runId: runningRun.id, }); @@ -639,7 +650,7 @@ export class ScheduleService { return; } try { - await this.archiveWorkspace(interruptedWorkspace.workspaceId, interruptedWorkspace.repoRoot); + await this.archiveWorkspace(interruptedWorkspace.workspaceId); } catch (error) { this.logger.warn( { @@ -930,7 +941,7 @@ export class ScheduleService { shouldArchiveScheduleRunWorkspace({ agentId, archiveOnFinish: config.archiveOnFinish }) ) { try { - await this.archiveWorkspace(workspace.workspaceId, config.cwd); + await this.archiveWorkspace(workspace.workspaceId); } catch (error) { this.logger.warn( { @@ -954,7 +965,7 @@ export class ScheduleService { const firstAgentContext = { prompt }; switch (config.isolation ?? "local") { case "local": - return this.createLocalCheckoutWorkspace({ cwd: config.cwd, firstAgentContext }); + return this.createDirectoryWorkspace({ cwd: config.cwd, firstAgentContext }); case "worktree": return (await this.createPaseoWorktreeWorkspace({ cwd: config.cwd, firstAgentContext })) .workspace; diff --git a/packages/server/src/server/selective-timeline-delivery.e2e.test.ts b/packages/server/src/server/selective-timeline-delivery.e2e.test.ts new file mode 100644 index 000000000..8933def32 --- /dev/null +++ b/packages/server/src/server/selective-timeline-delivery.e2e.test.ts @@ -0,0 +1,287 @@ +import { afterEach, beforeEach, expect, test } from "vitest"; +import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { DaemonClient } from "./test-utils/daemon-client.js"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "./test-utils/paseo-daemon.js"; + +interface MessageWaiter { + predicate(message: SessionOutboundMessage): boolean; + resolve(message: SessionOutboundMessage): void; + reject(error: Error): void; + timeout: ReturnType; +} + +class ConnectedClient { + readonly messages: SessionOutboundMessage[] = []; + private readonly waiters: MessageWaiter[] = []; + private readonly unsubscribe: () => void; + + constructor(readonly client: DaemonClient) { + this.unsubscribe = client.subscribeRawMessages((message) => { + this.messages.push(message); + for (let waiterIndex = this.waiters.length - 1; waiterIndex >= 0; waiterIndex -= 1) { + const waiter = this.waiters[waiterIndex]; + if (!waiter.predicate(message)) continue; + clearTimeout(waiter.timeout); + this.waiters.splice(waiterIndex, 1); + waiter.resolve(message); + } + }); + } + + clear(): void { + this.messages.length = 0; + } + + next( + predicate: (message: SessionOutboundMessage) => boolean, + description: string, + ): Promise { + const existing = this.messages.find(predicate); + if (existing) return Promise.resolve(existing); + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + const index = this.waiters.findIndex((waiter) => waiter.resolve === resolve); + if (index >= 0) this.waiters.splice(index, 1); + reject(new Error(`Timed out waiting for ${description}`)); + }, 5_000); + this.waiters.push({ predicate, resolve, reject, timeout }); + }); + } + + hasTimeline(agentId: string): boolean { + return this.messages.some( + (message) => message.type === "agent_stream" && message.payload.agentId === agentId, + ); + } + + async barrier(label: string): Promise { + await this.client.ping({ requestId: `barrier-${label}` }); + } + + close(): void { + this.unsubscribe(); + for (const waiter of this.waiters) { + clearTimeout(waiter.timeout); + waiter.reject(new Error("Client boundary closed")); + } + this.waiters.length = 0; + } +} + +function isAgentStream(agentId: string) { + return (message: SessionOutboundMessage): boolean => + message.type === "agent_stream" && message.payload.agentId === agentId; +} + +function isDedicatedAttention(agentId: string) { + return (message: SessionOutboundMessage): boolean => + message.type === "agent_attention_required" && message.payload.agentId === agentId; +} + +function isLegacyAttention(agentId: string) { + return (message: SessionOutboundMessage): boolean => + message.type === "agent_stream" && + message.payload.agentId === agentId && + message.payload.event.type === "attention_required"; +} + +function dedicatedAttentionResult(message: SessionOutboundMessage, timelineLeaked: boolean) { + if (message.type !== "agent_attention_required") { + throw new Error(`Expected agent_attention_required, received ${message.type}`); + } + return { + type: message.type, + shouldNotify: message.payload.shouldNotify, + timelineLeaked, + }; +} + +function legacyAttentionResult(message: SessionOutboundMessage) { + if (message.type !== "agent_stream" || message.payload.event.type !== "attention_required") { + throw new Error(`Expected legacy attention_required agent_stream, received ${message.type}`); + } + return { + type: message.type, + eventType: message.payload.event.type, + agentId: message.payload.agentId, + }; +} + +let daemon: TestPaseoDaemon; +const clients: ConnectedClient[] = []; + +beforeEach(async () => { + daemon = await createTestPaseoDaemon(); +}); + +afterEach(async () => { + for (const connected of clients) { + connected.close(); + await connected.client.close().catch(() => undefined); + } + clients.length = 0; + await daemon.close(); +}, 30_000); + +async function connect(input: { clientId: string; selective: boolean }): Promise { + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + clientId: input.clientId, + capabilities: { [CLIENT_CAPS.selectiveAgentTimeline]: input.selective }, + reconnect: { enabled: false }, + }); + await client.connect(); + const connected = new ConnectedClient(client); + clients.push(connected); + return connected; +} + +test("subscription acknowledgements stay on the requesting socket of a retained session", async () => { + const legacy = await connect({ clientId: "shared-client", selective: false }); + const capable = await connect({ clientId: "shared-client", selective: true }); + legacy.clear(); + capable.clear(); + + await capable.client.setAgentTimelineSubscription(["agent-a"]); + await capable.barrier("targeted-subscription-ack"); + + expect( + legacy.messages.some((message) => message.type === "agent.timeline.set_subscription.response"), + ).toBe(false); +}); + +test("real WebSocket sessions enforce selective delivery, retained resets, downgrade, and dedicated attention", async () => { + const legacy = await connect({ clientId: "legacy-client", selective: false }); + let capable = await connect({ clientId: "capable-client", selective: true }); + const agents = await Promise.all( + ["A", "B", "C"].map((title) => + legacy.client.createAgent({ + provider: "codex", + cwd: "/tmp", + title: `Selective ${title}`, + modeId: "full-access", + }), + ), + ); + const [agentA, agentB, agentC] = agents; + legacy.clear(); + capable.clear(); + + await daemon.daemon.agentManager.emitLiveTimelineItem(agentC.id, { + type: "assistant_message", + text: "before membership", + }); + await legacy.next(isAgentStream(agentC.id), "legacy global delivery before membership"); + await capable.barrier("before-membership"); + expect(capable.hasTimeline(agentC.id)).toBe(false); + + await capable.client.setAgentTimelineSubscription([agentA.id, agentB.id]); + legacy.clear(); + capable.clear(); + await daemon.daemon.agentManager.emitLiveTimelineItem(agentA.id, { + type: "assistant_message", + text: "viewed A", + }); + await daemon.daemon.agentManager.emitLiveTimelineItem(agentB.id, { + type: "assistant_message", + text: "viewed B", + }); + await daemon.daemon.agentManager.emitLiveTimelineItem(agentC.id, { + type: "assistant_message", + text: "unviewed C", + }); + await Promise.all([ + capable.next(isAgentStream(agentA.id), "capable A delivery"), + capable.next(isAgentStream(agentB.id), "capable B delivery"), + legacy.next(isAgentStream(agentC.id), "legacy C delivery"), + ]); + await capable.barrier("unviewed-c"); + expect(capable.hasTimeline(agentC.id)).toBe(false); + + await capable.client.setAgentTimelineSubscription([agentB.id]); + legacy.clear(); + capable.clear(); + await daemon.daemon.agentManager.emitLiveTimelineItem(agentA.id, { + type: "assistant_message", + text: "removed A", + }); + await daemon.daemon.agentManager.emitLiveTimelineItem(agentB.id, { + type: "assistant_message", + text: "retained B", + }); + await Promise.all([ + legacy.next(isAgentStream(agentA.id), "legacy removed A delivery"), + capable.next(isAgentStream(agentB.id), "capable retained B delivery"), + ]); + await capable.barrier("removed-a"); + expect(capable.hasTimeline(agentA.id)).toBe(false); + + capable.close(); + await capable.client.close(); + clients.splice(clients.indexOf(capable), 1); + capable = await connect({ clientId: "capable-client", selective: true }); + legacy.clear(); + capable.clear(); + await daemon.daemon.agentManager.emitLiveTimelineItem(agentB.id, { + type: "assistant_message", + text: "after capable resume", + }); + await legacy.next(isAgentStream(agentB.id), "legacy delivery after capable resume"); + await capable.barrier("resumed-membership-reset"); + expect(capable.hasTimeline(agentB.id)).toBe(false); + + capable.client.sendHeartbeat({ + deviceType: "mobile", + focusedAgentId: null, + lastActivityAt: new Date().toISOString(), + appVisible: true, + }); + legacy.clear(); + capable.clear(); + const attention = capable.next( + isDedicatedAttention(agentC.id), + "capable dedicated attention notification", + ); + const legacyAttention = legacy.next( + isLegacyAttention(agentC.id), + "legacy attention stream notification", + ); + await legacy.client.sendMessage(agentC.id, "finish attention boundary test"); + const [attentionMessage, legacyAttentionMessage] = await Promise.all([ + attention, + legacyAttention, + ]); + await capable.barrier("attention-delivery"); + expect({ + capable: dedicatedAttentionResult(attentionMessage, capable.hasTimeline(agentC.id)), + legacy: legacyAttentionResult(legacyAttentionMessage), + }).toEqual({ + capable: { + type: "agent_attention_required", + shouldNotify: true, + timelineLeaked: false, + }, + legacy: { + type: "agent_stream", + eventType: "attention_required", + agentId: agentC.id, + }, + }); + + capable.close(); + await capable.client.close(); + clients.splice(clients.indexOf(capable), 1); + const downgraded = await connect({ clientId: "capable-client", selective: false }); + downgraded.clear(); + await daemon.daemon.agentManager.emitLiveTimelineItem(agentA.id, { + type: "assistant_message", + text: "after downgrade", + }); + const downgradedDelivery = await downgraded.next( + isAgentStream(agentA.id), + "legacy global delivery after capability downgrade", + ); + + expect(downgradedDelivery.type).toBe("agent_stream"); +}, 30_000); 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 c76827e95..8507a935e 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 @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, expect, test } from "vitest"; @@ -8,6 +8,7 @@ import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js"; import { createDaemonTestContext, type DaemonTestContext } from "./test-utils/index.js"; import type { CreateAgentOptions } from "./test-utils/index.js"; import type { CreateAgentWorktreeTarget } from "./messages.js"; +import { createRealpathAwarePathMatcher } from "../utils/path.js"; let ctx: DaemonTestContext; const tempRoots: string[] = []; @@ -42,6 +43,18 @@ function createGitRepo(): string { return repoDir; } +function createGitRepoWithNestedDirectory(): string { + const repoDir = createGitRepo(); + mkdirSync(path.join(repoDir, "packages", "app"), { recursive: true }); + writeFileSync(path.join(repoDir, "packages", "app", ".gitkeep"), ""); + execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add nested app"], { + cwd: repoDir, + stdio: "pipe", + }); + return repoDir; +} + async function expectAgentAbsentFromActiveList(agentId: string): Promise { await expect .poll( @@ -84,8 +97,9 @@ async function expectWorktreeListEmpty(repoDir: string): Promise { async function createAgentInBranchOffWorktree(options?: { autoArchive?: boolean; branchName?: string; + repoDir?: string; }): Promise<{ repoDir: string; agentId: string; worktreePath: string }> { - const repoDir = createGitRepo(); + const repoDir = options?.repoDir ?? createGitRepo(); const branchName = options?.branchName ?? `agent-lifecycle-${Date.now()}`; const created = await ctx.client.createAgent({ config: { @@ -142,6 +156,109 @@ 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); }, 30000); +test("create_agent_request auto-archives a nested workspace from an existing Paseo worktree", async () => { + const repoDir = createGitRepoWithNestedDirectory(); + const source = await createAgentInBranchOffWorktree({ branchName: "nested-source", repoDir }); + await ctx.client.waitForFinish(source.agentId, 10000); + const nestedCwd = path.join(source.worktreePath, "packages", "app"); + + const created = await ctx.client.createAgent({ + config: { + ...getFullAccessConfig("codex"), + cwd: nestedCwd, + }, + worktree: { + mode: "branch-off", + newBranch: "nested-auto-archive", + base: "main", + }, + autoArchive: true, + initialPrompt: "Say done.", + }); + + const createdWorktreeRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { + cwd: created.cwd, + stdio: "pipe", + }) + .toString() + .trim(); + expect( + createRealpathAwarePathMatcher(path.join(createdWorktreeRoot, "packages", "app"))(created.cwd), + ).toBe(true); + await ctx.client.waitForFinish(created.id, 10000); + + await expectAgentAbsentFromActiveList(created.id); + await expect + .poll( + async () => { + const workspaces = await ctx.client.fetchWorkspaces(); + const matchesCreatedWorkspace = createRealpathAwarePathMatcher(created.cwd); + return workspaces.entries.some((workspace) => + matchesCreatedWorkspace(workspace.workspaceDirectory), + ); + }, + { timeout: 10000, interval: 100 }, + ) + .toBe(false); + await expect.poll(() => existsSync(created.cwd), { timeout: 10000, interval: 100 }).toBe(false); + expect(existsSync(source.worktreePath)).toBe(true); + + await ctx.client.archivePaseoWorktree({ worktreePath: source.worktreePath }); +}, 30000); + +test("failed nested worktree creation cleans up the created workspace and backing directory", async () => { + const repoDir = createGitRepoWithNestedDirectory(); + const source = await createAgentInBranchOffWorktree({ + branchName: "nested-failure-source", + repoDir, + }); + await ctx.client.waitForFinish(source.agentId, 10000); + const nestedCwd = path.join(source.worktreePath, "packages", "app"); + + await expect( + ctx.client.createAgent({ + config: { provider: "unknown-provider", cwd: nestedCwd }, + worktree: { + mode: "branch-off", + newBranch: "nested-failure-cleanup", + base: "main", + }, + initialPrompt: "This agent cannot be created.", + }), + ).rejects.toThrow(); + + await expect + .poll( + async () => { + const listed = await ctx.client.getPaseoWorktreeList({ cwd: source.repoDir }); + return ( + listed.worktrees.length === 1 && + createRealpathAwarePathMatcher(source.worktreePath)( + listed.worktrees[0]?.worktreePath ?? "", + ) + ); + }, + { timeout: 10000, interval: 100 }, + ) + .toBe(true); + await expect + .poll( + async () => { + const workspaces = await ctx.client.fetchWorkspaces(); + return ( + workspaces.entries.length === 1 && + createRealpathAwarePathMatcher(source.worktreePath)( + workspaces.entries[0]?.workspaceDirectory ?? "", + ) + ); + }, + { timeout: 10000, interval: 100 }, + ) + .toBe(true); + + await ctx.client.archivePaseoWorktree({ worktreePath: source.worktreePath }); +}, 30000); + test("create_agent_request with autoArchive archives only the agent when no worktree was created", async () => { const repoDir = createGitRepo(); const created = await ctx.client.createAgent({ diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index c0fcff4bb..183f02633 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -10,6 +10,7 @@ import { assertPullRequestAutoMergeEnableReady, } from "../services/github-service.js"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; +import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages"; import { decodeFileTransferFrame, @@ -17,11 +18,13 @@ import { FileTransferOpcode, type FileTransferFrame, } from "@getpaseo/protocol/binary-frames/index"; -import { Session } from "./session.js"; +import { isSessionRpcAllowed, Session } from "./session.js"; import { DownloadTokenStore } from "./file-download/token-store.js"; import { StructuredAgentFallbackError } from "./agent/agent-response-loop.js"; import type { StoredAgentRecord } from "./agent/agent-storage.js"; +import type { AgentManagerEvent } from "./agent/agent-manager.js"; import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; +import { createPersistedProjectRecord } from "./workspace-registry.js"; import type { SessionOptions } from "./session.js"; import type { SessionInboundMessage, SessionOutboundMessage } from "./messages.js"; import { @@ -275,6 +278,7 @@ vi.mock("./worktree-bootstrap.js", async (importOriginal) => { }); interface SessionForTestOptions { + scopes?: readonly string[]; agentManager?: { [K in keyof SessionOptions["agentManager"]]?: unknown }; agentStorage?: { [K in keyof SessionOptions["agentStorage"]]?: unknown }; github?: Partial; @@ -292,6 +296,7 @@ interface SessionForTestOptions { resolveRepoRoot?: ReturnType; resolveForge?: ReturnType; getWorkspaceGitMetadata?: ReturnType; + getProjectSlug?: ReturnType; }; workspaceRegistry?: { get: ReturnType }; projectRegistry?: Partial; @@ -309,6 +314,7 @@ interface SessionForTestOptions { daemonRuntimeConfig?: SessionOptions["daemonRuntimeConfig"]; downloadTokenStore?: SessionOptions["downloadTokenStore"]; messages?: unknown[]; + targetedMessages?: Array<{ source: object; message: SessionOutboundMessage }>; binaryMessages?: Uint8Array[]; } @@ -339,13 +345,20 @@ 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 })), + getProjectSlug: vi.fn(), ...options.workspaceGitService, }; const messages = options.messages ?? []; - return new Session({ + const sessionOptions: SessionOptions = { clientId: "test-client", onMessage: (message) => messages.push(message), + ...(options.targetedMessages + ? { + onMessageToSource: (source: object, message: SessionOutboundMessage) => + options.targetedMessages?.push({ source, message }), + } + : {}), onBinaryMessage: createBinaryMessageHandler(options.binaryMessages), logger, downloadTokenStore: options.downloadTokenStore ?? asDownloadTokenStore(), @@ -357,17 +370,20 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { ...options.agentManager, }), agentStorage: asAgentStorage({ + get: vi.fn().mockResolvedValue(undefined), list: vi.fn().mockResolvedValue([]), ...options.agentStorage, }), - projectRegistry: options.projectRegistry ?? { + projectRegistry: { list: vi.fn().mockResolvedValue([]), get: vi.fn(), + getOrCreateActiveByRoot: vi.fn(), upsert: vi.fn(), archive: vi.fn(), remove: vi.fn(), initialize: vi.fn(), existsOnDisk: vi.fn(), + ...options.projectRegistry, }, workspaceRegistry: options.workspaceRegistry ?? { get: vi.fn(), @@ -399,9 +415,85 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { serverId: options.serverId, daemonVersion: options.daemonVersion, daemonRuntimeConfig: options.daemonRuntimeConfig, - }); + scopes: options.scopes ?? ["*"], + }; + return new Session(sessionOptions); } +describe("session authorization scopes", () => { + test("rejects an RPC outside an exact grant with the generic RPC error", async () => { + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ + scopes: ["hub.execution.agent.create.request"], + messages, + }); + + await session.handleMessage({ type: "ping", requestId: "restricted-ping", clientSentAt: 42 }); + + expect(messages).toEqual([ + { + type: "rpc_error", + payload: { + requestId: "restricted-ping", + requestType: "ping", + error: "Session is not authorized for ping", + code: "access_denied", + }, + }, + ]); + }); + + test.each([ + ["*", "ping"], + ["hub.execution.*", "hub.execution.agent.create.request"], + ["hub.execution.agent.create.request", "hub.execution.agent.create.request"], + ])("scope %s authorizes %s", (scope, requestType) => { + expect(isSessionRpcAllowed([scope], requestType)).toBe(true); + }); + + test.each([ + ["hub.execution.*", "hub.management.daemon.get_status.request"], + ["hub.execution.agent.create.request", "hub.execution.agent.update"], + ["hub.execution.*", "hub.executions.agent.create.request"], + ])("scope %s rejects %s", (scope, requestType) => { + expect(isSessionRpcAllowed([scope], requestType)).toBe(false); + }); + + test("replaces a session's scopes without reconstructing the session", async () => { + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ scopes: ["hub.execution.*"], messages }); + + await session.handleMessage({ + type: "ping", + requestId: "before-scope-change", + clientSentAt: 1, + }); + session.setScopes(["*"]); + await session.handleMessage({ type: "ping", requestId: "after-scope-change", clientSentAt: 2 }); + + expect(messages).toEqual([ + { + type: "rpc_error", + payload: { + requestId: "before-scope-change", + requestType: "ping", + error: "Session is not authorized for ping", + code: "access_denied", + }, + }, + { + type: "pong", + payload: { + requestId: "after-scope-change", + clientSentAt: 2, + serverReceivedAt: expect.any(Number), + serverSentAt: expect.any(Number), + }, + }, + ]); + }); +}); + describe("project command-center RPCs", () => { test("returns normalized repositories from the host GitHub service", async () => { const messages: SessionOutboundMessage[] = []; @@ -516,12 +608,20 @@ describe("project command-center RPCs", () => { const parentDirectory = realpathSync(mkdtempSync(join(tmpdir(), "paseo-project-session-"))); const directoryPath = join(parentDirectory, "new-project"); const messages: SessionOutboundMessage[] = []; - const projectUpsert = vi.fn().mockResolvedValue(undefined); + const projectAllocation = vi.fn(async (input) => + createPersistedProjectRecord({ + projectId: "prj_created_directory", + rootPath: input.rootPath, + kind: input.kind, + displayName: input.displayName, + createdAt: input.timestamp, + updatedAt: input.timestamp, + }), + ); const session = createSessionForTest({ messages, projectRegistry: { - list: vi.fn().mockResolvedValue([]), - upsert: projectUpsert, + getOrCreateActiveByRoot: projectAllocation, }, workspaceGitService: { getCheckout: vi.fn(async (cwd: string) => ({ @@ -545,7 +645,12 @@ describe("project command-center RPCs", () => { }); expect(existsSync(directoryPath)).toBe(true); - expect(projectUpsert).toHaveBeenCalledOnce(); + expect(projectAllocation).toHaveBeenCalledWith({ + rootPath: directoryPath, + kind: "non_git", + displayName: "new-project", + timestamp: expect.any(String), + }); expect(messages).toEqual([ { type: "project.create_directory.response", @@ -553,7 +658,7 @@ describe("project command-center RPCs", () => { requestId: "req-create-directory", directoryPath, project: { - projectId: directoryPath, + projectId: "prj_created_directory", projectDisplayName: "new-project", projectCustomName: null, projectRootPath: directoryPath, @@ -576,8 +681,7 @@ describe("project command-center RPCs", () => { const session = createSessionForTest({ messages, projectRegistry: { - list: vi.fn().mockResolvedValue([]), - upsert: vi.fn().mockRejectedValue(new Error("registry unavailable")), + getOrCreateActiveByRoot: vi.fn().mockRejectedValue(new Error("registry unavailable")), }, workspaceGitService: { getCheckout: vi.fn(async (cwd: string) => ({ @@ -4032,17 +4136,17 @@ describe("session paseo worktree creation handling", () => { }); describe("session workspace script handling", () => { - test("passes service-owned git metadata into workspace script spawning", async () => { + test("passes the project slug and cached branch into workspace script spawning", async () => { const messages: unknown[] = []; - const workspaceGitService = { - peekSnapshot: vi.fn(() => null), - getWorkspaceGitMetadata: vi.fn().mockResolvedValue({ - projectKind: "git", - projectDisplayName: "getpaseo/paseo", - workspaceDisplayName: "feature/service-scripts", - projectSlug: "paseo", + const snapshot = createWorkspaceGitSnapshot("/tmp/repo", { + git: { currentBranch: "feature/service-scripts", - }), + remoteUrl: "https://github.com/getpaseo/paseo.git", + }, + }); + const workspaceGitService = { + peekSnapshot: vi.fn(() => snapshot), + getProjectSlug: vi.fn().mockResolvedValue("paseo"), }; const workspaceRegistry = { get: vi.fn().mockResolvedValue({ @@ -4075,8 +4179,6 @@ describe("session workspace script handling", () => { requestId: "request-script", }); - expect(workspaceGitService.getWorkspaceGitMetadata).toHaveBeenCalledTimes(1); - expect(workspaceGitService.getWorkspaceGitMetadata).toHaveBeenCalledWith("/tmp/repo"); expect(spawnMocks.spawnWorkspaceScript).toHaveBeenCalledWith( expect.objectContaining({ repoRoot: "/tmp/repo", @@ -4554,13 +4656,266 @@ describe("chat/schedule/loop dispatch routing (behavior preservation)", () => { }); }); +test("replaces a capable session's complete viewed timeline set", async () => { + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ messages }); + session.updateClientCapabilities({ selective_agent_timeline: true }); + + await session.handleMessage({ + type: "agent.timeline.set_subscription.request", + agentIds: ["agent-b", "agent-a", "agent-a"], + requestId: "timeline-subscription-1", + }); + + expect(messages).toEqual([ + { + type: "agent.timeline.set_subscription.response", + payload: { + agentIds: ["agent-a", "agent-b"], + requestId: "timeline-subscription-1", + }, + }, + ]); +}); + +test("acknowledges a timeline subscription only to its socket source", async () => { + const messages: SessionOutboundMessage[] = []; + const targetedMessages: Array<{ source: object; message: SessionOutboundMessage }> = []; + const session = createSessionForTest({ messages, targetedMessages }); + const capableSocket = {}; + session.updateClientCapabilities({ selective_agent_timeline: true }, capableSocket); + + await session.handleMessage( + { + type: "agent.timeline.set_subscription.request", + agentIds: ["agent-a"], + requestId: "timeline-subscription-targeted", + }, + capableSocket, + ); + + expect(messages).toEqual([]); + expect(targetedMessages).toEqual([ + { + source: capableSocket, + message: { + type: "agent.timeline.set_subscription.response", + payload: { + agentIds: ["agent-a"], + requestId: "timeline-subscription-targeted", + }, + }, + }, + ]); +}); + +test("unions viewed timelines across socket sources and removes detached sources", async () => { + const messages: SessionOutboundMessage[] = []; + const agentEventListeners: Array<(event: AgentManagerEvent) => void> = []; + const session = createSessionForTest({ + messages, + agentManager: { + subscribe: vi.fn((listener: (event: AgentManagerEvent) => void) => { + agentEventListeners.push(listener); + return () => {}; + }), + }, + }); + session.updateClientCapabilities({ selective_agent_timeline: true }); + const firstSocket = {}; + const secondSocket = {}; + session.updateClientCapabilities({ selective_agent_timeline: true }, firstSocket); + session.updateClientCapabilities({ selective_agent_timeline: true }, secondSocket); + + await session.handleMessage( + { + type: "agent.timeline.set_subscription.request", + agentIds: ["agent-a"], + requestId: "timeline-subscription-a", + }, + firstSocket, + ); + await session.handleMessage( + { + type: "agent.timeline.set_subscription.request", + agentIds: ["agent-b"], + requestId: "timeline-subscription-b", + }, + secondSocket, + ); + messages.length = 0; + + if (agentEventListeners.length === 0) throw new Error("Agent event listener was not installed"); + const forward = (event: AgentManagerEvent) => { + for (const listener of agentEventListeners) listener(event); + }; + forward({ + type: "agent_stream", + agentId: "agent-a", + event: { + type: "timeline", + provider: "mock", + item: { type: "assistant_message", messageId: "message-a", text: "A" }, + }, + }); + forward({ + type: "agent_stream", + agentId: "agent-b", + event: { + type: "timeline", + provider: "mock", + item: { type: "assistant_message", messageId: "message-b", text: "B" }, + }, + }); + expect(messages.filter((message) => message.type === "agent_stream")).toHaveLength(2); + + const legacySocket = {}; + session.updateClientCapabilities(null, legacySocket); + expect(session.supportsForSource(CLIENT_CAPS.selectiveAgentTimeline, legacySocket)).toBe(false); + expect(session.supportsForSource(CLIENT_CAPS.selectiveAgentTimeline, firstSocket)).toBe(true); + messages.length = 0; + forward({ + type: "agent_stream", + agentId: "agent-not-viewed", + event: { + type: "timeline", + provider: "mock", + item: { type: "assistant_message", messageId: "message-legacy", text: "legacy" }, + }, + }); + expect(messages.some((message) => message.type === "agent_stream")).toBe(true); + + session.clearAgentTimelineSubscription(legacySocket); + + session.clearAgentTimelineSubscription(firstSocket); + messages.length = 0; + forward({ + type: "agent_stream", + agentId: "agent-a", + event: { + type: "timeline", + provider: "mock", + item: { type: "assistant_message", messageId: "message-a-2", text: "detached A" }, + }, + }); + forward({ + type: "agent_stream", + agentId: "agent-b", + event: { + type: "timeline", + provider: "mock", + item: { type: "assistant_message", messageId: "message-b-2", text: "retained B" }, + }, + }); + expect( + messages.flatMap((message) => + message.type === "agent_stream" ? [message.payload.agentId] : [], + ), + ).toEqual(["agent-b"]); +}); + +test("keeps selective delivery scoped per socket when a retained session also has a legacy socket", async () => { + const messages: SessionOutboundMessage[] = []; + const targetedMessages: Array<{ source: object; message: SessionOutboundMessage }> = []; + const agentEventListeners: Array<(event: AgentManagerEvent) => void> = []; + const session = createSessionForTest({ + messages, + targetedMessages, + agentManager: { + subscribe: vi.fn((listener: (event: AgentManagerEvent) => void) => { + agentEventListeners.push(listener); + return () => {}; + }), + }, + }); + const legacySocket = {}; + const selectiveSocket = {}; + session.updateClientCapabilities(null, legacySocket); + session.updateClientCapabilities({ selective_agent_timeline: true }, selectiveSocket); + await session.handleMessage( + { + type: "agent.timeline.set_subscription.request", + agentIds: ["viewed-agent"], + requestId: "timeline-subscription-selective", + }, + selectiveSocket, + ); + targetedMessages.length = 0; + + const listener = agentEventListeners[0]; + if (!listener) throw new Error("Agent event listener was not installed"); + listener({ + type: "agent_stream", + agentId: "not-viewed-agent", + event: { + type: "timeline", + provider: "mock", + item: { type: "assistant_message", messageId: "message-global", text: "global" }, + }, + }); + + expect(messages).toEqual([]); + expect(targetedMessages).toEqual([ + { + source: legacySocket, + message: expect.objectContaining({ + type: "agent_stream", + payload: expect.objectContaining({ agentId: "not-viewed-agent" }), + }), + }, + ]); +}); + +test("sends project updates only to capable sockets in a retained session", () => { + const messages: SessionOutboundMessage[] = []; + const targetedMessages: Array<{ source: object; message: SessionOutboundMessage }> = []; + const session = createSessionForTest({ messages, targetedMessages }); + const legacySocket = {}; + const capableSocket = {}; + session.updateClientCapabilities(null, legacySocket); + session.updateClientCapabilities({ [CLIENT_CAPS.projectUpdates]: true }, capableSocket); + + session.emitProjectUpdate({ + kind: "upsert", + project: createPersistedProjectRecord({ + projectId: "project-capable-socket", + rootPath: "/tmp/project-capable-socket", + kind: "git", + displayName: "project-capable-socket", + createdAt: "2026-07-17T00:00:00.000Z", + updatedAt: "2026-07-17T00:00:00.000Z", + }), + }); + + expect(messages).toEqual([]); + expect(targetedMessages).toEqual([ + { + source: capableSocket, + message: expect.objectContaining({ + type: "project.update", + payload: expect.objectContaining({ kind: "upsert" }), + }), + }, + ]); +}); + describe("agent config setters", () => { + function liveAgentManager(overrides: { [K in keyof SessionOptions["agentManager"]]?: unknown }): { + [K in keyof SessionOptions["agentManager"]]?: unknown; + } { + return { + waitForAgentClose: vi.fn().mockResolvedValue(undefined), + touchAgentActivity: vi.fn(() => ({ id: "agent-1" })), + ...overrides, + }; + } + test("set_agent_mode_request: success emits accepted response carrying the notice", async () => { const messages: SessionOutboundMessage[] = []; const notice = { type: "info", message: "Switched to plan mode" } as const; const session = createSessionForTest({ messages, - agentManager: { setAgentMode: vi.fn().mockResolvedValue(notice) }, + agentManager: liveAgentManager({ setAgentMode: vi.fn().mockResolvedValue(notice) }), }); await session.handleMessage({ @@ -4588,7 +4943,9 @@ describe("agent config setters", () => { const messages: SessionOutboundMessage[] = []; const session = createSessionForTest({ messages, - agentManager: { setAgentMode: vi.fn().mockRejectedValue(new Error("mode boom")) }, + agentManager: liveAgentManager({ + setAgentMode: vi.fn().mockRejectedValue(new Error("mode boom")), + }), }); await session.handleMessage({ @@ -4623,7 +4980,7 @@ describe("agent config setters", () => { const messages: SessionOutboundMessage[] = []; const session = createSessionForTest({ messages, - agentManager: { setAgentModel: vi.fn().mockResolvedValue(undefined) }, + agentManager: liveAgentManager({ setAgentModel: vi.fn().mockResolvedValue(undefined) }), }); await session.handleMessage({ @@ -4645,7 +5002,9 @@ describe("agent config setters", () => { const messages: SessionOutboundMessage[] = []; const session = createSessionForTest({ messages, - agentManager: { setAgentModel: vi.fn().mockRejectedValue(new Error("model boom")) }, + agentManager: liveAgentManager({ + setAgentModel: vi.fn().mockRejectedValue(new Error("model boom")), + }), }); await session.handleMessage({ @@ -4680,7 +5039,7 @@ describe("agent config setters", () => { const messages: SessionOutboundMessage[] = []; const session = createSessionForTest({ messages, - agentManager: { setAgentFeature: vi.fn().mockResolvedValue(undefined) }, + agentManager: liveAgentManager({ setAgentFeature: vi.fn().mockResolvedValue(undefined) }), }); await session.handleMessage({ @@ -4703,7 +5062,9 @@ describe("agent config setters", () => { const messages: SessionOutboundMessage[] = []; const session = createSessionForTest({ messages, - agentManager: { setAgentFeature: vi.fn().mockRejectedValue(new Error("feature boom")) }, + agentManager: liveAgentManager({ + setAgentFeature: vi.fn().mockRejectedValue(new Error("feature boom")), + }), }); await session.handleMessage({ @@ -4740,7 +5101,9 @@ describe("agent config setters", () => { const notice = { type: "warning", message: "Thinking budget reduced" } as const; const session = createSessionForTest({ messages, - agentManager: { setAgentThinkingOption: vi.fn().mockResolvedValue(notice) }, + agentManager: liveAgentManager({ + setAgentThinkingOption: vi.fn().mockResolvedValue(notice), + }), }); await session.handleMessage({ @@ -4768,9 +5131,9 @@ describe("agent config setters", () => { const messages: SessionOutboundMessage[] = []; const session = createSessionForTest({ messages, - agentManager: { + agentManager: liveAgentManager({ setAgentThinkingOption: vi.fn().mockRejectedValue(new Error("thinking boom")), - }, + }), }); await session.handleMessage({ diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 2416c77e3..b974559cb 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,7 +1,7 @@ import equal from "fast-deep-equal"; import { v4 as uuidv4 } from "uuid"; import { lstat, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises"; -import { basename, normalize, resolve, sep } from "path"; +import { resolve, sep } from "path"; import { homedir } from "node:os"; import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; import { @@ -36,7 +36,7 @@ import { isStoredAgentProviderAvailable, toAgentPersistenceHandle, } from "./persistence-hooks.js"; -import { ensureAgentLoaded } from "./agent/agent-loading.js"; +import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent/agent-loading.js"; import { formatSystemNotificationPrompt, sendPromptToAgent, @@ -61,6 +61,7 @@ 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 type { ProjectUpdate } from "./workspace-reconciliation-service.js"; import { CLIENT_SHUTDOWN_RPC_REASON, normalizeClientRestartRpcReason, @@ -76,6 +77,7 @@ import type { ManagedAgent, } from "./agent/agent-manager.js"; import { createAgentCommand } from "./agent/create-agent/create.js"; +import { resolveCreateAgentIntent, type CreateAgentIntent } from "./agent/create-agent/intent.js"; import { archiveAgentCommand, cancelAgentRunCommand, @@ -119,6 +121,7 @@ import { } from "./agent/import-sessions.js"; import { checkoutLiteFromGitSnapshot, + checkoutFromPersistedWorkspacePlacement, deriveWorkspaceDisplayName, } from "./workspace-registry-model.js"; import { resolveWorkspaceIdForPath } from "./resolve-workspace-id-for-path.js"; @@ -150,11 +153,15 @@ import { AgentConfigSession } from "./session/agent-config/agent-config-session. import { ProjectConfigSession } from "./session/project-config/project-config-session.js"; import { DaemonSession, type DaemonRuntimeConfig } from "./session/daemon/daemon-session.js"; import type { DaemonWebSocketRuntimeDiagnosticSnapshot } from "./session/daemon/diagnostics.js"; +import type { HubRelationshipManagement } from "./hub/relationship-controller.js"; +import { HubExecutionController } from "./hub/execution-controller.js"; +import type { HubExecutionAgents } from "./hub/daemon-executions.js"; import { DownloadTokenStore } from "./file-download/token-store.js"; import { PushTokenStore } from "./push/token-store.js"; import { archivePersistedWorkspaceRecord, archiveWorkspaceContents, + requireActiveWorkspaceForArchive, } from "./workspace-archive-service.js"; import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js"; import type { ServiceProxySubsystem } from "./service-proxy.js"; @@ -165,6 +172,7 @@ import { } from "./session/git-mutation/git-mutation-service.js"; import { createWorkspaceProvisioningService, + WorkspaceProvisioningError, type WorkspaceProvisioningService, } from "./session/workspace-provisioning/workspace-provisioning-service.js"; import { @@ -199,13 +207,13 @@ import type { ForgeService } from "../services/forge-service.js"; import type { ProviderUsageService } from "../services/quota-fetcher/service.js"; import { summarizeFetchWorkspacesEntries, + workspaceIdsForProjects, workspaceIdsOnCheckout, WorkspaceDirectory, type WorkspaceUpdatesFilter, } from "./workspace-directory.js"; import { shouldEmitPendingBootstrapUpdate } from "./workspace-bootstrap-dedupe.js"; import { - createLocalCheckoutWorkspace, createPaseoWorktree, type CreatePaseoWorktreeInput, type CreatePaseoWorktreeResult, @@ -222,19 +230,15 @@ import { handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage, } from "./worktree-session.js"; import { archiveByScope, type ActiveWorkspaceRef } from "./workspace-archive-service.js"; -import { - WorktreeRequestError, - toWorktreeRequestError, - toWorktreeWireError, -} from "./worktree-errors.js"; +import { WorktreeRequestError, toWorktreeWireError } from "./worktree-errors.js"; import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; import { createProjectDirectory, ProjectDirectoryRequestError, } from "./project-directory-service.js"; -import { type WorktreeConfig, createWorktree } from "../utils/worktree.js"; import { runGitCommand } from "../utils/run-git-command.js"; import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js"; +import { resolveWorktreeSourceCwd } from "./workspace-source.js"; // TODO: Remove once all app store clients are on >=0.1.45 and understand arbitrary provider strings. // Clients before 0.1.45 validate providers with z.enum(["claude", "codex", "opencode"]) and reject @@ -259,49 +263,6 @@ function resolveSubscriptionId( return uuidv4(); } -function buildWorkspaceCheckout( - workspace: PersistedWorkspaceRecord, - project: PersistedProjectRecord, - // The persisted `branch` field is the source of truth, but it is null for - // records created before branch was lifted to its own field (no migrations, - // per data-model.md) and for any path that didn't backfill it. Fall back to - // the live git branch so checkout.currentBranch never regresses to null. - fallbackBranch?: string | null, -): ProjectPlacementPayload["checkout"] { - if (project.kind !== "git") { - return { - cwd: workspace.cwd, - isGit: false, - currentBranch: null, - remoteUrl: null, - worktreeRoot: null, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }; - } - const currentBranch = workspace.branch ?? fallbackBranch ?? null; - if (workspace.kind === "worktree") { - return { - cwd: workspace.cwd, - isGit: true, - currentBranch, - remoteUrl: null, - worktreeRoot: workspace.cwd, - isPaseoOwnedWorktree: true, - mainRepoRoot: project.rootPath, - }; - } - return { - cwd: workspace.cwd, - isGit: true, - currentBranch, - remoteUrl: null, - worktreeRoot: workspace.cwd, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }; -} - function isAppVersionAtLeast(appVersion: string | null, minVersion: string): boolean { if (!appVersion) return false; // Strip prerelease suffix: "0.1.45-beta.4" -> "0.1.45" @@ -372,6 +333,14 @@ type FetchAgentsResponsePayload = Extract< type FetchAgentsResponseEntry = FetchAgentsResponsePayload["entries"][number]; type FetchAgentsResponsePageInfo = FetchAgentsResponsePayload["pageInfo"]; type AgentUpdatesFilter = FetchAgentsRequestFilter; +type CreateAgentRequestMessage = Extract; + +interface ResolvedSessionCreateAgentIntent { + config: AgentSessionConfig; + intent: CreateAgentIntent; + createdDirectoryWorkspace: boolean; +} + type FetchWorkspacesRequestMessage = Extract< SessionInboundMessage, { type: "fetch_workspaces_request" } @@ -426,9 +395,11 @@ type AgentMcpTransportFactory = () => Promise; export interface SessionOptions { clientId: string; + scopes: readonly string[]; appVersion?: string | null; clientCapabilities?: Record | null; onMessage: (msg: SessionOutboundMessage) => void; + onMessageToSource?: (source: object, msg: SessionOutboundMessage) => void; onBinaryMessage?: (frame: Uint8Array) => void; getTransportBufferedAmount?: () => number | null; onLifecycleIntent?: (intent: SessionLifecycleIntent) => void; @@ -462,6 +433,8 @@ export interface SessionOptions { terminalManager: TerminalManager | null; providerSnapshotManager: ProviderSnapshotManager; providerUsageService: ProviderUsageService; + hubExecutionAgents?: HubExecutionAgents; + hubRelationships?: HubRelationshipManagement; serviceProxy?: ServiceProxySubsystem; scriptRuntimeStore?: WorkspaceScriptRuntimeStore; workspaceSetupSnapshots?: Map; @@ -525,6 +498,34 @@ function parseClientCapabilities( return new Set(result); } +export function isSessionRpcAllowed(scopes: readonly string[], rpcName: string): boolean { + return scopes.some((scope) => { + if (scope === "*" || scope === rpcName) { + return true; + } + if (!scope.endsWith(".*")) { + return false; + } + return rpcName.startsWith(scope.slice(0, -1)); + }); +} + +function sessionRequestId(message: SessionInboundMessage): string | null { + if ("requestId" in message && typeof message.requestId === "string") { + return message.requestId; + } + if ( + "payload" in message && + typeof message.payload === "object" && + message.payload !== null && + "requestId" in message.payload && + typeof message.payload.requestId === "string" + ) { + return message.payload.requestId; + } + return null; +} + interface AgentTimelineProjectionSelection { timeline: AgentTimelineFetchResult; entries: TimelineProjectionEntry[]; @@ -554,10 +555,14 @@ function describeRegistryTransition(record: ArchivedRecordSnapshot | null): Regi */ export class Session { private readonly clientId: string; + private scopes: readonly string[]; private appVersion: string | null; private clientCapabilities: ReadonlySet; private readonly sessionId: string; private readonly onMessage: (msg: SessionOutboundMessage) => void; + private readonly onMessageToSource: + | ((source: object, msg: SessionOutboundMessage) => void) + | null; private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null; private readonly getTransportBufferedAmount: () => number | null; private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null; @@ -583,6 +588,10 @@ export class Session { private readonly daemonConfigStore: DaemonConfigStore; private readonly pushTokenStore: PushTokenStore; private unsubscribeAgentEvents: (() => void) | null = null; + private viewedTimelineAgentIds = new Set(); + private readonly viewedTimelineAgentIdsBySource = new Map>(); + private readonly clientCapabilitiesBySource = new Map>(); + private readonly defaultTimelineSubscriptionSource = {}; private unsubscribeTerminalWorkspaceContributionEvents: (() => void) | null = null; private readonly agentUpdates: AgentUpdatesService; private workspaceUpdatesSubscription: WorkspaceUpdatesSubscriptionState | null = null; @@ -616,15 +625,18 @@ export class Session { private readonly agentConfigSession: AgentConfigSession; private readonly projectConfigSession: ProjectConfigSession; private readonly daemonSession: DaemonSession; + private readonly hubExecutionController: HubExecutionController | null; private readonly workspaceScripts: WorkspaceScriptsService; private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch; constructor(options: SessionOptions) { const { clientId, + scopes, appVersion, clientCapabilities, onMessage, + onMessageToSource, onBinaryMessage, getTransportBufferedAmount, onLifecycleIntent, @@ -671,10 +683,12 @@ export class Session { getWebSocketRuntimeMetrics, } = options; this.clientId = clientId; + this.scopes = [...scopes]; this.appVersion = appVersion ?? null; this.clientCapabilities = parseClientCapabilities(clientCapabilities); this.sessionId = uuidv4(); this.onMessage = onMessage; + this.onMessageToSource = onMessageToSource ?? null; this.onBinaryMessage = onBinaryMessage ?? null; this.getTransportBufferedAmount = getTransportBufferedAmount ?? (() => 0); this.onLifecycleIntent = onLifecycleIntent ?? null; @@ -717,10 +731,11 @@ export class Session { logger: this.sessionLogger, }); this.workspaceRecovery = createWorkspaceRecoveryService({ + paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId), getProject: (projectId) => this.projectRegistry.get(projectId), isDirectory: (path) => this.filesystem.isDirectory(path), - recreateWorktree: (workspace) => this.recreateArchivedWorktree(workspace), unarchiveWorkspace: async (workspace) => { await this.workspaceProvisioning.ensureWorkspaceRecordUnarchived(workspace); }, @@ -801,6 +816,13 @@ export class Session { emit: (msg) => this.emit(msg), }, operations: { + ensureLoaded: async (agentId) => { + await ensureUnarchivedAgentLoaded(agentId, { + agentManager, + agentStorage, + logger: this.sessionLogger, + }); + }, setMode: async (agentId, modeId) => (await setAgentModeCommand({ agentManager }, { agentId, modeId })).notice, setModel: (agentId, modelId) => agentManager.setAgentModel(agentId, modelId), @@ -834,7 +856,14 @@ export class Session { listProjects: () => this.projectRegistry.list(), listWorkspaces: () => this.workspaceRegistry.list(), logger: this.sessionLogger, + hubRelationships: options.hubRelationships, }); + this.hubExecutionController = options.hubExecutionAgents + ? new HubExecutionController({ + agents: options.hubExecutionAgents, + send: (message) => this.emit(message), + }) + : null; this.daemonConfigStore = daemonConfigStore; this.terminalManager = terminalManager; this.terminalController = new TerminalSessionController({ @@ -897,6 +926,7 @@ export class Session { scriptRuntimeStore: this.scriptRuntimeStore, terminalManager: this.terminalManager, workspaceRegistry: this.workspaceRegistry, + projectRegistry: this.projectRegistry, workspaceGitService: this.workspaceGitService, getDaemonTcpPort: this.getDaemonTcpPort, getDaemonTcpHost: this.getDaemonTcpHost, @@ -963,14 +993,134 @@ export class Session { } } - updateClientCapabilities(capabilities: Record | null): void { + updateClientCapabilities(capabilities: Record | null, source?: object): void { this.clientCapabilities = parseClientCapabilities(capabilities); + if (source) { + this.clientCapabilitiesBySource.set(source, this.clientCapabilities); + } + if (!source && !this.supports(CLIENT_CAPS.selectiveAgentTimeline)) { + this.viewedTimelineAgentIdsBySource.clear(); + this.viewedTimelineAgentIds.clear(); + } + } + + clearAgentTimelineSubscription(source: object): void { + this.clientCapabilitiesBySource.delete(source); + if (this.viewedTimelineAgentIdsBySource.delete(source)) { + this.rebuildViewedTimelineAgentIds(); + } + } + + private replaceAgentTimelineSubscription(source: object | undefined, agentIds: string[]): void { + const subscriptionSource = source ?? this.defaultTimelineSubscriptionSource; + if (agentIds.length === 0) this.viewedTimelineAgentIdsBySource.delete(subscriptionSource); + else this.viewedTimelineAgentIdsBySource.set(subscriptionSource, new Set(agentIds)); + this.rebuildViewedTimelineAgentIds(); + } + + private rebuildViewedTimelineAgentIds(): void { + const viewedAgentIds = new Set(); + for (const agentIds of this.viewedTimelineAgentIdsBySource.values()) { + for (const agentId of agentIds) viewedAgentIds.add(agentId); + } + this.viewedTimelineAgentIds = viewedAgentIds; + } + + private usesSelectiveTimelineDelivery(): boolean { + if (this.clientCapabilitiesBySource.size === 0) { + return this.supports(CLIENT_CAPS.selectiveAgentTimeline); + } + for (const capabilities of this.clientCapabilitiesBySource.values()) { + if (!capabilities.has(CLIENT_CAPS.selectiveAgentTimeline)) return false; + } + return true; + } + + private forwardAgentStream( + event: Extract, + serializedEvent: Extract["payload"]["event"], + ): void { + if (this.clientCapabilitiesBySource.size === 0 || !this.onMessageToSource) { + if (this.usesSelectiveTimelineDelivery() && serializedEvent.type === "attention_required") { + this.emit({ + type: "agent_attention_required", + payload: { + agentId: event.agentId, + reason: serializedEvent.reason, + timestamp: serializedEvent.timestamp, + shouldNotify: serializedEvent.shouldNotify, + ...(serializedEvent.notification ? { notification: serializedEvent.notification } : {}), + }, + }); + } else if ( + !this.usesSelectiveTimelineDelivery() || + this.viewedTimelineAgentIds.has(event.agentId) + ) { + this.emit({ + type: "agent_stream", + payload: this.buildAgentStreamPayload(event, serializedEvent), + }); + } + return; + } + + for (const [source, capabilities] of this.clientCapabilitiesBySource) { + const supportsSelectiveDelivery = capabilities.has(CLIENT_CAPS.selectiveAgentTimeline); + if (supportsSelectiveDelivery && serializedEvent.type === "attention_required") { + this.onMessageToSource(source, { + type: "agent_attention_required", + payload: { + agentId: event.agentId, + reason: serializedEvent.reason, + timestamp: serializedEvent.timestamp, + shouldNotify: serializedEvent.shouldNotify, + ...(serializedEvent.notification ? { notification: serializedEvent.notification } : {}), + }, + }); + continue; + } + if ( + supportsSelectiveDelivery && + !this.viewedTimelineAgentIdsBySource.get(source)?.has(event.agentId) + ) { + continue; + } + this.onMessageToSource(source, { + type: "agent_stream", + payload: this.buildAgentStreamPayload(event, serializedEvent), + }); + } } supports(capability: ClientCapability): boolean { return this.clientCapabilities.has(capability); } + supportsForSource(capability: ClientCapability, source: object): boolean { + return ( + this.clientCapabilitiesBySource.get(source)?.has(capability) ?? this.supports(capability) + ); + } + + emitProjectUpdate(update: ProjectUpdate): void { + const message: SessionOutboundMessage = { + type: "project.update", + payload: + update.kind === "upsert" + ? { kind: "upsert", project: this.buildProjectDescriptor(update.project) } + : update, + }; + if (this.clientCapabilitiesBySource.size === 0 || !this.onMessageToSource) { + if (this.supports(CLIENT_CAPS.projectUpdates)) this.emit(message); + return; + } + for (const [source, capabilities] of this.clientCapabilitiesBySource) { + if (capabilities.has(CLIENT_CAPS.projectUpdates)) { + this.onMessageToSource(source, message); + } + } + } + async syncWorkspaceGitObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise { await this.workspaceGitObserver.syncObserverForWorkspace(workspace); } @@ -979,6 +1129,16 @@ export class Session { await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], { skipReconcile: true }); } + private async emitCreatedWorkspaceUpdate(workspace: WorkspaceDescriptorPayload): Promise { + if (this.workspaceUpdatesSubscription) { + await this.emitWorkspaceUpdateForWorkspaceId(workspace.id); + return; + } + // COMPAT(workspaceCreateCausalUpdate): added in v0.1.106, remove after 2027-01-12. + // Older clients create before subscribing and require the causal update beside the response. + this.emit({ type: "workspace_update", payload: { kind: "upsert", workspace } }); + } + async archiveWorkspaceRecordForExternalMutation(workspaceId: string): Promise { await this.archiveWorkspaceRecord(workspaceId); } @@ -994,8 +1154,24 @@ export class Session { this.clearWorkspaceArchiving(workspaceIds); } - async emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIds: Iterable): Promise { - await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds); + async emitWorkspaceUpdatesForExternalWorkspaceIds( + workspaceIds: Iterable, + options?: { skipReconcile?: boolean }, + ): Promise { + await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds, options); + } + + async syncWorkspaceGitObserversForExternalWorkspaceIds( + workspaceIds: Iterable, + ): Promise { + await Promise.all( + Array.from(new Set(workspaceIds)).map(async (workspaceId) => { + const workspace = await this.workspaceRegistry.get(workspaceId); + if (workspace && !workspace.archivedAt) { + await this.workspaceGitObserver.syncObserverForWorkspace(workspace); + } + }), + ); } async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise { @@ -1271,10 +1447,7 @@ export class Session { "agent.session.forward_stream", ); - this.emit({ - type: "agent_stream", - payload: this.buildAgentStreamPayload(event, serializedEvent), - }); + this.forwardAgentStream(event, serializedEvent); if (event.event.type === "permission_requested") { this.emit({ @@ -1352,9 +1525,15 @@ export class Session { if (!project) { throw new Error(`Project not found for workspace ${workspace.workspaceId}`); } - const liveBranch = - this.workspaceGitService.peekSnapshot(workspace.cwd)?.git.currentBranch ?? null; - const checkout = buildWorkspaceCheckout(workspace, project, liveBranch); + const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd); + const checkout = checkoutFromPersistedWorkspacePlacement({ + workspace, + // COMPAT(workspacePlacementBackfill): added in v0.1.107, remove after 2027-01-15. + // Legacy records can lack branch and worktreeRoot because persisted registries + // are not migrated in place. + fallbackBranch: snapshot?.git.currentBranch ?? null, + fallbackWorktreeRoot: snapshot?.git.repoRoot, + }); return { projectKey: project.projectId, projectName: resolveProjectDisplayName(project), @@ -1377,7 +1556,7 @@ export class Session { /** * Main entry point for processing session messages */ - public async handleMessage(msg: SessionInboundMessage): Promise { + public async handleMessage(msg: SessionInboundMessage, source?: object): Promise { this.inflightRequests++; if (this.inflightRequests > this.peakInflightRequests) { this.peakInflightRequests = this.inflightRequests; @@ -1390,8 +1569,23 @@ export class Session { }, "agent.session.inbound", ); + if (!isSessionRpcAllowed(this.scopes, msg.type)) { + const requestId = sessionRequestId(msg); + if (requestId) { + this.emit({ + type: "rpc_error", + payload: { + requestId, + requestType: msg.type, + error: `Session is not authorized for ${msg.type}`, + code: "access_denied", + }, + }); + } + return; + } try { - await this.dispatchInboundMessage(msg); + await this.dispatchInboundMessage(msg, source); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); this.sessionLogger.error({ err }, "Error handling message"); @@ -1429,12 +1623,17 @@ export class Session { } } - private async dispatchInboundMessage(msg: SessionInboundMessage): Promise { + public setScopes(scopes: readonly string[]): void { + this.scopes = [...scopes]; + } + + private async dispatchInboundMessage(msg: SessionInboundMessage, source?: object): Promise { const promise = this.dispatchVoiceAndControlMessage(msg) ?? this.dispatchAgentRewindMessage(msg) ?? this.dispatchAgentRelationshipMessage(msg) ?? - this.dispatchAgentTimelineMessage(msg) ?? + this.dispatchAgentTimelineMessage(msg, source) ?? + this.dispatchHubExecutionMessage(msg) ?? this.dispatchAgentLifecycleMessage(msg) ?? this.dispatchAgentConfigMessage(msg) ?? this.dispatchCheckoutMessage(msg) ?? @@ -1516,7 +1715,10 @@ export class Session { } } - private dispatchAgentTimelineMessage(msg: SessionInboundMessage): Promise | undefined { + private dispatchAgentTimelineMessage( + msg: SessionInboundMessage, + source?: object, + ): Promise | undefined { switch (msg.type) { case "fetch_agent_timeline_request": return this.handleFetchAgentTimelineRequest(msg); @@ -1524,6 +1726,23 @@ export class Session { return this.handleProviderSubagentListRequest(msg); case "agent.provider_subagents.timeline.get.request": return this.handleProviderSubagentTimelineRequest(msg); + case "agent.timeline.set_subscription.request": { + const agentIds = [...new Set(msg.agentIds)].sort(); + if ( + source + ? this.supportsForSource(CLIENT_CAPS.selectiveAgentTimeline, source) + : this.supports(CLIENT_CAPS.selectiveAgentTimeline) + ) { + this.replaceAgentTimelineSubscription(source, agentIds); + } + const response: SessionOutboundMessage = { + type: "agent.timeline.set_subscription.response", + payload: { agentIds, requestId: msg.requestId }, + }; + if (source && this.onMessageToSource) this.onMessageToSource(source, response); + else this.emit(response); + return undefined; + } case "agent.fork_context.request": return this.handleAgentForkContextRequest(msg); default: @@ -1531,6 +1750,12 @@ export class Session { } } + private dispatchHubExecutionMessage(msg: SessionInboundMessage): Promise | undefined { + return msg.type === "hub.execution.agent.create.request" + ? this.hubExecutionController?.createAgent(msg) + : undefined; + } + private dispatchAgentLifecycleMessage(msg: SessionInboundMessage): Promise | undefined { switch (msg.type) { case "fetch_agents_request": @@ -1594,6 +1819,10 @@ export class Session { return this.daemonSession.handleGetStatusRequest(msg); case "daemon.get_pairing_offer.request": return this.daemonSession.handleGetPairingOfferRequest(msg); + case "hub.management.daemon.connect.request": + case "hub.management.daemon.get_status.request": + case "hub.management.daemon.disconnect.request": + return this.daemonSession.handleHubRelationshipRequest(msg); case "diagnostics.request": return this.daemonSession.handleDiagnosticsRequest(msg); case "daemon.update.request": @@ -1942,7 +2171,7 @@ export class Session { try { await this.agentStorage.remove(agentId); - await this.agentManager.deleteCommittedTimeline(agentId); + await this.agentManager.deleteAgentState(agentId); } catch (error) { this.sessionLogger.error({ err: error, agentId }, `Failed to fully delete agent ${agentId}`); } @@ -2567,9 +2796,7 @@ export class Session { /** * Handle create agent request */ - private async handleCreateAgentRequest( - msg: Extract, - ): Promise { + private async handleCreateAgentRequest(msg: CreateAgentRequestMessage): Promise { const { config, worktreeName, @@ -2582,7 +2809,6 @@ export class Session { autoArchive, images, attachments, - labels, env, } = msg; this.sessionLogger.info( @@ -2595,6 +2821,12 @@ export class Session { let createdWorktreeForCleanup: CreatePaseoWorktreeWorkflowResult | null = null; let createdAgentId: string | null = null; try { + const requestedCwd = resolve(config.cwd); + const needsRequestedDirectory = + Boolean(worktreeName || git || worktree) || (!msg.workspaceId && !msg.callerAgentId); + if (needsRequestedDirectory && !(await this.filesystem.isDirectory(requestedCwd))) { + throw new Error(`Working directory does not exist or is not a directory: ${requestedCwd}`); + } const trimmedPrompt = initialPrompt?.trim(); const { provisionalTitle } = resolveCreateAgentTitles({ configTitle: config.title, @@ -2613,18 +2845,15 @@ export class Session { hasLegacyGitOptions: Boolean(git), }); createdWorktreeForCleanup = createdWorktree; - const createAgentConfig: AgentSessionConfig = createdWorktree - ? { ...config, cwd: createdWorktree.worktree.worktreePath } - : config; - const workspaceId = await this.workspaceProvisioning.resolveOrCreateWorkspaceIdForCreateAgent( - { - createdWorktree, - requestedWorkspaceId: msg.workspaceId, - cwd: createAgentConfig.cwd, - initialTitle: workspacePromptTitle, - }, - ); - const createdDirectoryWorkspaceForAgent = !createdWorktree && !msg.workspaceId; + const resolvedIntent = await this.resolveSessionCreateAgentIntent({ + request: msg, + 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}`); + } const { snapshot, liveSnapshot } = await createAgentCommand( { @@ -2637,8 +2866,8 @@ export class Session { }, { kind: "session", - config: createAgentConfig, - workspaceId, + config: resolvedIntent.config, + workspaceId: resolvedIntent.intent.workspaceId, worktreeName, initialPrompt, clientMessageId, @@ -2646,7 +2875,7 @@ export class Session { images, attachments, git, - labels, + labels: resolvedIntent.intent.labels, env, provisionalTitle, firstAgentContext, @@ -2656,14 +2885,14 @@ export class Session { ); createdAgentId = snapshot.id; await this.agentUpdates.forwardLiveAgent(snapshot); - if (createdDirectoryWorkspaceForAgent && trimmedPrompt) { + if (resolvedIntent.createdDirectoryWorkspace && trimmedPrompt) { this.workspaceAutoName.scheduleForDirectory( { - workspaceId, - cwd: createAgentConfig.cwd, + workspaceId: resolvedIntent.intent.workspaceId, + cwd: resolvedIntent.config.cwd, firstAgentContext, }, - { currentSelection: this.getFocusedAgentSelectionForCwd(createAgentConfig.cwd) }, + { currentSelection: this.getFocusedAgentSelectionForCwd(resolvedIntent.config.cwd) }, ); } this.createAgentLifecycleDispatch.registerAutoArchiveIfRequested({ @@ -2718,6 +2947,55 @@ export class Session { } } + private async resolveSessionCreateAgentIntent(input: { + request: CreateAgentRequestMessage; + createdWorktree: CreatePaseoWorktreeWorkflowResult | null; + workspacePromptTitle: string | null; + }): Promise { + const { request, createdWorktree } = input; + const callerAgent = request.callerAgentId + ? this.agentManager.getAgent(request.callerAgentId) + : null; + if (request.callerAgentId && !callerAgent) { + throw new Error(`Caller agent ${request.callerAgentId} not found`); + } + + let config = request.config; + + const intent = await resolveCreateAgentIntent({ + explicitWorkspaceId: createdWorktree?.workspace.workspaceId ?? request.workspaceId, + caller: callerAgent + ? { id: callerAgent.id, cwd: callerAgent.cwd, workspaceId: callerAgent.workspaceId } + : null, + labels: request.labels, + resolveWorkspace: async (workspaceId) => { + if (createdWorktree?.workspace.workspaceId === workspaceId) { + return { workspaceId, cwd: createdWorktree.workspace.cwd }; + } + const workspace = await this.workspaceRegistry.get(workspaceId); + if (!workspace || workspace.archivedAt) { + throw new Error(`Workspace ${workspaceId} not found`); + } + return { workspaceId, cwd: workspace.cwd }; + }, + createWorkspace: async () => ({ + workspaceId: await this.workspaceProvisioning.resolveOrCreateWorkspaceIdForCreateAgent({ + createdWorktree: null, + cwd: config.cwd, + initialTitle: input.workspacePromptTitle, + }), + cwd: config.cwd, + }), + }); + config = { ...config, cwd: intent.cwd }; + + return { + config, + intent, + createdDirectoryWorkspace: !createdWorktree && !request.workspaceId && !callerAgent, + }; + } + private async handleResumeAgentRequest( msg: Extract, ): Promise { @@ -3079,6 +3357,15 @@ export class Session { const agentIds = Array.isArray(agentId) ? agentId : [agentId]; try { + await Promise.all( + agentIds.map((id) => + ensureAgentLoaded(id, { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + logger: this.sessionLogger, + }), + ), + ); await Promise.all(agentIds.map((id) => this.agentManager.clearAgentAttention(id))); if (requestId) { const agents = ( @@ -3165,8 +3452,16 @@ export class Session { ); try { - const agents = this.agentManager.listAgents(); - const agent = agents.find((a) => a.id === agentId); + const existing = this.agentManager.getAgent(agentId); + const stored = existing ? null : await this.agentStorage.get(agentId); + const agent = + existing || (stored && !stored.archivedAt) + ? await ensureAgentLoaded(agentId, { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + logger: this.sessionLogger, + }) + : null; if (agent?.session?.listCommands) { const commands = await agent.session.listCommands(); @@ -3749,7 +4044,7 @@ export class Session { statusEnteredAt: null, activityAt: null, diffStat, - scripts: this.buildWorkspaceScriptPayloadSnapshot(workspace.workspaceId, workspace.cwd), + scripts: this.buildWorkspaceScriptPayloadSnapshot(workspace, resolvedProjectRecord), ...(resolvedProjectRecord ? { project: await this.buildProjectPlacementForWorkspace(workspace, resolvedProjectRecord), @@ -3825,7 +4120,7 @@ export class Session { projectCustomName: projectRecord?.customName ?? null, projectRootPath: projectRecord?.rootPath ?? result.repoRoot, workspaceDirectory: result.workspace.cwd, - projectKind: "git", + projectKind: projectRecord?.kind ?? "git", workspaceKind: result.workspace.kind, name: resolveWorkspaceName({ title: result.workspace.title, @@ -3857,7 +4152,7 @@ export class Session { projectRecord?: PersistedProjectRecord | null; includeGitData: boolean; }): Promise { - if (input.includeGitData && input.projectRecord?.kind === "git") { + if (input.includeGitData && input.workspace.kind !== "directory") { return this.describeWorkspaceRecordWithGitData(input.workspace, input.projectRecord); } return this.describeWorkspaceRecord(input.workspace, input.projectRecord); @@ -3967,6 +4262,8 @@ export class Session { continue; } } + const workspaceId = payload.kind === "upsert" ? payload.workspace.id : payload.id; + subscription.lastEmittedByWorkspaceId.set(workspaceId, payload); this.emit({ type: "workspace_update", payload, @@ -3986,65 +4283,6 @@ export class Session { }; } - private async recreateArchivedWorktree(workspace: PersistedWorkspaceRecord): Promise { - const branch = workspace.branch; - if (!branch) { - throw new WorktreeRequestError({ - code: "unknown", - message: `Workspace ${workspace.workspaceId} has no branch to restore`, - }); - } - const project = await this.projectRegistry.get(workspace.projectId); - if (!project) { - throw new WorktreeRequestError({ - code: "unknown", - message: `Project ${workspace.projectId} not found for workspace ${workspace.workspaceId}`, - }); - } - const projectRootExists = await this.filesystem - .isDirectory(project.rootPath) - .catch(() => false); - if (!projectRootExists) { - throw new WorktreeRequestError({ - code: "unknown", - message: `Project root is missing for ${workspace.projectId}: ${project.rootPath}`, - }); - } - - // Archiving through the default path (scope "workspace", worktreePath only) - // resolves repoRoot=null, so deletePaseoWorktree's `git worktree remove`/ - // `prune` is skipped and the admin registration survives — pinning the - // branch as "already checked out". Prune here frees any stale registration - // whose working dir is missing (a no-op for live worktrees) so the recreate - // below succeeds regardless of how the worktree was archived. - try { - await runGitCommand(["worktree", "prune"], { cwd: project.rootPath, timeout: 30_000 }); - } catch { - // not critical; git will prune lazily - } - - let result: WorktreeConfig; - try { - result = await createWorktree({ - cwd: project.rootPath, - worktreeSlug: basename(workspace.cwd), - source: { kind: "checkout-branch", branchName: branch }, - runSetup: false, - paseoHome: this.paseoHome, - worktreesRoot: this.worktreesRoot, - }); - } catch (error) { - throw toWorktreeRequestError(error); - } - - if (normalize(result.worktreePath) !== normalize(workspace.cwd)) { - throw new WorktreeRequestError({ - code: "unknown", - message: `Recreated worktree diverged from ${workspace.cwd}: ${result.worktreePath}`, - }); - } - } - private async restoreWorkspaceAndEmit(workspaceId: string): Promise { await this.workspaceRecovery.restore(workspaceId); const workspace = await this.workspaceRegistry.get(workspaceId); @@ -4093,9 +4331,8 @@ export class Session { ...(options?.resolveDefaultBranch ? { resolveDefaultBranch: options.resolveDefaultBranch } : {}), - projectRegistry: this.projectRegistry, - workspaceRegistry: this.workspaceRegistry, workspaceGitService: this.workspaceGitService, + workspaceProvisioning: this.workspaceProvisioning, }); void Promise.all([ this.gitMutation.notifyGitMutation(input.cwd, "create-worktree"), @@ -4117,6 +4354,9 @@ export class Session { workspaceId: workspace.workspaceId, cwd: workspace.cwd, kind: workspace.kind, + worktreeRoot: workspace.worktreeRoot, + isPaseoOwnedWorktree: workspace.isPaseoOwnedWorktree, + mainRepoRoot: workspace.mainRepoRoot, })); } @@ -4148,21 +4388,12 @@ export class Session { ); } - await this.teardownArchivedWorkspace({ - workspaceId: existingWorkspace.workspaceId, - cwd: existingWorkspace.cwd, - }); + await this.teardownArchivedWorkspace(existingWorkspace.workspaceId); } - // Git watch and subscription state is keyed by directory; the script runtime - // store is keyed by the opaque workspace id. Each cleanup uses its own key so an - // opaque id is never resolved as a filesystem path. - private async teardownArchivedWorkspace(input: { - workspaceId: string; - cwd: string; - }): Promise { - this.workspaceGitObserver.removeForCwd(input.cwd); - this.scriptRuntimeStore?.removeForWorkspace(input.workspaceId); + private async teardownArchivedWorkspace(workspaceId: string): Promise { + this.workspaceGitObserver.removeForWorkspaceId(workspaceId); + this.scriptRuntimeStore?.removeForWorkspace(workspaceId); } private async reconcileAndEmitWorkspaceUpdates(): Promise { @@ -4197,16 +4428,12 @@ export class Session { result.changesApplied.map(async (change) => { switch (change.kind) { case "workspace_archived": - await this.teardownArchivedWorkspace({ - workspaceId: change.workspaceId, - cwd: change.directory, - }); + await this.teardownArchivedWorkspace(change.workspaceId); changedWorkspaceIds.add(change.workspaceId); break; case "workspace_updated": changedWorkspaceIds.add(change.workspaceId); break; - case "project_archived": case "project_updated": changedProjectIds.add(change.projectId); break; @@ -4215,10 +4442,11 @@ export class Session { ); if (changedProjectIds.size > 0) { - for (const workspace of await this.workspaceRegistry.list()) { - if (changedProjectIds.has(workspace.projectId)) { - changedWorkspaceIds.add(workspace.workspaceId); - } + for (const workspaceId of workspaceIdsForProjects( + await this.workspaceRegistry.list(), + changedProjectIds, + )) { + changedWorkspaceIds.add(workspaceId); } } @@ -4259,6 +4487,9 @@ export class Session { this.workspaceGitObserver.recordDescriptorState(workspaceId, nextWorkspace); if (!nextWorkspace) { + if (workspace && !subscription.lastEmittedByWorkspaceId.has(workspaceId)) { + continue; + } subscription.lastEmittedByWorkspaceId.delete(workspaceId); this.bufferOrEmitWorkspaceUpdate( subscription, @@ -4515,6 +4746,7 @@ export class Session { "fetch_workspaces_response_ready", ); const snapshot = this.buildBootstrapSnapshot(payload.entries); + this.seedWorkspaceSubscriptionSnapshot(subscriptionId, request.filter, payload.entries); this.emit({ type: "fetch_workspaces_response", @@ -4575,6 +4807,23 @@ export class Session { return { snapshotByWorkspaceId }; } + private seedWorkspaceSubscriptionSnapshot( + subscriptionId: string | null, + filter: FetchWorkspacesRequestFilter | undefined, + entries: FetchWorkspacesResponseEntry[], + ): void { + const subscription = this.workspaceUpdatesSubscription; + if (!subscription) return; + if (subscriptionId && subscription.subscriptionId !== subscriptionId) return; + if (!subscriptionId && !equal(subscription.filter, filter)) return; + for (const entry of entries) { + subscription.lastEmittedByWorkspaceId.set(entry.id, { + kind: "upsert", + workspace: entry, + }); + } + } + private async registerWorkspaceForImportedAgent( workspace: PersistedWorkspaceRecord, ): Promise { @@ -4605,6 +4854,7 @@ export class Session { { err: error, sourceKind: request.source.kind, requestId: request.requestId }, "Failed to create workspace", ); + const errorCode = error instanceof WorkspaceProvisioningError ? error.code : undefined; this.emit({ type: "workspace.create.response", payload: { @@ -4612,6 +4862,7 @@ export class Session { workspace: null, setupTerminalId: null, error: message, + errorCode, }, }); } @@ -4642,13 +4893,10 @@ export class Session { const explicitTitle = request.title?.trim() || null; const promptTitle = resolveFirstAgentPromptTitle(request.firstAgentContext); - const workspace = await createLocalCheckoutWorkspace( - { cwd, title: explicitTitle ?? promptTitle }, - { - projectRegistry: this.projectRegistry, - workspaceRegistry: this.workspaceRegistry, - workspaceGitService: this.workspaceGitService, - }, + const workspace = await this.workspaceProvisioning.createWorkspaceForDirectory( + cwd, + explicitTitle ?? promptTitle, + request.source.projectId, ); await this.syncWorkspaceGitObserverForWorkspace(workspace); const descriptor = await this.describeWorkspaceRecord(workspace); @@ -4661,7 +4909,7 @@ export class Session { error: null, }, }); - await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId); + await this.emitCreatedWorkspaceUpdate(descriptor); void this.workspaceGitService .getSnapshot(workspace.cwd, { force: true, includeForge: true, reason: "open_project" }) .catch((error) => { @@ -4706,10 +4954,7 @@ export class Session { return; } - const sourceCwd = await this.resolveWorktreeSourceCwd({ - cwd: source.cwd, - projectId: source.projectId, - }); + const sourceCwd = await resolveWorktreeSourceCwd(source, this.projectRegistry); const result = await this.createPaseoWorktreeWorkflow( { @@ -4718,24 +4963,17 @@ export class Session { worktreeSlug: source.worktreeSlug, action: source.action, refName: source.refName, + branchName: source.branchName, checkoutSource: source.checkoutSource, githubPrNumber: source.githubPrNumber, firstAgentContext: request.firstAgentContext, + title: request.title, }, source.baseBranch ? { resolveDefaultBranch: async () => source.baseBranch as string } : undefined, ); - if (request.title?.trim()) { - await this.workspaceRegistry.upsert({ - ...result.workspace, - title: request.title.trim(), - updatedAt: new Date().toISOString(), - }); - result.workspace.title = request.title.trim(); - } - const descriptor = await this.describeCreatedWorktreeWorkspace(result); this.emit({ type: "workspace.create.response", @@ -4746,24 +4984,7 @@ export class Session { error: null, }, }); - this.emit({ - type: "workspace_update", - payload: { kind: "upsert", workspace: descriptor }, - }); - } - - private async resolveWorktreeSourceCwd(input: { - cwd?: string; - projectId?: string; - }): Promise { - if (input.cwd) { - return expandTilde(input.cwd); - } - const project = await this.projectRegistry.get(input.projectId as string); - if (!project || project.archivedAt) { - throw new Error(`Project not found: ${input.projectId}`); - } - return project.rootPath; + await this.emitCreatedWorkspaceUpdate(descriptor); } private async handleOpenProjectRequest( @@ -5125,10 +5346,10 @@ 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( - workspaceId: string, - workspaceDirectory: string, + workspace: PersistedWorkspaceRecord, + project: PersistedProjectRecord | null, ): WorkspaceDescriptorPayload["scripts"] { - return this.workspaceScripts.buildSnapshot(workspaceId, workspaceDirectory); + return this.workspaceScripts.buildSnapshot(workspace, project); } private handleStartWorkspaceScriptRequest(request: StartWorkspaceScriptRequest): Promise { @@ -5234,15 +5455,10 @@ export class Session { request: Extract, ): Promise { try { - const existing = await this.workspaceRegistry.get(request.workspaceId); - if (!existing) { - throw new Error(`Workspace not found: ${request.workspaceId}`); - } - - const gitSnapshot = await this.workspaceGitService - .getSnapshot(existing.cwd) - .catch(() => null); - const repoRoot = gitSnapshot?.git?.repoRoot ?? null; + const existing = await requireActiveWorkspaceForArchive( + { listActiveWorkspaces: () => this.listActiveWorkspaceRefs() }, + request.workspaceId, + ); await archiveByScope( { @@ -5266,8 +5482,6 @@ export class Session { }, { scope: { kind: "workspace", workspaceId: existing.workspaceId }, - repoRoot, - paseoWorktreesBaseRoot: this.worktreesRoot, requestId: request.requestId, }, ); @@ -5657,6 +5871,11 @@ export class Session { msg: Extract, ): Promise { try { + await ensureUnarchivedAgentLoaded(msg.parentAgentId, { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + logger: this.sessionLogger, + }); this.emit({ type: "agent.provider_subagents.list.response", payload: { @@ -5684,6 +5903,11 @@ export class Session { ): Promise { const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail"); try { + await ensureUnarchivedAgentLoaded(msg.parentAgentId, { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + logger: this.sessionLogger, + }); const descriptor = this.agentManager.getProviderSubagent(msg.parentAgentId, msg.subagentId); if (!descriptor) { throw new Error("Provider subagent not found"); @@ -6029,6 +6253,9 @@ export class Session { * Emit a message to the client */ private emit(msg: SessionOutboundMessage): void { + if (msg.type !== "rpc_error" && !isSessionRpcAllowed(this.scopes, msg.type)) { + return; + } // JSON.stringify(msg) is only computed when trace is enabled — it runs for // every outbound message otherwise, and trace is disabled by default. // Optional-chained because test logger stubs don't implement isLevelEnabled. @@ -6066,6 +6293,7 @@ export class Session { this.unsubscribeAgentEvents = null; } this.agentUpdates.dispose(); + await this.hubExecutionController?.cleanup(); if (this.unsubscribeTerminalWorkspaceContributionEvents) { this.unsubscribeTerminalWorkspaceContributionEvents(); this.unsubscribeTerminalWorkspaceContributionEvents = null; 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 8460584fd..06daa4726 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -182,6 +182,7 @@ function createSessionForWorkspaceGitWatchTests(options?: { const session = new Session({ clientId: "test-client", + scopes: ["*"], onMessage: (message) => emitted.push(message as { type: string; payload: unknown }), logger: createStub(logger), downloadTokenStore: createStub({}), @@ -472,7 +473,6 @@ describe("workspace git watch targets", () => { onBranchChanged: handleBranchChange, }, ); - const sessionAny = session as unknown as SessionInternals; seedGitWorkspace({ projects, workspaces, @@ -482,7 +482,7 @@ describe("workspace git watch targets", () => { name: "old-branch", }); - syncGitObserver(session, "/tmp/repo", "ws-10"); + await session.syncWorkspaceGitObserversForExternalWorkspaceIds(["ws-10"]); subscriptions[0]?.listener( createWorkspaceRuntimeSnapshot("/tmp/repo", { @@ -498,16 +498,6 @@ describe("workspace git watch targets", () => { scriptName: "app", }), ]); - expect(sessionAny.buildWorkspaceScriptPayloadSnapshot("ws-10", "/tmp/repo")).toEqual([ - expect.objectContaining({ - scriptName: "app", - hostname: "app--new-branch--paseo.localhost", - localProxyUrl: "http://app--new-branch--paseo.localhost:6767", - publicProxyUrl: null, - proxyUrl: "http://app--new-branch--paseo.localhost:6767", - }), - ]); - await session.cleanup(); }); diff --git a/packages/server/src/server/session.workspace-resolution-invariants.test.ts b/packages/server/src/server/session.workspace-resolution-invariants.test.ts index f730a74cd..277320e54 100644 --- a/packages/server/src/server/session.workspace-resolution-invariants.test.ts +++ b/packages/server/src/server/session.workspace-resolution-invariants.test.ts @@ -88,6 +88,7 @@ function createHarness(input: { const session = new Session({ clientId: "test", + scopes: ["*"], appVersion: null, onMessage: (m) => emitted.push(m), logger: createStub(logger), @@ -112,6 +113,22 @@ function createHarness(input: { existsOnDisk: async () => true, list: async () => Array.from(projects.values()), get: async (id: string) => projects.get(id) ?? null, + getOrCreateActiveByRoot: async (allocation) => { + const existing = Array.from(projects.values()).find( + (project) => !project.archivedAt && project.rootPath === allocation.rootPath, + ); + if (existing) return existing; + const project = createPersistedProjectRecord({ + projectId: `prj_${projects.size.toString().padStart(16, "0")}`, + rootPath: allocation.rootPath, + kind: allocation.kind, + displayName: allocation.displayName, + createdAt: allocation.timestamp, + updatedAt: allocation.timestamp, + }); + projects.set(project.projectId, project); + return project; + }, upsert: async (record: PersistedProjectRecord) => { projects.set(record.projectId, record); }, @@ -310,10 +327,9 @@ test("S3: re-open active workspace by exact path returns the same record", async }); // ───────────────────────────────────────────────────────────────────────────── -// S4. Open a subdir of an active git workspace: canonicalizes UP to the repo -// root, returns the existing workspace. (Per "always go to the nearest git".) +// S4. Every selected path is an exact lexical root, even inside a Git checkout. // ───────────────────────────────────────────────────────────────────────────── -test("S4: open subdir of active git workspace returns the repo-root workspace", async () => { +test("S4: open subdir of active git workspace creates an independent exact-root workspace", async () => { const h = createHarness({ workspaces: [gitWorkspace(FOO)], projects: [gitProject(FOO)], @@ -321,8 +337,9 @@ test("S4: open subdir of active git workspace returns the repo-root workspace", }); await openProject(h.session, FOO_SUB); const resp = getOpenResponse(h.emitted, "req-1"); - expect(resp?.workspace?.id).toBe(workspaceByCwd(h.workspaces, FOO)?.workspaceId); - expect(h.workspaces.size).toBe(1); + expect(resp?.workspace?.workspaceDirectory).toBe(FOO_SUB); + expect(resp?.workspace?.projectId).not.toBe(workspaceByCwd(h.workspaces, FOO)?.projectId); + expect(h.workspaces.size).toBe(2); }); // ───────────────────────────────────────────────────────────────────────────── @@ -343,10 +360,10 @@ test("S5: open subdir of active non-git directory creates a SEPARATE workspace", }); // ───────────────────────────────────────────────────────────────────────────── -// S6. Open the EXACT path of an archived git workspace: this IS explicit user -// intent to re-open what they archived. Unarchive is correct here. +// S6. Explicit project opening allocates a fresh identity when only archived +// records exist. Agent restore is the separate path that restores ownership. // ───────────────────────────────────────────────────────────────────────────── -test("S6: re-opening an archived git workspace by exact path UNARCHIVES it", async () => { +test("S6: re-opening an archived git workspace by exact path creates a fresh project and workspace", async () => { const archivedAt = "2026-04-22T13:08:05.400Z"; const h = createHarness({ workspaces: [gitWorkspace(TOOLBOX, archivedAt)], @@ -354,8 +371,12 @@ test("S6: re-opening an archived git workspace by exact path UNARCHIVES it", asy gitRoots: [TOOLBOX], }); await openProject(h.session, TOOLBOX); - expect(workspaceByCwd(h.workspaces, TOOLBOX)?.archivedAt).toBeNull(); - expect(h.projects.get(TOOLBOX)?.archivedAt).toBeNull(); + const fresh = Array.from(h.workspaces.values()).find( + (workspace) => workspace.cwd === TOOLBOX && !workspace.archivedAt, + ); + expect(fresh?.workspaceId).not.toBe("ws-toolbox"); + expect(fresh?.projectId).toMatch(/^prj_[0-9a-f]{16}$/); + expect(h.projects.get(TOOLBOX)?.archivedAt).toBe(archivedAt); }); // ───────────────────────────────────────────────────────────────────────────── @@ -433,11 +454,10 @@ test("S10: opening a git repo nested inside an archived non-git directory create }); // ───────────────────────────────────────────────────────────────────────────── -// S11. Archive then re-add round-trip (project-level): opening the exact path -// of an archived project unarchives both the project and its workspace, -// reusing the same path-derived ids. +// S11. Archive then re-add produces a fresh opaque identity; it never reuses +// archived compatibility IDs. // ───────────────────────────────────────────────────────────────────────────── -test("S11: re-opening an archived project by exact path unarchives project + workspace and reuses ids", async () => { +test("S11: re-opening an archived project by exact path keeps archived records and allocates fresh ids", async () => { const archivedAt = "2026-04-22T13:08:05.400Z"; const h = createHarness({ workspaces: [gitWorkspace(TOOLBOX, archivedAt)], @@ -447,12 +467,11 @@ test("S11: re-opening an archived project by exact path unarchives project + wor await openProject(h.session, TOOLBOX); const resp = getOpenResponse(h.emitted, "req-1"); expect(resp?.error).toBeNull(); - expect(resp?.workspace?.id).toBe(workspaceByCwd(h.workspaces, TOOLBOX)?.workspaceId); - expect(resp?.workspace?.projectId).toBe(TOOLBOX); - expect(h.workspaces.size).toBe(1); - expect(h.projects.size).toBe(1); - expect(workspaceByCwd(h.workspaces, TOOLBOX)?.archivedAt).toBeNull(); - expect(h.projects.get(TOOLBOX)?.archivedAt).toBeNull(); + expect(resp?.workspace?.id).not.toBe("ws-toolbox"); + expect(resp?.workspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/); + expect(h.workspaces.size).toBe(2); + expect(h.projects.size).toBe(2); + expect(h.projects.get(TOOLBOX)?.archivedAt).toBe(archivedAt); }); // ───────────────────────────────────────────────────────────────────────────── @@ -482,7 +501,7 @@ test("S12: resolveWorkspaceIdForPath does not return archived ancestor via prefi // being explicit. To get git features back the user unarchives the parent // (S6/S11). // ───────────────────────────────────────────────────────────────────────────── -test("S13: subfolder of an archived git repo opens as a directory workspace", async () => { +test("S13: subfolder of an archived git repo opens as its own git-backed workspace", async () => { const archivedAt = "2026-04-22T13:08:05.400Z"; const h = createHarness({ workspaces: [gitWorkspace(TOOLBOX, archivedAt)], @@ -492,5 +511,5 @@ test("S13: subfolder of an archived git repo opens as a directory workspace", as await openProject(h.session, TOOLBOX_FLOMO); const resp = getOpenResponse(h.emitted, "req-1"); expect(resp?.error).toBeNull(); - expect(resp?.workspace?.workspaceKind).toBe("directory"); + expect(resp?.workspace?.workspaceKind).toBe("local_checkout"); }); diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 1b23c3bbf..cd2b94e17 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -33,12 +33,12 @@ import type { AgentStreamEvent, } from "./agent/agent-sdk-types.js"; import { createWorktree } from "../utils/worktree.js"; +import { createRealpathAwarePathMatcher } from "../utils/path.js"; import { readPaseoWorktreeMetadata, writePaseoWorktreeFirstAgentBranchAutoNameMetadata, writePaseoWorktreeMetadata, } from "../utils/worktree-metadata.js"; -import { WorktreeRequestError } from "./worktree-errors.js"; import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js"; import type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js"; import { WorkspaceAutoName } from "./workspace-auto-name.js"; @@ -128,7 +128,6 @@ interface SessionTestAccess { agentUpdates: AgentUpdatesService; workspaceUpdatesSubscription: unknown; interruptAgentIfRunning(agentId: string): unknown; - recreateArchivedWorktree(workspace: PersistedWorkspaceRecord): Promise; reconcileActiveWorkspaceRecords(...args: unknown[]): Promise>; reconcileWorkspaceRecord(workspaceId: string): Promise<{ changed: boolean; @@ -154,6 +153,10 @@ interface SessionTestAccess { clearWorkspaceArchiving(workspaceIds: Iterable): void; emitWorkspaceUpdateForCwd(...args: unknown[]): Promise; emitWorkspaceUpdatesForWorkspaceIds(...args: unknown[]): Promise; + emitWorkspaceUpdatesForExternalWorkspaceIds( + workspaceIds: Iterable, + options?: { skipReconcile?: boolean }, + ): Promise; emit(message: unknown): void; onMessage(message: unknown): void; paseoHome: string; @@ -612,6 +615,7 @@ function createSessionForWorkspaceTests( const session = asTestSession( new Session({ clientId: "test-client", + scopes: ["*"], appVersion: options.appVersion ?? null, onMessage: options.onMessage ?? vi.fn(), onWorkspaceRecovered: options.onWorkspaceRecovered, @@ -652,6 +656,15 @@ function createSessionForWorkspaceTests( existsOnDisk: async () => true, list: async () => [], get: async () => null, + getOrCreateActiveByRoot: async (input) => + createPersistedProjectRecord({ + projectId: "prj_0000000000000000", + rootPath: input.rootPath, + kind: input.kind, + displayName: input.displayName, + createdAt: input.timestamp, + updatedAt: input.timestamp, + }), upsert: async () => {}, archive: async () => {}, remove: async () => {}, @@ -803,6 +816,7 @@ test("create_agent_request keeps requested child cwd when grouped under an exist const session = asTestSession( new Session({ clientId: "test-client", + scopes: ["*"], appVersion: null, onMessage: (message) => emitted.push(message), logger: asSessionLogger(logger), @@ -844,7 +858,6 @@ test("create_agent_request keeps requested child cwd when grouped under an exist terminalManager: null, }), ); - await session.handleMessage({ type: "create_agent_request", requestId: "req-create-child", @@ -857,7 +870,7 @@ test("create_agent_request keeps requested child cwd when grouped under an exist await expect( session.buildProjectPlacementForWorkspaceId(createdAgent!.workspaceId!), ).resolves.toMatchObject({ - projectKey: parent, + projectKey: expect.stringMatching(/^prj_[0-9a-f]{16}$/), checkout: { cwd: child }, }); expect(findByType(emitted, "status")?.payload).toMatchObject({ @@ -869,6 +882,164 @@ 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-")); + try { + const parent = path.join(workdir, "parent"); + const child = path.join(parent, "packages", "app"); + mkdirSync(child, { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: parent, stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@getpaseo.local"], { + cwd: parent, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: parent, stdio: "pipe" }); + writeFileSync(path.join(child, "README.md"), "app\n"); + execFileSync("git", ["add", "."], { cwd: parent, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "initial"], { cwd: parent, stdio: "pipe" }); + + const logger = { + child: () => logger, + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + 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), + ); + const workspaceRegistry = new FileBackedWorkspaceRegistry( + path.join(workdir, "workspaces.json"), + asSessionLogger(logger), + ); + const workspaceGitService = createNoopWorkspaceGitService({ + getCheckout: async (cwd: string) => ({ + cwd, + isGit: true, + currentBranch: "main", + remoteUrl: null, + worktreeRoot: parent, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }), + resolveRepoRoot: async () => parent, + resolveDefaultBranch: async () => "main", + }); + await projectRegistry.upsert( + createPersistedProjectRecord({ + projectId: "proj-parent", + rootPath: parent, + kind: "git", + displayName: "parent", + createdAt: "2026-05-07T00:00:00.000Z", + updatedAt: "2026-05-07T00:00:00.000Z", + }), + ); + await workspaceRegistry.upsert( + createPersistedWorkspaceRecord({ + workspaceId: "ws-parent", + projectId: "proj-parent", + cwd: parent, + kind: "local_checkout", + displayName: "parent", + createdAt: "2026-05-07T00:00:00.000Z", + updatedAt: "2026-05-07T00:00:00.000Z", + }), + ); + + const emitted: SessionOutboundMessage[] = []; + const session = new Session({ + clientId: "test-client", + scopes: ["*"], + appVersion: null, + onMessage: (message) => emitted.push(message), + logger: asSessionLogger(logger), + downloadTokenStore: asDownloadTokenStore(), + pushTokenStore: asPushTokenStore(), + paseoHome: path.join(workdir, "paseo-home"), + agentManager, + agentStorage, + projectRegistry, + workspaceRegistry, + chatService: asChatService(), + scheduleService: asScheduleService(), + loopService: asLoopService(), + checkoutDiffManager: asCheckoutDiffManager({ + subscribe: async () => ({ + initial: { cwd: child, files: [], error: null }, + unsubscribe: () => {}, + }), + scheduleRefreshForCwd: () => {}, + onWorkspaceStateMayHaveChanged: () => {}, + getMetrics: () => ({ + checkoutDiffTargetCount: 0, + checkoutDiffSubscriptionCount: 0, + checkoutDiffWatcherCount: 0, + checkoutDiffFallbackRefreshTargetCount: 0, + }), + dispose: () => {}, + }), + workspaceGitService, + workspaceAutoName: new WorkspaceAutoName({ + agentManager, + workspaceRegistry, + workspaceGitService, + providerSnapshotManager: createProviderSnapshotManagerStub().manager, + readDaemonConfig: () => ({ metadataGeneration: { providers: [] } }), + gitMutation: { notifyGitMutation: async () => {} }, + emitWorkspaceUpdateForCwd: async () => {}, + emitWorkspaceUpdateForWorkspaceId: async () => {}, + logger: asSessionLogger(logger), + }), + daemonConfigStore: asDaemonConfigStore({ + get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }), + onChange: () => () => {}, + }), + mcpBaseUrl: null, + stt: null, + tts: null, + providerSnapshotManager: createProviderSnapshotManagerStub().manager, + terminalManager: null, + }); + + await session.handleMessage({ + type: "create_agent_request", + requestId: "req-create-worktree-child", + config: { provider: "codex", cwd: child }, + attachments: [], + worktree: { mode: "branch-off", newBranch: "feature/created-worktree" }, + }); + + const [createdAgent] = agentManager.listAgents(); + const createdWorktreeRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { + cwd: createdAgent!.cwd, + stdio: "pipe", + }) + .toString() + .trim(); + expect( + createRealpathAwarePathMatcher(path.join(createdWorktreeRoot, "packages", "app"))( + createdAgent?.cwd ?? "", + ), + ).toBe(true); + expect(findByType(emitted, "status")?.payload).toMatchObject({ + status: "agent_created", + agent: { cwd: createdAgent?.cwd }, + }); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("create_agent_request does not title an existing workspace from the agent prompt", async () => { vi.useFakeTimers(); const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-existing-title-")); @@ -927,6 +1098,7 @@ test("create_agent_request does not title an existing workspace from the agent p const session = asTestSession( new Session({ clientId: "test-client", + scopes: ["*"], appVersion: null, onMessage: vi.fn(), logger: asSessionLogger(logger), @@ -1250,6 +1422,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients" const session = asTestSession( new Session({ clientId: "test-client", + scopes: ["*"], onMessage: (message) => emitted.push(message), logger: asSessionLogger(logger), downloadTokenStore: asDownloadTokenStore(), @@ -1616,6 +1789,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy const session = asTestSession( new Session({ clientId: "test-client", + scopes: ["*"], onMessage: (message) => emitted.push(message), logger: asSessionLogger(sessionLogger), downloadTokenStore: asDownloadTokenStore(), @@ -1785,6 +1959,7 @@ test("close_items_request archives stored agents that are not currently loaded", const session = asTestSession( new Session({ clientId: "test-client", + scopes: ["*"], onMessage: (message) => emitted.push(message), logger: asSessionLogger(sessionLogger), downloadTokenStore: asDownloadTokenStore(), @@ -1945,6 +2120,7 @@ test("close_items_request continues after an archive failure", async () => { const session = asTestSession( new Session({ clientId: "test-client", + scopes: ["*"], onMessage: (message) => emitted.push(message), logger: asSessionLogger(sessionLogger), downloadTokenStore: asDownloadTokenStore(), @@ -2106,6 +2282,93 @@ test("non-git workspace uses deterministic directory name and no unknown branch expect(result.entries[0]?.name).not.toBe("Unknown branch"); }); +test("workspace placements preserve checkout facts independently from the project", async () => { + const session = createSessionForWorkspaceTests(); + const manualWorktree = createPersistedWorkspaceRecord({ + workspaceId: "ws-manual-worktree", + projectId: "proj-manual-worktree", + cwd: "/tmp/manual-worktree", + kind: "worktree", + displayName: "manual", + isPaseoOwnedWorktree: false, + mainRepoRoot: "/tmp/main-repo", + 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", + kind: "directory", + displayName: "plain", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const paseoSubdirectory = createPersistedWorkspaceRecord({ + workspaceId: "ws-paseo-subdirectory", + projectId: "proj-manual-worktree", + cwd: "/tmp/paseo-worktree/packages/app", + kind: "worktree", + displayName: "app", + isPaseoOwnedWorktree: true, + mainRepoRoot: "/tmp/main-repo", + 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", + kind: "git", + displayName: "main", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + session.workspaceRegistry.get = async (workspaceId: string) => + [manualWorktree, explicitDirectory, paseoSubdirectory].find( + (workspace) => workspace.workspaceId === workspaceId, + ) ?? null; + session.projectRegistry.get = async () => project; + session.workspaceGitService.peekSnapshot = (cwd: string) => + cwd === paseoSubdirectory.cwd + ? createWorkspaceRuntimeSnapshot(cwd, { + git: { repoRoot: "/tmp/paseo-worktree" }, + }) + : null; + + await expect( + session.buildProjectPlacementForWorkspaceId(manualWorktree.workspaceId), + ).resolves.toEqual( + expect.objectContaining({ + checkout: expect.objectContaining({ + isGit: true, + isPaseoOwnedWorktree: false, + mainRepoRoot: "/tmp/main-repo", + }), + }), + ); + await expect( + session.buildProjectPlacementForWorkspaceId(explicitDirectory.workspaceId), + ).resolves.toEqual( + expect.objectContaining({ + checkout: expect.objectContaining({ + isGit: false, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }), + }), + ); + await expect( + session.buildProjectPlacementForWorkspaceId(paseoSubdirectory.workspaceId), + ).resolves.toEqual( + expect.objectContaining({ + checkout: expect.objectContaining({ + cwd: paseoSubdirectory.cwd, + worktreeRoot: "/tmp/paseo-worktree", + }), + }), + ); +}); + test("active-scoped fetch_agents includes only unarchived agents in active workspaces", async () => { const session = createSessionForWorkspaceTests(); const archivedAt = "2026-03-02T12:00:00.000Z"; @@ -2957,6 +3220,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto const session = asTestSession( new Session({ clientId: "test-client", + scopes: ["*"], onMessage: (message) => emitted.push(message), logger: asSessionLogger(logger), downloadTokenStore: asDownloadTokenStore(), @@ -3345,7 +3609,7 @@ test("project.remove.request removes an already-empty project", async () => { ]); }); -test("create paseo worktree request returns a registered workspace descriptor", async () => { +test("create paseo worktree response preserves an explicit non-Git project", async () => { const emitted: SessionOutboundMessage[] = []; const createdAt = "2026-05-12T12:00:00.000Z"; vi.setSystemTime(new Date(createdAt)); @@ -3408,7 +3672,15 @@ test("create paseo worktree request returns a registered workspace descriptor", ); const workspaces = new Map(); - const projects = new Map(); + const explicitProject = createPersistedProjectRecord({ + projectId: "prj_explicit_non_git", + rootPath: path.join(tempDir, "selected-project"), + kind: "non_git", + displayName: "Selected project", + createdAt, + updatedAt: createdAt, + }); + const projects = new Map([[explicitProject.projectId, explicitProject]]); session.paseoHome = paseoHome; session.workspaceRegistry.get = async (lookupWorkspaceId: string) => workspaces.get(lookupWorkspaceId) ?? null; @@ -3420,6 +3692,22 @@ test("create paseo worktree request returns a registered workspace descriptor", }; session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; session.projectRegistry.list = async () => Array.from(projects.values()); + session.projectRegistry.getOrCreateActiveByRoot = async (input) => { + const existing = Array.from(projects.values()).find( + (project) => !project.archivedAt && project.rootPath === input.rootPath, + ); + if (existing) return existing; + const project = createPersistedProjectRecord({ + projectId: `prj_${projects.size.toString().padStart(16, "0")}`, + rootPath: input.rootPath, + kind: input.kind, + displayName: input.displayName, + createdAt: input.timestamp, + updatedAt: input.timestamp, + }); + projects.set(project.projectId, project); + return project; + }; session.projectRegistry.upsert = async ( record: ReturnType, ) => { @@ -3432,6 +3720,7 @@ test("create paseo worktree request returns a registered workspace descriptor", await session.handleCreatePaseoWorktreeRequest({ type: "create_paseo_worktree_request", cwd: repoDir, + projectId: explicitProject.projectId, worktreeSlug: "worktree-123", requestId: "req-worktree", }); @@ -3444,8 +3733,10 @@ test("create paseo worktree request returns a registered workspace descriptor", expect(response?.payload.error).toBeNull(); expect(response?.payload.workspace).toMatchObject({ - projectDisplayName: "repo", - projectKind: "git", + projectId: explicitProject.projectId, + projectDisplayName: explicitProject.displayName, + projectRootPath: explicitProject.rootPath, + projectKind: "non_git", workspaceKind: "worktree", name: "worktree-123", status: "done", @@ -3454,7 +3745,7 @@ test("create paseo worktree request returns a registered workspace descriptor", expect(response?.payload.workspace?.id).toMatch(/^wks_[0-9a-f]{16}$/); expect(response?.payload.workspace?.workspaceDirectory).toContain(path.join("worktree-123")); expect(workspaces.has(response?.payload.workspace?.id ?? "")).toBe(true); - expect(projects.has(response?.payload.workspace?.projectId ?? "")).toBe(true); + expect(projects.get(explicitProject.projectId)).toEqual(explicitProject); }); test("workspace update fanout for multiple cwd values is deduplicated", async () => { @@ -3547,6 +3838,18 @@ test("open_project_request registers a workspace before any agent exists", async if (isSessionOutboundMessage(message)) emitted.push(message); }; session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; + session.projectRegistry.getOrCreateActiveByRoot = async (allocation) => { + const project = createPersistedProjectRecord({ + projectId: "prj_githubruntime", + rootPath: allocation.rootPath, + kind: allocation.kind, + displayName: allocation.displayName, + createdAt: allocation.timestamp, + updatedAt: allocation.timestamp, + }); + projects.set(project.projectId, project); + return project; + }; session.projectRegistry.upsert = async ( record: ReturnType, ) => { @@ -3860,6 +4163,18 @@ test("open_project_request emits a workspace_update with githubRuntime once the if (isSessionOutboundMessage(message)) emitted.push(message); }; session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; + session.projectRegistry.getOrCreateActiveByRoot = async (allocation) => { + const project = createPersistedProjectRecord({ + projectId: "prj_githubruntime", + rootPath: allocation.rootPath, + kind: allocation.kind, + displayName: allocation.displayName, + createdAt: allocation.timestamp, + updatedAt: allocation.timestamp, + }); + projects.set(project.projectId, project); + return project; + }; session.projectRegistry.upsert = async ( record: ReturnType, ) => { @@ -4085,7 +4400,6 @@ test("open_project_request reclassifies an archived directory workspace when git "orchestrate", "desktop-daemon-settings", ); - const remoteProjectId = "remote:github.com/getpaseo/paseo"; const archivedAt = "2026-04-24T09:48:36.168Z"; const workspaceId = "ws-desktop-daemon-settings"; @@ -4163,12 +4477,9 @@ test("open_project_request reclassifies an archived directory workspace when git const response = findByType(emitted, "open_project_response"); expect(response?.payload.error).toBeNull(); - expect(response?.payload.workspace?.projectId).toBe(remoteProjectId); - expect(response?.payload.workspace?.workspaceKind).toBe("worktree"); - expect(projects.get(remoteProjectId)?.kind).toBe("git"); - expect(workspaces.get(workspaceId)?.projectId).toBe(remoteProjectId); - expect(workspaces.get(workspaceId)?.kind).toBe("worktree"); - expect(workspaces.get(workspaceId)?.displayName).toBe("feature/desktop-daemon-settings"); + expect(response?.payload.workspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/); + expect(projects.get(cwd)?.archivedAt).toBe(archivedAt); + expect(workspaces.get(workspaceId)?.archivedAt).toBe(archivedAt); }); test("open_project_request reclassifies an active directory workspace when git metadata becomes available", async () => { @@ -4282,14 +4593,11 @@ test("open_project_request reclassifies an active directory workspace when git m const response = findByType(emitted, "open_project_response"); expect(response?.payload.error).toBeNull(); - expect(response?.payload.workspace?.projectId).toBe(repoRoot); - expect(response?.payload.workspace?.workspaceKind).toBe("worktree"); - expect(workspaces.get(workspaceId)?.projectId).toBe(repoRoot); - expect(workspaces.get(workspaceId)?.kind).toBe("worktree"); - expect(workspaces.get(workspaceId)?.displayName).toBe("feature/desktop-daemon-settings"); + expect(response?.payload.workspace?.projectId).toBe(cwd); + expect(workspaces.get(workspaceId)?.projectId).toBe(cwd); }); -test("open_project_request groups a plain git worktree under an existing repo project", async () => { +test("open_project_request gives a plain git worktree its own exact-root project", async () => { const emitted: SessionOutboundMessage[] = []; const session = createSessionForWorkspaceTests(); const projects = new Map>(); @@ -4375,16 +4683,14 @@ test("open_project_request groups a plain git worktree under an existing repo pr const response = findByType(emitted, "open_project_response"); expect(response?.payload.error).toBeNull(); - expect(response?.payload.workspace?.projectId).toBe(repoRoot); - expect(response?.payload.workspace?.workspaceKind).toBe("worktree"); + expect(response?.payload.workspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/); const worktreeWorkspace = Array.from(workspaces.values()).find( (workspace) => workspace.cwd === cwd, ); - expect(worktreeWorkspace?.projectId).toBe(repoRoot); - expect(worktreeWorkspace?.kind).toBe("worktree"); + expect(worktreeWorkspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/); }); -test("open_project_request unarchives an existing archived workspace and project", async () => { +test("open_project_request keeps archived records and allocates a fresh workspace", async () => { const emitted: SessionOutboundMessage[] = []; const session = createSessionForWorkspaceTests(); const projects = new Map>(); @@ -4443,14 +4749,15 @@ test("open_project_request unarchives an existing archived workspace and project requestId: "req-open-unarchive", }); - expect(workspaces.get(workspaceId)?.archivedAt).toBeNull(); - expect(projects.get(cwd)?.archivedAt).toBeNull(); + expect(workspaces.get(workspaceId)?.archivedAt).not.toBeNull(); + expect(projects.get(cwd)?.archivedAt).not.toBeNull(); const response = findByType(emitted, "open_project_response"); expect(response?.payload.error).toBeNull(); - expect(response?.payload.workspace?.id).toBe(workspaceId); + expect(response?.payload.workspace?.id).not.toBe(workspaceId); + expect(response?.payload.workspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/); }); -test("open_project_request recreates a missing project record when unarchiving its workspace", async () => { +test("open_project_request does not repurpose an orphaned archived workspace", async () => { const emitted: SessionOutboundMessage[] = []; const session = createSessionForWorkspaceTests(); const projects = new Map>(); @@ -4497,18 +4804,12 @@ test("open_project_request recreates a missing project record when unarchiving i requestId: "req-open-removed-project", }); - expect(projects.get(cwd)).toEqual( - expect.objectContaining({ - projectId: cwd, - displayName: "repo", - archivedAt: null, - }), - ); - expect(workspaces.get(workspaceId)?.archivedAt).toBeNull(); + expect(projects.get(cwd)).toBeUndefined(); + expect(workspaces.get(workspaceId)?.archivedAt).not.toBeNull(); const response = findByType(emitted, "open_project_response"); expect(response?.payload.error).toBeNull(); - expect(response?.payload.workspace?.id).toBe(workspaceId); - expect(response?.payload.workspace?.projectDisplayName).toBe("repo"); + expect(response?.payload.workspace?.id).not.toBe(workspaceId); + expect(response?.payload.workspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/); }); test("workspace recovery stays accepted when git observer warming fails", async () => { @@ -5099,68 +5400,6 @@ test("legacy refresh_agent_request restores a real deleted worktree", async () = rmSync(tempDir, { recursive: true, force: true }); }); -test("recreateArchivedWorktree throws a typed WorktreeRequestError when the project root is missing", async () => { - const { tempDir, repoDir } = createRecreateWorktreeRepo(); - const branch = "feature/keep"; - execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" }); - - const worktreesRoot = path.join(tempDir, "worktrees"); - const paseoHome = path.join(tempDir, "paseo-home"); - const created = await createWorktree({ - cwd: repoDir, - worktreeSlug: "keep", - source: { kind: "checkout-branch", branchName: branch }, - runSetup: false, - paseoHome, - worktreesRoot, - }); - const worktreePath = realpathSync(created.worktreePath); - - const session = createSessionForWorkspaceTests({ paseoHome, worktreesRoot }); - const projects = new Map>(); - const workspaces = new Map>(); - const workspaceId = "ws-missing-root"; - const projectId = repoDir; - const missingRoot = path.join(tempDir, "does-not-exist"); - projects.set( - projectId, - createPersistedProjectRecord({ - projectId, - rootPath: missingRoot, - kind: "git", - displayName: "worktree-project", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-10T00:00:00.000Z", - archivedAt: "2026-03-10T00:00:00.000Z", - }), - ); - const workspaceRecord = createPersistedWorkspaceRecord({ - workspaceId, - projectId, - cwd: worktreePath, - kind: "worktree", - branch, - displayName: branch, - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-10T00:00:00.000Z", - archivedAt: "2026-03-10T00:00:00.000Z", - }); - workspaces.set(workspaceId, workspaceRecord); - - session.projectRegistry.get = async (id: string) => projects.get(id) ?? null; - session.workspaceRegistry.get = async (id: string) => workspaces.get(id) ?? null; - session.filesystem.isDirectory = async (target: string) => - existsSync(target) && statSync(target).isDirectory(); - - await expect(session.recreateArchivedWorktree(workspaceRecord)).rejects.toBeInstanceOf( - WorktreeRequestError, - ); - // Guard fires before createWorktree, so archivedAt is untouched. - expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z"); - - rmSync(tempDir, { recursive: true, force: true }); -}); - test.skip("open_project_request collapses a git subdirectory onto the repo root workspace", async () => { const emitted: SessionOutboundMessage[] = []; const session = createSessionForWorkspaceTests(); @@ -5747,6 +5986,40 @@ test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline desc expect(descriptors).toEqual([baselineDescriptor]); }); +test("lists Git runtime for a checkout explicitly owned by a non-Git project", async () => { + const session = createSessionForWorkspaceTests(); + const project = createPersistedProjectRecord({ + projectId: "proj-explicit-directory", + rootPath: "/tmp/explicit-directory", + kind: "non_git", + displayName: "directory project", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-explicit-checkout", + projectId: project.projectId, + cwd: REPO_CWD, + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + session.listAgentPayloads = async () => []; + session.projectRegistry.list = async () => [project]; + session.workspaceRegistry.list = async () => [workspace]; + session.workspaceGitService.peekSnapshot = () => createWorkspaceRuntimeSnapshot(REPO_CWD); + + const descriptors = Array.from( + (await session.buildWorkspaceDescriptorMap({ includeGitData: true })).values(), + ) as Array<{ gitRuntime?: { currentBranch: string | null }; githubRuntime?: unknown }>; + + expect(descriptors[0]).toMatchObject({ + gitRuntime: { currentBranch: "main" }, + githubRuntime: expect.any(Object), + }); +}); + test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fields", async () => { const setupSession = () => { const session = createSessionForWorkspaceTests(); @@ -6188,6 +6461,90 @@ test("emitWorkspaceUpdatesForWorkspaceIds includes archiving state and dedupes u ]); }); +test("external workspace updates emit one deduplicated batch without reconciling", async () => { + const emitted: SessionOutboundMessage[] = []; + const session = createSessionForWorkspaceTests(); + const project = createPersistedProjectRecord({ + projectId: "proj-observer-batch", + rootPath: "/tmp/observer-batch", + kind: "non_git", + displayName: "observer-batch", + createdAt: "2026-07-15T00:00:00.000Z", + updatedAt: "2026-07-15T00:00:00.000Z", + }); + const main = createPersistedWorkspaceRecord({ + workspaceId: "ws-observer-main", + projectId: project.projectId, + cwd: "/tmp/observer-batch/main", + kind: "directory", + displayName: "main", + createdAt: "2026-07-15T00:00:00.000Z", + updatedAt: "2026-07-15T00:00:00.000Z", + }); + const feature = createPersistedWorkspaceRecord({ + workspaceId: "ws-observer-feature", + projectId: project.projectId, + cwd: "/tmp/observer-batch/feature", + kind: "directory", + displayName: "feature", + createdAt: "2026-07-15T00:00:00.000Z", + updatedAt: "2026-07-15T00:00:00.000Z", + }); + const snapshotReads = { projects: 0, workspaces: 0 }; + session.projectRegistry.list = async () => { + snapshotReads.projects += 1; + return [project]; + }; + session.workspaceRegistry.list = async () => { + snapshotReads.workspaces += 1; + return [main, feature]; + }; + session.listAgentPayloads = async () => []; + session.workspaceUpdatesSubscription = { + subscriptionId: "sub-observer-batch", + filter: undefined, + isBootstrapping: false, + pendingUpdatesByWorkspaceId: new Map(), + lastEmittedByWorkspaceId: new Map(), + }; + session.onMessage = (message) => { + if (isSessionOutboundMessage(message)) emitted.push(message); + }; + + await session.emitWorkspaceUpdatesForExternalWorkspaceIds( + [main.workspaceId, feature.workspaceId, main.workspaceId], + { skipReconcile: true }, + ); + + expect(filterByType(emitted, "workspace_update")).toEqual([ + { + type: "workspace_update", + payload: { + kind: "upsert", + workspace: expect.objectContaining({ + id: main.workspaceId, + projectId: project.projectId, + workspaceDirectory: main.cwd, + name: main.displayName, + }), + }, + }, + { + type: "workspace_update", + payload: { + kind: "upsert", + workspace: expect.objectContaining({ + id: feature.workspaceId, + projectId: project.projectId, + workspaceDirectory: feature.cwd, + name: feature.displayName, + }), + }, + }, + ]); + expect(snapshotReads).toEqual({ projects: 1, workspaces: 1 }); +}); + test("fetch_workspaces_response reads runtime fields from passive workspace git service snapshots", async () => { const emitted: SessionOutboundMessage[] = []; const runtimeSnapshot = createWorkspaceRuntimeSnapshot(REPO_CWD, { @@ -6568,6 +6925,64 @@ test("subscribed fetch_workspaces includes git enrichment in the initial snapsho ); }); +test("a workspace leaving a filtered subscription after bootstrap emits a removal", async () => { + const emitted: SessionOutboundMessage[] = []; + const session = asTestSession( + createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }), + ); + const descriptor = { + id: "ws-buffered", + projectId: "proj-buffered", + projectDisplayName: "repo", + projectRootPath: REPO_CWD, + workspaceDirectory: REPO_CWD, + projectKind: "git" as const, + workspaceKind: "local_checkout" as const, + name: "repo work", + status: "done" as const, + activityAt: null, + diffStat: null, + }; + let currentDescriptor: typeof descriptor | null = descriptor; + let finishListing: (result: ListFetchResult) => void = () => {}; + const listing = new Promise((resolve) => { + finishListing = resolve; + }); + session.listFetchWorkspacesEntries = async () => listing; + session.buildWorkspaceDescriptorMap = async () => + new Map(currentDescriptor ? [[currentDescriptor.id, currentDescriptor]] : []); + session.reconcileAndEmitWorkspaceUpdates = async () => undefined; + + const bootstrap = session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "req-buffered-filter", + filter: { query: "repo" }, + subscribe: { subscriptionId: "sub-buffered-filter" }, + }); + await session.emitWorkspaceUpdatesForWorkspaceIds([descriptor.id], { skipReconcile: true }); + finishListing({ + entries: [], + emptyProjects: [], + pageInfo: { nextCursor: null, prevCursor: null, hasMore: false }, + }); + await bootstrap; + + expect(filterByType(emitted, "workspace_update")).toEqual([ + { type: "workspace_update", payload: { kind: "upsert", workspace: descriptor } }, + ]); + + emitted.length = 0; + currentDescriptor = { ...descriptor, name: "other work" }; + await session.emitWorkspaceUpdatesForWorkspaceIds([descriptor.id], { skipReconcile: true }); + + expect(filterByType(emitted, "workspace_update")).toEqual([ + { + type: "workspace_update", + payload: { kind: "remove", id: descriptor.id }, + }, + ]); +}); + test("project.rename.request stores customName and emits an updated workspace descriptor", async () => { const emitted: SessionOutboundMessage[] = []; const session = asTestSession( @@ -7439,6 +7854,18 @@ test("workspace.create worktree source checks out a GitHub PR from githubPrNumbe existsOnDisk: async () => true, list: async () => Array.from(projects.values()), get: async (projectId: string) => projects.get(projectId) ?? null, + getOrCreateActiveByRoot: async (allocation) => { + const project = createPersistedProjectRecord({ + projectId: "prj_worktreefixture", + rootPath: allocation.rootPath, + kind: allocation.kind, + displayName: allocation.displayName, + createdAt: allocation.timestamp, + updatedAt: allocation.timestamp, + }); + projects.set(project.projectId, project); + return project; + }, upsert: async (record) => { projects.set(record.projectId, record); }, @@ -7633,7 +8060,7 @@ test("workspace auto-name replaces the unchanged prompt title", async () => { } }); -test("workspace auto-name applies title once when branch auto-name is rejected", async () => { +test("workspace auto-name uses the backing root for a nested worktree", async () => { vi.useFakeTimers(); const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "workspace-auto-name-rejected-"))); const repoDir = path.join(tempDir, "repo"); @@ -7653,15 +8080,19 @@ test("workspace auto-name applies title once when branch auto-name is rejected", writePaseoWorktreeFirstAgentBranchAutoNameMetadata(repoDir, { placeholderBranchName: "placeholder-branch", }); + const workspaceCwd = path.join(repoDir, "packages", "app"); + mkdirSync(workspaceCwd, { recursive: true }); const workspace = createPersistedWorkspaceRecord({ workspaceId: "ws-rejected-branch-title", projectId: "proj-rejected-branch-title", - cwd: repoDir, + cwd: workspaceCwd, kind: "worktree", displayName: "Fix checkout title", title: "Fix checkout title", branch: "placeholder-branch", + worktreeRoot: repoDir, + isPaseoOwnedWorktree: true, createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", }); @@ -7721,7 +8152,7 @@ test("workspace auto-name applies title once when branch auto-name is rejected", }, }); expect(gitMutations).toEqual([]); - expect(emittedCwds).toEqual([repoDir]); + expect(emittedCwds).toEqual([workspaceCwd]); } finally { vi.useRealTimers(); rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); @@ -7827,4 +8258,165 @@ test("workspace.create.response persists the first prompt as the initial title", expect(workspaceId).toBeDefined(); const persisted = await session.workspaceRegistry.get(workspaceId as string); expect(persisted?.title).toBe("Add retries to the payments flow"); + expect(filterByType(emitted, "workspace_update")).toHaveLength(1); +}); + +test("workspace create emits through a matching workspace subscription", async () => { + const emitted: SessionOutboundMessage[] = []; + const workspaces = new Map>(); + const session = createSessionForWorkspaceTests({ + onMessage: (message) => emitted.push(message), + workspaceRegistry: { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => Array.from(workspaces.values()), + get: async (workspaceId) => workspaces.get(workspaceId) ?? null, + upsert: async (workspace) => { + workspaces.set(workspace.workspaceId, workspace); + }, + archive: async () => {}, + remove: async () => {}, + }, + }); + session.listAgentPayloads = async () => []; + + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "req-subscribe-create-match", + filter: { query: "repo" }, + subscribe: { subscriptionId: "sub-create-match" }, + }); + emitted.length = 0; + await session.handleMessage({ + type: "workspace.create.request", + requestId: "req-create-match", + source: { kind: "directory", path: REPO_CWD }, + }); + + expect(filterByType(emitted, "workspace_update")).toHaveLength(1); +}); + +test("workspace create stays out of a non-matching workspace subscription", async () => { + const emitted: SessionOutboundMessage[] = []; + const workspaces = new Map>(); + const session = createSessionForWorkspaceTests({ + onMessage: (message) => emitted.push(message), + workspaceRegistry: { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => Array.from(workspaces.values()), + get: async (workspaceId) => workspaces.get(workspaceId) ?? null, + upsert: async (workspace) => { + workspaces.set(workspace.workspaceId, workspace); + }, + archive: async () => {}, + remove: async () => {}, + }, + }); + session.listAgentPayloads = async () => []; + + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "req-subscribe-create-filtered", + filter: { query: "definitely-not-this-workspace" }, + subscribe: { subscriptionId: "sub-create-filtered" }, + }); + emitted.length = 0; + await session.handleMessage({ + type: "workspace.create.request", + requestId: "req-create-filtered", + source: { kind: "directory", path: REPO_CWD }, + }); + + expect(filterByType(emitted, "workspace_update")).toEqual([]); +}); + +test("workspace.create.request attaches a directory workspace to its explicit active project", async () => { + const emitted: SessionOutboundMessage[] = []; + const projects = new Map([ + [ + "prj_explicit", + createPersistedProjectRecord({ + projectId: "prj_explicit", + rootPath: path.join(REPO_CWD, "unrelated"), + kind: "non_git", + displayName: "unrelated", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }), + ], + ]); + const workspaces = new Map(); + const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }); + session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? 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; + + await session.handleMessage({ + type: "workspace.create.request", + requestId: "req-explicit-project", + source: { kind: "directory", path: REPO_CWD, projectId: "prj_explicit" }, + }); + + const response = findByType(emitted, "workspace.create.response"); + expect(response?.payload).toMatchObject({ + requestId: "req-explicit-project", + error: null, + workspace: { projectId: "prj_explicit" }, + }); + const workspaceId = response?.payload.workspace?.id; + expect(workspaceId).toEqual(expect.any(String)); + expect(workspaces.get(workspaceId as string)).toMatchObject({ + cwd: REPO_CWD, + projectId: "prj_explicit", + }); +}); + +test("workspace.create.request reports an unknown explicit project", async () => { + const emitted: SessionOutboundMessage[] = []; + const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }); + + await session.handleMessage({ + type: "workspace.create.request", + requestId: "req-unknown-project", + source: { kind: "directory", path: REPO_CWD, projectId: "prj_missing" }, + }); + + expect(findByType(emitted, "workspace.create.response")?.payload).toMatchObject({ + requestId: "req-unknown-project", + workspace: null, + errorCode: "unknown_project", + }); +}); + +test("workspace.create.request reports an archived explicit project", async () => { + const emitted: SessionOutboundMessage[] = []; + const archivedProject = createPersistedProjectRecord({ + projectId: "prj_archived", + rootPath: path.join(REPO_CWD, "unrelated"), + kind: "non_git", + displayName: "unrelated", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + archivedAt: "2026-03-02T00:00:00.000Z", + }); + const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }); + session.projectRegistry.get = async (projectId: string) => + projectId === archivedProject.projectId ? archivedProject : null; + + await session.handleMessage({ + type: "workspace.create.request", + requestId: "req-archived-project", + source: { kind: "directory", path: REPO_CWD, projectId: "prj_archived" }, + }); + + expect(findByType(emitted, "workspace.create.response")?.payload).toMatchObject({ + requestId: "req-archived-project", + workspace: null, + errorCode: "archived_project", + }); }); diff --git a/packages/server/src/server/session/agent-config/agent-config-session.test.ts b/packages/server/src/server/session/agent-config/agent-config-session.test.ts index d9ae7470c..d0e14519a 100644 --- a/packages/server/src/server/session/agent-config/agent-config-session.test.ts +++ b/packages/server/src/server/session/agent-config/agent-config-session.test.ts @@ -9,14 +9,21 @@ import type { AgentProviderNotice } from "../../agent/agent-sdk-types.js"; import type { SessionOutboundMessage } from "../../messages.js"; class FakeAgentConfigOperations implements AgentConfigOperations { + readonly loadedAgentIds: string[] = []; readonly modeCalls: Array<{ agentId: string; modeId: string }> = []; readonly modelCalls: Array<{ agentId: string; modelId: string | null }> = []; readonly featureCalls: Array<{ agentId: string; featureId: string; value: unknown }> = []; readonly thinkingCalls: Array<{ agentId: string; thinkingOptionId: string | null }> = []; modeNotice: AgentProviderNotice | null = null; thinkingNotice: AgentProviderNotice | null = null; + loadFailure: Error | null = null; failWith: Error | null = null; + async ensureLoaded(agentId: string): Promise { + this.loadedAgentIds.push(agentId); + if (this.loadFailure) throw this.loadFailure; + } + async setMode(agentId: string, modeId: string): Promise { this.modeCalls.push({ agentId, modeId }); if (this.failWith) throw this.failWith; @@ -68,6 +75,7 @@ describe("AgentConfigSession", () => { }); expect(operations.modeCalls).toEqual([{ agentId: "agent-1", modeId: "plan" }]); + expect(operations.loadedAgentIds).toEqual(["agent-1"]); expect(emitted).toEqual([ { type: "set_agent_mode_response", @@ -109,6 +117,43 @@ describe("AgentConfigSession", () => { }); }); + test("set mode: a failed load rejects without mutating the collected agent", async () => { + const { subsystem, emitted, operations } = makeSubsystem(); + operations.loadFailure = new Error("agent is archived"); + + await subsystem.handleSetAgentModeRequest({ + type: "set_agent_mode_request", + agentId: "agent-1", + modeId: "plan", + requestId: "req-1", + }); + + expect(operations.loadedAgentIds).toEqual(["agent-1"]); + expect(operations.modeCalls).toEqual([]); + expect(emitted.map((message) => message.type)).toEqual([ + "activity_log", + "set_agent_mode_response", + ]); + expect(emitted[0]).toEqual({ + type: "activity_log", + payload: { + id: expect.any(String), + timestamp: expect.any(Date), + type: "error", + content: "Failed to set agent mode: agent is archived", + }, + }); + expect(emitted[1]).toEqual({ + type: "set_agent_mode_response", + payload: { + requestId: "req-1", + agentId: "agent-1", + accepted: false, + error: "agent is archived", + }, + }); + }); + test("set model: emits an accepted response with no notice", async () => { const { subsystem, emitted, operations } = makeSubsystem(); diff --git a/packages/server/src/server/session/agent-config/agent-config-session.ts b/packages/server/src/server/session/agent-config/agent-config-session.ts index ef8630c57..1cd39856a 100644 --- a/packages/server/src/server/session/agent-config/agent-config-session.ts +++ b/packages/server/src/server/session/agent-config/agent-config-session.ts @@ -19,11 +19,12 @@ export interface AgentConfigSessionHost { /** * The per-agent config mutations this subsystem drives. The shell adapts these - * onto the live AgentManager (mode still routes through setAgentModeCommand); - * tests wire an in-memory fake. Mode and thinking yield a provider notice; model - * and feature do not. + * onto the AgentManager and loads a collected agent before mutation (mode still + * routes through setAgentModeCommand); tests wire an in-memory fake. Mode and + * thinking yield a provider notice; model and feature do not. */ export interface AgentConfigOperations { + ensureLoaded(agentId: string): Promise; setMode(agentId: string, modeId: string): Promise; setModel(agentId: string, modelId: string | null): Promise; setFeature(agentId: string, featureId: string, value: unknown): Promise; @@ -138,6 +139,7 @@ export class AgentConfigSession { this.logger.info(logFields, `session: ${logLabel}`); try { + await this.operations.ensureLoaded(agentId); const notice = await run(); this.logger.info(logFields, `session: ${logLabel} success`); emitResponse({ requestId, agentId, accepted: true, error: null, notice }); diff --git a/packages/server/src/server/session/checkout/checkout-session.ts b/packages/server/src/server/session/checkout/checkout-session.ts index 0b97961a0..3505a5b12 100644 --- a/packages/server/src/server/session/checkout/checkout-session.ts +++ b/packages/server/src/server/session/checkout/checkout-session.ts @@ -1220,7 +1220,8 @@ export class CheckoutSession { try { // The payload schema keeps checkRunId and workflowRunId optional (a Gitea - // Actions run has no check-run id; GitLab routes by changeRequestNumber), + // Actions run has no check-run id; forge adapters may also route by + // changeRequestNumber), // but a request that addresses no check at all is not actionable — reject // it here with a clear message instead of failing deep in an adapter. The // schema itself cannot enforce this: it is a discriminated-union member, so diff --git a/packages/server/src/server/session/daemon/daemon-session.test.ts b/packages/server/src/server/session/daemon/daemon-session.test.ts index 9417a17ed..661439bad 100644 --- a/packages/server/src/server/session/daemon/daemon-session.test.ts +++ b/packages/server/src/server/session/daemon/daemon-session.test.ts @@ -10,6 +10,7 @@ import { } from "./daemon-session.js"; import type { DaemonWebSocketRuntimeDiagnosticSnapshot } from "./diagnostics.js"; import type { ProviderAvailability } from "../../agent/agent-manager.js"; +import type { HubRelationshipManagement } from "../../hub/relationship-controller.js"; import type { SessionOutboundMessage } from "../../messages.js"; const tempDirs: string[] = []; @@ -40,6 +41,7 @@ function makeSubsystem(overrides: { daemonRuntimeConfig?: DaemonRuntimeConfig; listProviderAvailability?: () => Promise; getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null; + hubRelationships?: HubRelationshipManagement; }) { const emitted: SessionOutboundMessage[] = []; const restartIntents: Parameters[0][] = []; @@ -60,12 +62,67 @@ function makeSubsystem(overrides: { listWorkspaces: async () => [], listProviderAvailability: overrides.listProviderAvailability ?? (async () => []), getWebSocketRuntimeMetrics: overrides.getWebSocketRuntimeMetrics, + hubRelationships: overrides.hubRelationships, logger: pino({ level: "silent" }), }); return { subsystem, emitted, paseoHome, restartIntents }; } describe("DaemonSession", () => { + test("Hub relationship command failures return correlated RPC errors", async () => { + const { subsystem, emitted } = makeSubsystem({ + hubRelationships: { + connect: async () => { + throw new Error("Hub rejected enrollment (401)"); + }, + status: () => ({ + state: "not_connected", + daemonId: null, + hubOrigin: null, + scopes: [], + connectedAt: null, + lastError: null, + }), + disconnect: async () => { + throw new Error("Hub revocation failed (503)"); + }, + }, + }); + + await subsystem.handleHubRelationshipRequest({ + type: "hub.management.daemon.connect.request", + requestId: "connect-1", + hubUrl: "https://hub.test", + token: "token", + }); + await subsystem.handleHubRelationshipRequest({ + type: "hub.management.daemon.disconnect.request", + requestId: "disconnect-1", + force: false, + }); + + expect(emitted).toEqual([ + { + type: "rpc_error", + payload: { + requestId: "connect-1", + requestType: "hub.management.daemon.connect.request", + error: "Hub rejected enrollment (401)", + code: "handler_error", + }, + }, + { + type: "rpc_error", + payload: { + requestId: "disconnect-1", + requestType: "hub.management.daemon.disconnect.request", + error: "Hub revocation failed (503)", + code: "handler_error", + }, + }, + ]); + }); + test("status reports identity, runtime config, and providers with errors normalized to null", async () => { const { subsystem, emitted } = makeSubsystem({ serverId: "srv-1", diff --git a/packages/server/src/server/session/daemon/daemon-session.ts b/packages/server/src/server/session/daemon/daemon-session.ts index beb525118..7142ce8f8 100644 --- a/packages/server/src/server/session/daemon/daemon-session.ts +++ b/packages/server/src/server/session/daemon/daemon-session.ts @@ -10,6 +10,7 @@ import { import { DaemonSelfUpdateSessionController } from "./daemon-self-update-session-controller.js"; import type { ManagedAgent } from "../../agent/agent-manager.js"; import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../../workspace-registry.js"; +import type { HubRelationshipManagement } from "../../hub/relationship-controller.js"; export interface DaemonRuntimeConfig { listen: string | null; @@ -48,6 +49,7 @@ export interface DaemonSessionOptions { listProviderAvailability: () => Promise; getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null; logger: pino.Logger; + hubRelationships?: HubRelationshipManagement; } /** @@ -71,6 +73,7 @@ export class DaemonSession { private readonly getWebSocketRuntimeMetrics: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null; private readonly logger: pino.Logger; private readonly selfUpdate: DaemonSelfUpdateSessionController; + private readonly hubRelationships: HubRelationshipManagement | null; constructor(options: DaemonSessionOptions) { this.host = options.host; @@ -85,6 +88,7 @@ export class DaemonSession { this.listProviderAvailability = options.listProviderAvailability; this.getWebSocketRuntimeMetrics = options.getWebSocketRuntimeMetrics ?? (() => null); this.logger = options.logger; + this.hubRelationships = options.hubRelationships ?? null; this.selfUpdate = new DaemonSelfUpdateSessionController({ clientId: this.clientId, daemonVersion: this.daemonVersion ?? null, @@ -95,6 +99,56 @@ export class DaemonSession { }); } + async handleHubRelationshipRequest( + msg: Extract< + SessionInboundMessage, + { + type: + | "hub.management.daemon.connect.request" + | "hub.management.daemon.get_status.request" + | "hub.management.daemon.disconnect.request"; + } + >, + ): Promise { + try { + if (!this.hubRelationships) throw new Error("Hub relationship management is unavailable"); + if (msg.type === "hub.management.daemon.connect.request") { + const status = await this.hubRelationships.connect({ + hubUrl: msg.hubUrl, + token: msg.token, + }); + this.host.emit({ + type: "hub.management.daemon.connect.response", + payload: { requestId: msg.requestId, status }, + }); + return; + } + if (msg.type === "hub.management.daemon.disconnect.request") { + const result = await this.hubRelationships.disconnect({ force: msg.force ?? false }); + this.host.emit({ + type: "hub.management.daemon.disconnect.response", + payload: { requestId: msg.requestId, ...result }, + }); + return; + } + this.host.emit({ + type: "hub.management.daemon.get_status.response", + payload: { requestId: msg.requestId, status: this.hubRelationships.status() }, + }); + } catch (error) { + this.logger.error({ err: error }, "Failed to handle Hub relationship request"); + this.host.emit({ + type: "rpc_error", + payload: { + requestId: msg.requestId, + requestType: msg.type, + error: error instanceof Error ? error.message : String(error), + code: "handler_error", + }, + }); + } + } + async handleGetStatusRequest( msg: Extract, ): Promise { 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 08d37068e..b86131401 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 @@ -24,6 +24,7 @@ function makeDescriptor(overrides: { id: string; workspaceDirectory: string; projectKind?: string; + workspaceKind?: "directory" | "local_checkout" | "worktree"; name?: string | null; diffStat?: { additions: number; deletions: number } | null; }): WorkspaceDescriptorPayload { @@ -31,6 +32,7 @@ function makeDescriptor(overrides: { id: overrides.id, workspaceDirectory: overrides.workspaceDirectory, projectKind: overrides.projectKind ?? "git", + workspaceKind: overrides.workspaceKind ?? "local_checkout", name: overrides.name ?? null, diffStat: overrides.diffStat ?? null, } as unknown as WorkspaceDescriptorPayload; @@ -135,11 +137,24 @@ describe("syncObservers", () => { test("does not register a non-git workspace", () => { const h = buildHarness(); h.service.syncObservers([ - makeDescriptor({ id: "ws1", workspaceDirectory: WS1, projectKind: "directory" }), + makeDescriptor({ + id: "ws1", + workspaceDirectory: WS1, + projectKind: "directory", + workspaceKind: "directory", + }), ]); expect(h.registerCalls).toEqual([]); }); + test("registers a Git workspace even when its owning project is non-Git", () => { + const h = buildHarness(); + h.service.syncObservers([ + makeDescriptor({ id: "ws1", workspaceDirectory: WS1, projectKind: "non_git" }), + ]); + expect(h.registerCalls).toEqual([WS1]); + }); + test("is idempotent — re-syncing the same git workspace does not re-register", () => { const h = buildHarness(); const descriptor = makeDescriptor({ id: "ws1", workspaceDirectory: WS1 }); @@ -148,14 +163,46 @@ describe("syncObservers", () => { expect(h.registerCalls).toEqual([WS1]); }); + test("shares one cwd subscription across distinct workspace identities", () => { + const h = buildHarness(); + h.service.syncObservers([ + makeDescriptor({ id: "ws1", workspaceDirectory: WS1 }), + makeDescriptor({ id: "ws2", workspaceDirectory: WS1 }), + ]); + expect(h.registerCalls).toEqual([WS1]); + }); + test("tears down the subscription when a git workspace becomes non-git", () => { const h = buildHarness(); h.service.syncObservers([makeDescriptor({ id: "ws1", workspaceDirectory: WS1 })]); h.service.syncObservers([ - makeDescriptor({ id: "ws1", workspaceDirectory: WS1, projectKind: "directory" }), + makeDescriptor({ + id: "ws1", + workspaceDirectory: WS1, + projectKind: "directory", + workspaceKind: "directory", + }), ]); expect(h.unsubscribeCalls).toEqual([WS1]); }); + + test("keeps a shared subscription when one sibling becomes non-git", () => { + const h = buildHarness(); + h.service.syncObservers([ + makeDescriptor({ id: "ws1", workspaceDirectory: WS1 }), + makeDescriptor({ id: "ws2", workspaceDirectory: WS1 }), + ]); + h.service.syncObservers([ + makeDescriptor({ + id: "ws1", + workspaceDirectory: WS1, + workspaceKind: "directory", + }), + ]); + expect(h.unsubscribeCalls).toEqual([]); + h.emitSnapshot(WS1, "feature"); + expect(h.branchChanges).toEqual([["ws2", null, "feature"]]); + }); }); describe("git snapshot listener", () => { @@ -202,6 +249,13 @@ describe("shouldSkipUpdate", () => { expect(h.service.shouldSkipUpdate("ws1", a)).toBe(true); expect(h.service.shouldSkipUpdate("ws1", b)).toBe(false); }); + + test("starts from the descriptor state recorded during observer sync", () => { + const h = buildHarness(); + const descriptor = makeDescriptor({ id: "ws1", workspaceDirectory: WS1, name: "main" }); + h.service.syncObservers([descriptor]); + expect(h.service.shouldSkipUpdate("ws1", descriptor)).toBe(true); + }); }); describe("recordDescriptorState", () => { @@ -239,10 +293,25 @@ describe("teardown", () => { expect(h.unsubscribeCalls).toEqual([]); }); - test("removeForCwd unsubscribes and stops the observer", () => { + test("keeps a shared cwd subscription until its last workspace is removed", () => { const h = buildHarness(); - h.service.syncObservers([makeDescriptor({ id: "ws1", workspaceDirectory: WS1 })]); - h.service.removeForCwd(WS1); + h.service.syncObservers([ + makeDescriptor({ id: "ws1", workspaceDirectory: WS1, name: "main" }), + makeDescriptor({ id: "ws2", workspaceDirectory: WS1, name: "main" }), + ]); + + h.emitSnapshot(WS1, "feature"); + h.service.removeForWorkspaceId("ws1"); + expect(h.unsubscribeCalls).toEqual([]); + + h.emitSnapshot(WS1, "next"); + expect(h.branchChanges).toEqual([ + ["ws1", "main", "feature"], + ["ws2", "main", "feature"], + ["ws2", "feature", "next"], + ]); + + h.service.removeForWorkspaceId("ws2"); expect(h.unsubscribeCalls).toEqual([WS1]); expect(() => h.emitSnapshot(WS1, "x")).toThrow(); }); 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 75aac14de..9a26f8bc4 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 @@ -10,8 +10,11 @@ import type { PersistedWorkspaceRecord } from "../../workspace-registry.js"; const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__"; interface WorkspaceGitWatchTarget { + workspaceIds: Set; +} + +interface WorkspaceGitWatchState { cwd: string; - workspaceId: string; latestDescriptorStateKey: string | null; lastBranchName: string | null; } @@ -20,8 +23,9 @@ interface WorkspaceGitWatchTarget { * 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, so the registration / dedupe / teardown lifecycle lives in one - * module instead of being smeared across the client session. + * 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. * * Branch changes reach `onBranchChanged` from two paths that share `lastBranchName`: the * on-disk snapshot listener (handleBranchSnapshot) and the workspace-emit loop @@ -37,7 +41,6 @@ export interface WorkspaceGitObserverService { recordDescriptorState(workspaceId: string, workspace: WorkspaceDescriptorPayload | null): void; handleBranchSnapshot(cwd: string, branchName: string | null): void; removeForWorkspaceId(workspaceId: string): void; - removeForCwd(cwd: string): void; dispose(): void; } @@ -67,6 +70,7 @@ export function createWorkspaceGitObserverService(deps: { } = deps; const watchTargets = new Map(); + const workspaceStates = new Map(); const subscriptions = new Map void>(); function descriptorStateKey(workspace: WorkspaceDescriptorPayload | null): string { @@ -79,71 +83,93 @@ export function createWorkspaceGitObserverService(deps: { ]); } - function resolveTargetByWorkspaceId(workspaceId: string): WorkspaceGitWatchTarget | null { - for (const target of watchTargets.values()) { - if (target.workspaceId === workspaceId) { - return target; - } - } - return null; - } - function rememberDescriptorState( workspaceId: string, workspace: WorkspaceDescriptorPayload | null, ): void { - const target = resolveTargetByWorkspaceId(workspaceId); - if (!target) { + const state = workspaceStates.get(workspaceId); + if (!state) { return; } - target.latestDescriptorStateKey = descriptorStateKey(workspace); - target.lastBranchName = workspace?.name ?? null; + state.latestDescriptorStateKey = descriptorStateKey(workspace); + state.lastBranchName = workspace?.name ?? null; } function removeForCwd(cwd: string): void { const normalizedCwd = resolve(cwd); + const target = watchTargets.get(normalizedCwd); + for (const workspaceId of target?.workspaceIds ?? []) { + workspaceStates.delete(workspaceId); + } watchTargets.delete(normalizedCwd); subscriptions.get(normalizedCwd)?.(); subscriptions.delete(normalizedCwd); } + function removeForWorkspaceId(workspaceId: string): void { + const state = workspaceStates.get(workspaceId); + if (!state) { + return; + } + workspaceStates.delete(workspaceId); + const target = watchTargets.get(state.cwd); + target?.workspaceIds.delete(workspaceId); + if (target?.workspaceIds.size === 0) { + removeForCwd(state.cwd); + } + } + function handleBranchSnapshot(cwd: string, branchName: string | null): void { const target = watchTargets.get(resolve(cwd)); if (!target) { return; } - const previousBranchName = target.lastBranchName; - if (branchName === previousBranchName) { - return; + for (const workspaceId of target.workspaceIds) { + const state = workspaceStates.get(workspaceId); + if (!state) { + continue; + } + const previousBranchName = state.lastBranchName; + if (branchName === previousBranchName) { + continue; + } + state.lastBranchName = branchName; + onBranchChanged?.(workspaceId, previousBranchName, branchName); } - - target.lastBranchName = branchName; - onBranchChanged?.(target.workspaceId, previousBranchName, branchName); } function syncObserver(cwd: string, options: { isGit: boolean; workspaceId: string }): void { const normalizedCwd = resolve(cwd); + const currentState = workspaceStates.get(options.workspaceId); + if (currentState && currentState.cwd !== normalizedCwd) { + removeForWorkspaceId(options.workspaceId); + } if (!options.isGit) { - removeForCwd(normalizedCwd); + removeForWorkspaceId(options.workspaceId); return; } + const target = watchTargets.get(normalizedCwd) ?? { + workspaceIds: new Set(), + }; + watchTargets.set(normalizedCwd, target); + target.workspaceIds.add(options.workspaceId); + if (!workspaceStates.has(options.workspaceId)) { + workspaceStates.set(options.workspaceId, { + cwd: normalizedCwd, + latestDescriptorStateKey: null, + lastBranchName: null, + }); + } + if (subscriptions.has(normalizedCwd)) { return; } - const target: WorkspaceGitWatchTarget = { - cwd: normalizedCwd, - workspaceId: options.workspaceId, - latestDescriptorStateKey: null, - lastBranchName: null, - }; - watchTargets.set(normalizedCwd, target); - - const subscription = workspaceGitService.registerWorkspace( - { cwd: normalizedCwd }, - (snapshot) => { + let subscription: ReturnType; + try { + subscription = workspaceGitService.registerWorkspace({ cwd: normalizedCwd }, (snapshot) => { handleBranchSnapshot(normalizedCwd, snapshot.git.currentBranch ?? null); void emitWorkspaceUpdateForCwd(normalizedCwd).catch((error) => { logger.warn( @@ -152,18 +178,21 @@ export function createWorkspaceGitObserverService(deps: { ); }); emitStatusUpdate(normalizedCwd, snapshot); - }, - ); + }); + } catch (error) { + removeForWorkspaceId(options.workspaceId); + throw error; + } subscriptions.set(normalizedCwd, subscription.unsubscribe); } function syncObservers(workspaces: Iterable): void { for (const workspace of workspaces) { syncObserver(workspace.workspaceDirectory, { - isGit: workspace.projectKind === "git", + isGit: workspace.workspaceKind !== "directory", workspaceId: workspace.id, }); - rememberDescriptorState(workspace.workspaceDirectory, workspace); + rememberDescriptorState(workspace.id, workspace); } } @@ -182,24 +211,24 @@ export function createWorkspaceGitObserverService(deps: { }, shouldSkipUpdate(workspaceId, workspace) { - const target = resolveTargetByWorkspaceId(workspaceId); - if (!target) { + const state = workspaceStates.get(workspaceId); + if (!state) { return false; } const nextStateKey = descriptorStateKey(workspace); - if (target.latestDescriptorStateKey === nextStateKey) { + if (state.latestDescriptorStateKey === nextStateKey) { return true; } - target.latestDescriptorStateKey = nextStateKey; + state.latestDescriptorStateKey = nextStateKey; return false; }, recordDescriptorState(workspaceId, nextWorkspace) { - const target = resolveTargetByWorkspaceId(workspaceId); - if (target && onBranchChanged) { + const state = workspaceStates.get(workspaceId); + if (state && onBranchChanged) { const newBranchName = nextWorkspace?.name ?? null; - if (newBranchName !== target.lastBranchName) { - onBranchChanged(workspaceId, target.lastBranchName, newBranchName); + if (newBranchName !== state.lastBranchName) { + onBranchChanged(workspaceId, state.lastBranchName, newBranchName); } } rememberDescriptorState(workspaceId, nextWorkspace); @@ -207,14 +236,7 @@ export function createWorkspaceGitObserverService(deps: { handleBranchSnapshot, - removeForWorkspaceId(workspaceId) { - const target = resolveTargetByWorkspaceId(workspaceId); - if (target) { - removeForCwd(target.cwd); - } - }, - - removeForCwd, + removeForWorkspaceId, dispose() { for (const unsubscribe of subscriptions.values()) { @@ -222,6 +244,7 @@ export function createWorkspaceGitObserverService(deps: { } subscriptions.clear(); watchTargets.clear(); + workspaceStates.clear(); }, }; } 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 91349f238..698e2e451 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 @@ -10,10 +10,13 @@ import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry, type PersistedProjectRecord, + createPersistedWorkspaceRecord, + type WorkspaceRegistry, } from "../../workspace-registry.js"; import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js"; import { createWorkspaceProvisioningService, + WorkspaceProvisioningError, type WorkspaceProvisioningService, } from "./workspace-provisioning-service.js"; @@ -27,6 +30,8 @@ const directorySymlinkType = process.platform === "win32" ? "junction" : "dir"; let tmpDir: string; let gitRoots: Set; +let gitBranches: Map; +let checkoutFailure: Error | null; let workspaceRegistry: FileBackedWorkspaceRegistry; let projectRegistry: FileBackedProjectRegistry; let provisioning: WorkspaceProvisioningService; @@ -35,6 +40,7 @@ function gitService() { return createNoopWorkspaceGitService({ peekSnapshot: () => null, getCheckout: async (cwd: string) => { + if (checkoutFailure) throw checkoutFailure; let worktreeRoot: string | null = null; for (const root of gitRoots) { if ( @@ -47,7 +53,7 @@ function gitService() { return { cwd, isGit: worktreeRoot !== null, - currentBranch: worktreeRoot ? "main" : null, + currentBranch: worktreeRoot ? (gitBranches.get(worktreeRoot) ?? "main") : null, remoteUrl: null, worktreeRoot, isPaseoOwnedWorktree: false, @@ -60,6 +66,8 @@ function gitService() { beforeEach(async () => { tmpDir = mkdtempSync(path.join(os.tmpdir(), "workspace-provisioning-")); gitRoots = new Set(); + gitBranches = new Map(); + checkoutFailure = null; workspaceRegistry = new FileBackedWorkspaceRegistry( path.join(tmpDir, "projects", "workspaces.json"), logger, @@ -112,6 +120,72 @@ test("re-opening an active workspace by exact path returns the same record witho expect(await workspaceRegistry.list()).toHaveLength(1); }); +test("re-opening Windows-equivalent workspace cwd spellings reuses the active and archived record", async () => { + const cwd = path.join(tmpDir, "workspace"); + const created = await provisioning.findOrCreateWorkspaceForDirectory(cwd); + await workspaceRegistry.upsert({ ...created, cwd: `${cwd}${path.sep}` }); + + const active = await provisioning.findOrCreateWorkspaceForDirectory(cwd); + expect(active.workspaceId).toBe(created.workspaceId); + expect(await workspaceRegistry.list()).toHaveLength(1); + + await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT); + const reopened = await provisioning.findOrCreateWorkspaceForDirectory(cwd); + expect(reopened).toMatchObject({ workspaceId: created.workspaceId, archivedAt: null }); + expect(await workspaceRegistry.list()).toHaveLength(1); +}); + +test("re-opening refreshes mutable checkout metadata without renaming the workspace", async () => { + const repo = path.join(tmpDir, "repo"); + const first = await provisioning.findOrCreateWorkspaceForDirectory(repo); + await workspaceRegistry.upsert({ ...first, title: "Pinned work" }); + gitRoots.add(repo); + gitBranches.set(repo, "feature/refresh"); + + const refreshed = await provisioning.findOrCreateWorkspaceForDirectory(repo); + + expect(refreshed).toMatchObject({ + workspaceId: first.workspaceId, + kind: "local_checkout", + branch: "feature/refresh", + displayName: first.displayName, + title: "Pinned work", + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }); + expect(await workspaceRegistry.get(first.workspaceId)).toEqual(refreshed); + expect((await projectRegistry.get(first.projectId))?.kind).toBe("git"); +}); + +test("persists manual worktree ownership separately from its workspace kind", async () => { + const cwd = path.join(tmpDir, "manual-worktree"); + const mainRepoRoot = path.join(tmpDir, "main-repo"); + const manualWorktreeProvisioning = createWorkspaceProvisioningService({ + workspaceRegistry, + projectRegistry, + workspaceGitService: createNoopWorkspaceGitService({ + peekSnapshot: () => null, + getCheckout: async () => ({ + cwd, + isGit: true, + currentBranch: "feature/manual", + remoteUrl: null, + worktreeRoot: cwd, + isPaseoOwnedWorktree: false, + mainRepoRoot, + }), + }), + }); + + const workspace = await manualWorktreeProvisioning.findOrCreateWorkspaceForDirectory(cwd); + + expect(workspace).toMatchObject({ + kind: "worktree", + isPaseoOwnedWorktree: false, + mainRepoRoot, + }); +}); + test("re-opening an archived workspace by its exact path unarchives it and keeps the id", async () => { const repo = path.join(tmpDir, "repo"); gitRoots.add(repo); @@ -124,6 +198,110 @@ test("re-opening an archived workspace by its exact path unarchives it and keeps expect(reopened.archivedAt).toBeNull(); }); +test("reopening archived exact-root records restores the fresh Git project", async () => { + const cwd = path.join(tmpDir, "repo"); + const project = await projectRegistry.getOrCreateActiveByRoot({ + rootPath: cwd, + kind: "non_git", + displayName: "repo", + timestamp: ARCHIVED_AT, + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-archived-root", + projectId: project.projectId, + cwd, + kind: "directory", + displayName: "repo", + createdAt: ARCHIVED_AT, + updatedAt: ARCHIVED_AT, + archivedAt: ARCHIVED_AT, + }); + await workspaceRegistry.upsert(workspace); + await projectRegistry.archive(project.projectId, ARCHIVED_AT); + const archivedProvisioning = createWorkspaceProvisioningService({ + workspaceRegistry, + projectRegistry, + workspaceGitService: createNoopWorkspaceGitService({ + peekSnapshot: () => null, + getCheckout: async () => ({ + cwd, + isGit: true, + currentBranch: "main", + remoteUrl: null, + worktreeRoot: cwd, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }), + }), + }); + + const reopened = await archivedProvisioning.ensureWorkspaceRecordUnarchived(workspace); + + expect(reopened).toMatchObject({ + workspaceId: workspace.workspaceId, + kind: "local_checkout", + archivedAt: null, + }); + expect(await projectRegistry.get(project.projectId)).toMatchObject({ + kind: "git", + archivedAt: null, + }); +}); + +test("uses one workspace snapshot when reopening an archived workspace", async () => { + const repo = path.join(tmpDir, "repo"); + gitRoots.add(repo); + const created = await provisioning.findOrCreateWorkspaceForDirectory(repo); + await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT); + + const archived = (await workspaceRegistry.list()).filter( + (workspace) => workspace.workspaceId === created.workspaceId, + ); + let reads = 0; + const snapshotRegistry: WorkspaceRegistry = { + initialize: () => workspaceRegistry.initialize(), + existsOnDisk: () => workspaceRegistry.existsOnDisk(), + list: async () => (reads++ === 0 ? archived : []), + get: (workspaceId) => workspaceRegistry.get(workspaceId), + upsert: (workspace) => workspaceRegistry.upsert(workspace), + archive: (workspaceId, archivedAt) => workspaceRegistry.archive(workspaceId, archivedAt), + remove: (workspaceId) => workspaceRegistry.remove(workspaceId), + }; + const snapshotProvisioning = createWorkspaceProvisioningService({ + workspaceRegistry: snapshotRegistry, + projectRegistry, + workspaceGitService: gitService(), + }); + + const reopened = await snapshotProvisioning.findOrCreateWorkspaceForDirectory(repo); + + expect(reopened).toMatchObject({ workspaceId: created.workspaceId, archivedAt: null }); + expect(await workspaceRegistry.list()).toHaveLength(1); +}); + +test("reopening an archived workspace refreshes placement without renaming it", async () => { + const repo = path.join(tmpDir, "repo"); + gitRoots.add(repo); + const created = await provisioning.findOrCreateWorkspaceForDirectory(repo); + await workspaceRegistry.upsert({ ...created, title: "Pinned archived work" }); + await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT); + gitRoots.delete(repo); + + const reopened = await provisioning.findOrCreateWorkspaceForDirectory(repo); + + expect(reopened).toMatchObject({ + workspaceId: created.workspaceId, + projectId: created.projectId, + title: "Pinned archived work", + kind: "directory", + branch: null, + displayName: created.displayName, + archivedAt: null, + }); + expect(reopened.updatedAt).toEqual(expect.any(String)); + expect(await workspaceRegistry.get(created.workspaceId)).toEqual(reopened); +}); + test("opening a subpath of an archived git workspace mints a fresh workspace at the exact subpath", async () => { const repo = path.join(tmpDir, "repo"); gitRoots.add(repo); @@ -138,7 +316,7 @@ test("opening a subpath of an archived git workspace mints a fresh workspace at expect((await workspaceRegistry.get(canonical.workspaceId))?.archivedAt).toBe(ARCHIVED_AT); }); -test("ensureWorkspaceRecordUnarchived clears archivedAt on the workspace and its project", async () => { +test("ensureWorkspaceRecordUnarchived restores the owning archived project with the workspace", async () => { const repo = path.join(tmpDir, "repo"); gitRoots.add(repo); const created = await provisioning.findOrCreateWorkspaceForDirectory(repo); @@ -154,6 +332,24 @@ test("ensureWorkspaceRecordUnarchived clears archivedAt on the workspace and its expect((await projectRegistry.get(created.projectId))?.archivedAt).toBeNull(); }); +test("does not unarchive either record when checkout refresh fails", async () => { + const repo = path.join(tmpDir, "repo"); + gitRoots.add(repo); + const created = await provisioning.findOrCreateWorkspaceForDirectory(repo); + await projectRegistry.archive(created.projectId, ARCHIVED_AT); + await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT); + const archivedProject = await projectRegistry.get(created.projectId); + const archivedWorkspace = await workspaceRegistry.get(created.workspaceId); + checkoutFailure = new Error("Git read failed"); + + await expect(provisioning.ensureWorkspaceRecordUnarchived(archivedWorkspace!)).rejects.toThrow( + "Git read failed", + ); + + expect(await projectRegistry.get(created.projectId)).toEqual(archivedProject); + expect(await workspaceRegistry.get(created.workspaceId)).toEqual(archivedWorkspace); +}); + test("resolveOrCreateWorkspaceIdForCreateAgent returns a created worktree's id without touching the registry", async () => { // The branch only reads workspace.workspaceId off the worktree result. const createdWorktree = { @@ -207,15 +403,90 @@ test("createWorkspaceForDirectory always mints a fresh workspace even when one a expect(await workspaceRegistry.list()).toHaveLength(2); }); -test("findOrCreateProjectForDirectory reuses the active project for the same root", async () => { +test("directory creation persists the live branch and a trimmed title", async () => { + const repo = path.join(tmpDir, "repo"); + gitRoots.add(repo); + const workspace = await provisioning.createWorkspaceForDirectory(repo, " Focused work "); + expect(workspace).toMatchObject({ branch: "main", title: "Focused work" }); +}); + +test("createWorkspaceForDirectory honors an explicit active project without cwd containment", async () => { + const project = await projectRegistry.getOrCreateActiveByRoot({ + rootPath: path.join(tmpDir, "elsewhere"), + kind: "non_git", + displayName: "elsewhere", + timestamp: "2026-03-01T00:00:00.000Z", + }); + const workspace = await provisioning.createWorkspaceForDirectory( + path.join(tmpDir, "directory"), + null, + project.projectId, + ); + expect(workspace.projectId).toBe(project.projectId); +}); + +test("createWorkspaceForDirectory refreshes an explicit project's stale Git kind", async () => { + const rootPath = path.join(tmpDir, "repo"); + gitRoots.add(rootPath); + const project = await projectRegistry.getOrCreateActiveByRoot({ + rootPath, + kind: "non_git", + displayName: "Saved project name", + timestamp: ARCHIVED_AT, + }); + await projectRegistry.upsert({ ...project, customName: "Pinned project name" }); + + const workspace = await provisioning.createWorkspaceForDirectory( + rootPath, + null, + project.projectId, + ); + + expect(workspace.projectId).toBe(project.projectId); + expect(await projectRegistry.get(project.projectId)).toMatchObject({ + projectId: project.projectId, + rootPath, + kind: "git", + displayName: "Saved project name", + customName: "Pinned project name", + }); +}); + +test("createWorkspaceForDirectory classifies unknown and archived explicit projects", async () => { + await expect( + provisioning.createWorkspaceForDirectory(path.join(tmpDir, "directory"), null, "missing"), + ).rejects.toMatchObject({ + code: "unknown_project", + } satisfies Partial); + const project = await projectRegistry.getOrCreateActiveByRoot({ + rootPath: path.join(tmpDir, "archived"), + kind: "non_git", + displayName: "archived", + timestamp: "2026-03-01T00:00:00.000Z", + }); + await projectRegistry.archive(project.projectId, "2026-03-02T00:00:00.000Z"); + await expect( + provisioning.createWorkspaceForDirectory( + path.join(tmpDir, "directory"), + null, + project.projectId, + ), + ).rejects.toMatchObject({ + code: "archived_project", + } satisfies Partial); +}); + +test("findOrCreateProjectForDirectory keeps nested selected roots independent", async () => { const repo = path.join(tmpDir, "repo"); gitRoots.add(repo); const first = await provisioning.findOrCreateProjectForDirectory(repo); const second = await provisioning.findOrCreateProjectForDirectory(path.join(repo, "sub")); - expect(second.projectId).toBe(first.projectId); - expect(await projectRegistry.list()).toHaveLength(1); + expect(second.projectId).not.toBe(first.projectId); + expect(first.rootPath).toBe(repo); + expect(second.rootPath).toBe(path.join(repo, "sub")); + expect(await projectRegistry.list()).toHaveLength(2); }); test("runInImportWorkspace uses an active requested workspace without creating another", async () => { 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 15d910aaf..103033803 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 @@ -1,12 +1,11 @@ -import { resolve } from "node:path"; +import { basename, resolve } from "node:path"; import type { Logger } from "pino"; import { - checkoutLiteFromGitSnapshot, - classifyDirectoryForProjectMembership, generateWorkspaceId, + initialWorkspacePlacement, + reconcileWorkspacePlacement, } from "../../workspace-registry-model.js"; import { - createPersistedProjectRecord, createPersistedWorkspaceRecord, type PersistedProjectRecord, type PersistedWorkspaceRecord, @@ -15,20 +14,8 @@ import { } from "../../workspace-registry.js"; import type { WorkspaceGitService } from "../../workspace-git-service.js"; import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js"; -import { createRealpathAwarePathMatcher } from "../../../utils/path.js"; +import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../../../utils/path.js"; -/** - * Resolves which workspace and project records a directory belongs to, creating, - * reclassifying, or unarchiving them as needed. Every path that needs a workspace - * for a cwd — opening a project, importing an agent, creating an agent, restoring - * an archived worktree — funnels through this one module, so the - * classify → resolve-project → persist → unarchive sequence (and the - * archived-reopen-at-a-different-path and reclassify-vs-unarchive special cases) - * lives in a single place instead of being smeared across the session. - * - * Read-only path resolution (no create/persist) lives in resolve-workspace-id-for-path.ts; - * this module owns the create-and-persist side. - */ export interface ResolveOrCreateWorkspaceIdInput { createdWorktree: CreatePaseoWorktreeWorkflowResult | null; requestedWorkspaceId?: string; @@ -46,6 +33,17 @@ export interface ImportWorkspaceResult { createdWorkspace: PersistedWorkspaceRecord | null; } +export interface CreateWorktreeWorkspaceInput { + sourceCwd: string; + projectId?: string; + repoRoot: string; + cwd: string; + worktreeRoot: string; + branch: string | null; + baseBranch: string | null; + title: string | null; +} + export interface WorkspaceProvisioningService { runInImportWorkspace( input: ImportWorkspaceInput, @@ -56,6 +54,10 @@ export interface WorkspaceProvisioningService { createWorkspaceForDirectory( cwd: string, title?: string | null, + projectId?: string, + ): Promise; + createWorkspaceForWorktree( + input: CreateWorktreeWorkspaceInput, ): Promise; findOrCreateProjectForDirectory(cwd: string): Promise; ensureWorkspaceRecordUnarchived( @@ -63,6 +65,22 @@ export interface WorkspaceProvisioningService { ): Promise; } +export type WorkspaceProvisioningErrorCode = "unknown_project" | "archived_project"; + +export class WorkspaceProvisioningError extends Error { + constructor( + readonly code: WorkspaceProvisioningErrorCode, + projectId: string, + ) { + super( + code === "unknown_project" + ? `Unknown project: ${projectId}` + : `Archived project: ${projectId}`, + ); + this.name = "WorkspaceProvisioningError"; + } +} + export function createWorkspaceProvisioningService(deps: { workspaceRegistry: WorkspaceRegistry; projectRegistry: ProjectRegistry; @@ -134,224 +152,220 @@ export function createWorkspaceProvisioningService(deps: { } } - async function resolveWorkspaceDirectory( - cwd: string, - options?: { refreshGit?: boolean }, - ): Promise { - const normalizedCwd = resolve(cwd); - if (options?.refreshGit === false) { - const snapshot = workspaceGitService.peekSnapshot(normalizedCwd); - return resolve(snapshot?.git.repoRoot ?? normalizedCwd); - } - - const checkout = await workspaceGitService.getCheckout(normalizedCwd); - return resolve(checkout.worktreeRoot ?? normalizedCwd); - } - - async function findExactWorkspaceByDirectory( - cwd: string, - options?: { refreshGit?: boolean }, - ): Promise { - const normalizedCwd = await resolveWorkspaceDirectory(cwd, options); - const workspaces = await workspaceRegistry.list(); - return workspaces.find((workspace) => workspace.cwd === normalizedCwd) ?? null; - } - - async function resolveProjectRecordForPlacement(input: { - membership: ReturnType; - timestamp: string; - }): Promise { - const rootPath = input.membership.projectRootPath; - const kind = input.membership.projectKind; - const projects = await projectRegistry.list(); - const existingProject = - projects.find((project) => !project.archivedAt && project.rootPath === rootPath) ?? - projects.find((project) => project.rootPath === rootPath) ?? - null; - - if (!existingProject) { - return createPersistedProjectRecord({ - projectId: input.membership.projectKey, - rootPath, - kind, - displayName: input.membership.projectName, - createdAt: input.timestamp, - updatedAt: input.timestamp, - }); - } - - return { - ...existingProject, - rootPath, - kind, - archivedAt: null, - updatedAt: input.timestamp, - }; - } - - async function reclassifyOrUnarchiveWorkspaceForDirectory(input: { - workspace: PersistedWorkspaceRecord; - project: PersistedProjectRecord | null; - cwd: string; - }): Promise { - const checkout = await workspaceGitService.getCheckout(input.cwd); - const membership = classifyDirectoryForProjectMembership({ cwd: input.cwd, checkout }); + async function findOrCreateProjectForDirectory(cwd: string): Promise { + const rootPath = resolve(cwd); + const checkout = await workspaceGitService.getCheckout(rootPath); const timestamp = new Date().toISOString(); - const projectRecord = await resolveProjectRecordForPlacement({ - membership, + return projectRegistry.getOrCreateActiveByRoot({ + rootPath, + kind: checkout.isGit ? "git" : "non_git", + displayName: basename(rootPath) || rootPath, timestamp, }); - const projectId = projectRecord.projectId; - const kind = membership.workspaceKind; - const displayName = membership.workspaceDisplayName; + } - if ( - input.workspace.projectId === projectId && - input.workspace.kind === kind && - input.workspace.displayName === displayName - ) { - if (!input.project) { - await projectRegistry.upsert(projectRecord); - } - return ensureWorkspaceRecordUnarchived(input.workspace); + async function requireActiveProject(projectId: string): Promise { + const project = await projectRegistry.get(projectId); + if (!project) throw new WorkspaceProvisioningError("unknown_project", projectId); + if (project.archivedAt) throw new WorkspaceProvisioningError("archived_project", projectId); + return project; + } + + async function createWorkspaceForDirectory( + cwd: string, + title?: string | null, + projectId?: string, + ): Promise { + const normalizedCwd = resolve(cwd); + const checkout = await workspaceGitService.getCheckout(normalizedCwd); + const project = projectId + ? await refreshProjectKind(await requireActiveProject(projectId), normalizedCwd, checkout) + : // COMPAT(workspaceCreateMissingProjectId): added in v0.1.107, remove after 2027-01-15. + await findOrCreateProjectForDirectory(normalizedCwd); + const timestamp = new Date().toISOString(); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: generateWorkspaceId(), + projectId: project.projectId, + ...initialWorkspacePlacement({ source: "checkout", cwd: normalizedCwd, checkout }), + title: title?.trim() || null, + createdAt: timestamp, + updatedAt: timestamp, + }); + await workspaceRegistry.upsert(workspace); + return workspace; + } + + async function createWorkspaceForWorktree( + input: CreateWorktreeWorkspaceInput, + ): Promise { + const sourceCwd = resolve(input.sourceCwd); + const repoRoot = resolve(input.repoRoot); + const cwd = resolve(input.cwd); + const worktreeRoot = resolve(input.worktreeRoot); + const project = await resolveSourceProjectForWorktree({ + sourceCwd, + projectId: input.projectId, + repoRoot, + }); + const timestamp = new Date().toISOString(); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: generateWorkspaceId(), + projectId: project.projectId, + ...initialWorkspacePlacement({ + source: "created_worktree", + cwd, + worktreeRoot, + branch: input.branch, + baseBranch: input.baseBranch, + mainRepoRoot: repoRoot, + }), + title: input.title, + createdAt: timestamp, + updatedAt: timestamp, + }); + await workspaceRegistry.upsert(workspace); + return workspace; + } + + async function resolveSourceProjectForWorktree(input: { + sourceCwd: string; + projectId?: string; + repoRoot: string; + }): Promise { + if (input.projectId) { + return refreshProjectKind(await requireActiveProject(input.projectId)); } - await projectRegistry.upsert(projectRecord); + const workspaces = await workspaceRegistry.list(); + const sourceWorkspace = + workspaces.find( + (workspace) => !workspace.archivedAt && areEquivalentPaths(workspace.cwd, input.sourceCwd), + ) ?? + workspaces.find( + (workspace) => !workspace.archivedAt && areEquivalentPaths(workspace.cwd, input.repoRoot), + ); + if (sourceWorkspace) { + const project = await projectRegistry.get(sourceWorkspace.projectId); + if (project) return refreshProjectKind(project); + // COMPAT(worktreeMissingSourceProject): added in v0.1.107, remove after 2027-01-15. + // Orphaned legacy workspace FKs fall through to exact-root allocation. + } - const nextWorkspace = { - ...input.workspace, - projectId, - cwd: input.cwd, - kind, - displayName, - archivedAt: null, - updatedAt: timestamp, - }; - await workspaceRegistry.upsert(nextWorkspace); - return nextWorkspace; + const project = await projectRegistry.getOrCreateActiveByRoot({ + rootPath: input.repoRoot, + kind: "git", + displayName: basename(input.repoRoot) || input.repoRoot, + timestamp: new Date().toISOString(), + }); + return refreshProjectKind(project); } async function findOrCreateWorkspaceForDirectory(cwd: string): Promise { - const inputCwd = resolve(cwd); - const normalizedCwd = await resolveWorkspaceDirectory(cwd); - const existingWorkspace = await findExactWorkspaceByDirectory(normalizedCwd, { - refreshGit: false, - }); - if (existingWorkspace) { - if (existingWorkspace.archivedAt && inputCwd !== normalizedCwd) { - const timestamp = new Date().toISOString(); - const checkout = checkoutLiteFromGitSnapshot(inputCwd, { - isGit: false, - currentBranch: null, - remoteUrl: null, - repoRoot: null, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }); - const membership = classifyDirectoryForProjectMembership({ cwd: inputCwd, checkout }); - const projectRecord = await resolveProjectRecordForPlacement({ - membership, - timestamp, - }); - await projectRegistry.upsert(projectRecord); - const workspaceRecord = createPersistedWorkspaceRecord({ - workspaceId: generateWorkspaceId(), - projectId: projectRecord.projectId, - cwd: inputCwd, - kind: membership.workspaceKind, - displayName: membership.workspaceDisplayName, - createdAt: timestamp, - updatedAt: timestamp, - }); - await workspaceRegistry.upsert(workspaceRecord); - return workspaceRecord; - } - return reclassifyOrUnarchiveWorkspaceForDirectory({ - workspace: existingWorkspace, - project: await projectRegistry.get(existingWorkspace.projectId), - cwd: normalizedCwd, - }); + const normalizedCwd = resolve(cwd); + const workspaces = await workspaceRegistry.list(); + const active = workspaces + .filter( + (workspace) => !workspace.archivedAt && areEquivalentPaths(workspace.cwd, normalizedCwd), + ) + .sort( + (left, right) => + Date.parse(left.createdAt) - Date.parse(right.createdAt) || + left.workspaceId.localeCompare(right.workspaceId), + )[0]; + if (active) return refreshWorkspaceRecord(active); + const archived = workspaces + .filter( + (workspace) => workspace.archivedAt && areEquivalentPaths(workspace.cwd, normalizedCwd), + ) + .sort( + (left, right) => + Date.parse(left.createdAt) - Date.parse(right.createdAt) || + left.workspaceId.localeCompare(right.workspaceId), + )[0]; + if (archived) { + const project = await projectRegistry.get(archived.projectId); + if (project && !project.archivedAt) return ensureWorkspaceRecordUnarchived(archived); } - return createWorkspaceForDirectory(normalizedCwd); } async function resolveOrCreateWorkspaceIdForCreateAgent( input: ResolveOrCreateWorkspaceIdInput, ): Promise { - if (input.createdWorktree) { - return input.createdWorktree.workspace.workspaceId; - } - - if (input.requestedWorkspaceId) { - return input.requestedWorkspaceId; - } - + if (input.createdWorktree) return input.createdWorktree.workspace.workspaceId; + if (input.requestedWorkspaceId) return input.requestedWorkspaceId; return (await createWorkspaceForDirectory(input.cwd, input.initialTitle)).workspaceId; } - async function createWorkspaceForDirectory( - cwd: string, - title?: string | null, - ): Promise { - const checkout = await workspaceGitService.getCheckout(cwd); - const membership = classifyDirectoryForProjectMembership({ cwd, checkout }); - const timestamp = new Date().toISOString(); - - const projectRecord = await resolveProjectRecordForPlacement({ - membership, - timestamp, - }); - await projectRegistry.upsert(projectRecord); - - const workspaceRecord = createPersistedWorkspaceRecord({ - workspaceId: generateWorkspaceId(), - projectId: projectRecord.projectId, - cwd, - kind: membership.workspaceKind, - displayName: membership.workspaceDisplayName, - title: title ?? null, - createdAt: timestamp, - updatedAt: timestamp, - }); - await workspaceRegistry.upsert(workspaceRecord); - return workspaceRecord; - } - - async function findOrCreateProjectForDirectory(cwd: string): Promise { - const normalizedCwd = resolve(cwd); - const checkout = await workspaceGitService.getCheckout(normalizedCwd); - const membership = classifyDirectoryForProjectMembership({ cwd: normalizedCwd, checkout }); - const projectRecord = await resolveProjectRecordForPlacement({ - membership, - timestamp: new Date().toISOString(), - }); - await projectRegistry.upsert(projectRecord); - return projectRecord; - } - async function ensureWorkspaceRecordUnarchived( workspace: PersistedWorkspaceRecord, ): Promise { const project = await projectRegistry.get(workspace.projectId); - if (!workspace.archivedAt && (!project || !project.archivedAt)) { - return workspace; - } - + if (!project) throw new Error(`Unknown project: ${workspace.projectId}`); const timestamp = new Date().toISOString(); - let unarchivedWorkspace = workspace; - if (workspace.archivedAt) { - unarchivedWorkspace = { ...workspace, archivedAt: null, updatedAt: timestamp }; - await workspaceRegistry.upsert(unarchivedWorkspace); - } - if (project?.archivedAt) { - await projectRegistry.upsert({ - ...project, - archivedAt: null, + const checkout = + workspace.archivedAt || project.archivedAt + ? await workspaceGitService.getCheckout(workspace.cwd) + : null; + let next: PersistedWorkspaceRecord | null = null; + if (workspace.archivedAt && checkout) { + const placementUpdate = reconcileWorkspacePlacement({ + workspace, + checkout, updatedAt: timestamp, }); + next = { + ...(placementUpdate?.workspace ?? workspace), + archivedAt: null, + updatedAt: timestamp, + }; } - return unarchivedWorkspace; + if (checkout && (project.archivedAt || workspace.archivedAt)) { + const projectCheckout = areEquivalentPaths(project.rootPath, workspace.cwd) + ? checkout + : await workspaceGitService.getCheckout(project.rootPath); + const kind = projectCheckout.isGit ? "git" : "non_git"; + if (project.archivedAt || project.kind !== kind) { + await projectRegistry.upsert({ ...project, kind, archivedAt: null, updatedAt: timestamp }); + } + } + if (!next) return workspace; + await workspaceRegistry.upsert(next); + return next; + } + + async function refreshWorkspaceRecord( + workspace: PersistedWorkspaceRecord, + ): Promise { + const checkout = await workspaceGitService.getCheckout(workspace.cwd); + const project = await projectRegistry.get(workspace.projectId); + if (project && !project.archivedAt) { + await refreshProjectKind(project, workspace.cwd, checkout); + } + const update = reconcileWorkspacePlacement({ + workspace, + checkout, + updatedAt: new Date().toISOString(), + }); + if (!update) return workspace; + await workspaceRegistry.upsert(update.workspace); + return update.workspace; + } + + async function refreshProjectKind( + project: PersistedProjectRecord, + workspaceCwd?: string, + workspaceCheckout?: Awaited>, + ): Promise { + const projectCheckout = + workspaceCwd && workspaceCheckout && areEquivalentPaths(project.rootPath, workspaceCwd) + ? workspaceCheckout + : await workspaceGitService.getCheckout(project.rootPath); + const kind: PersistedProjectRecord["kind"] = projectCheckout.isGit ? "git" : "non_git"; + if (project.kind === kind) return project; + const refreshed = { ...project, kind, updatedAt: new Date().toISOString() }; + await projectRegistry.upsert(refreshed); + return refreshed; } return { @@ -359,6 +373,7 @@ export function createWorkspaceProvisioningService(deps: { findOrCreateWorkspaceForDirectory, resolveOrCreateWorkspaceIdForCreateAgent, createWorkspaceForDirectory, + createWorkspaceForWorktree, 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 2d04d9fc9..91631a563 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 @@ -1,4 +1,19 @@ -import { describe, expect, test } from "vitest"; +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, test } from "vitest"; + +import { createWorktree } from "../../../utils/worktree.js"; import { createPersistedProjectRecord, createPersistedWorkspaceRecord, @@ -8,15 +23,23 @@ import { import { createWorkspaceRecoveryService } from "./workspace-recovery-service.js"; const NOW = "2026-07-11T10:12:30.752Z"; +const tempDirectories: string[] = []; -function createProject(): PersistedProjectRecord { +afterEach(() => { + for (const directory of tempDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function createProject(overrides: Partial = {}): PersistedProjectRecord { return createPersistedProjectRecord({ - projectId: "/repo", - rootPath: "/repo", + projectId: "/project", + rootPath: "/project", kind: "git", - displayName: "repo", + displayName: "project", createdAt: NOW, updatedAt: NOW, + ...overrides, }); } @@ -25,12 +48,15 @@ function createWorkspace( ): PersistedWorkspaceRecord { return createPersistedWorkspaceRecord({ workspaceId: "wks_15a1b5630ebaab33", - projectId: "/repo", + projectId: "/project", cwd: "/worktrees/trigger-1525443412986298439", kind: "worktree", displayName: "diagnose-repro-tdd", - title: "Codex TDD reproduction", + title: "TDD reproduction", branch: "diagnose-repro-tdd", + worktreeRoot: "/worktrees/trigger-1525443412986298439", + isPaseoOwnedWorktree: true, + mainRepoRoot: "/repo", createdAt: NOW, updatedAt: NOW, archivedAt: NOW, @@ -42,55 +68,53 @@ function createHarness(input?: { workspace?: PersistedWorkspaceRecord | null; project?: PersistedProjectRecord | null; directories?: string[]; - recreate?: (workspace: PersistedWorkspaceRecord) => Promise; + paseoHome?: string; + worktreesRoot?: string; }) { const workspace = input?.workspace === undefined ? createWorkspace() : input.workspace; const project = input?.project === undefined ? createProject() : input.project; const directories = new Set(input?.directories ?? ["/repo"]); const unarchived: string[] = []; - const recreated: string[] = []; const service = createWorkspaceRecoveryService({ + paseoHome: input?.paseoHome ?? "/paseo-home", + worktreesRoot: input?.worktreesRoot ?? "/worktrees", getWorkspace: async (workspaceId) => workspace?.workspaceId === workspaceId ? workspace : null, getProject: async (projectId) => (project?.projectId === projectId ? project : null), isDirectory: async (path) => directories.has(path), - recreateWorktree: async (record) => { - recreated.push(record.workspaceId); - await input?.recreate?.(record); - }, unarchiveWorkspace: async (record) => { unarchived.push(record.workspaceId); }, }); - return { service, recreated, unarchived }; + return { service, unarchived }; } describe("workspace recovery", () => { - test("authoritatively describes the archived missing worktree from the failed cloud run", async () => { - const { service, recreated, unarchived } = createHarness(); + test("describes a missing archived worktree from persisted placement", async () => { + const { service, unarchived } = createHarness(); await expect(service.inspect("wks_15a1b5630ebaab33")).resolves.toEqual({ kind: "recoverable", workspaceId: "wks_15a1b5630ebaab33", - workspaceName: "Codex TDD reproduction", + workspaceName: "TDD reproduction", action: "restore", branch: "diagnose-repro-tdd", }); - expect(recreated).toEqual([]); expect(unarchived).toEqual([]); }); - test("describes an archived workspace whose directory remains as unarchivable", async () => { + test("unarchives an archived workspace whose exact directory remains", async () => { const workspace = createWorkspace({ kind: "directory", branch: null }); - const { service } = createHarness({ + const { service, unarchived } = createHarness({ workspace, - directories: ["/repo", workspace.cwd], + directories: [workspace.cwd], }); - await expect(service.inspect(workspace.workspaceId)).resolves.toMatchObject({ - kind: "recoverable", + await expect(service.restore(workspace.workspaceId)).resolves.toEqual({ + workspaceId: workspace.workspaceId, action: "unarchive", }); + expect(unarchived).toEqual([workspace.workspaceId]); }); test("does not offer recovery for a missing non-worktree directory", async () => { @@ -105,27 +129,160 @@ describe("workspace recovery", () => { }); }); - test("keeps the workspace archived when recreation fails so restore can be retried", async () => { - let attempts = 0; - const { service, recreated, unarchived } = createHarness({ - recreate: async () => { - attempts += 1; - if (attempts === 1) { - throw new Error("git branch diagnose-repro-tdd is unavailable"); - } + test("uses the persisted source repository instead of the owning project to restore an exact subdirectory", async () => { + const { tempDir, repoDir } = createGitRepository(); + const branch = "feature/mixed-project"; + const sourceSubdirectory = join(repoDir, "packages", "app"); + mkdirSync(sourceSubdirectory, { recursive: true }); + writeFileSync(join(sourceSubdirectory, "README.md"), "app\n"); + execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "add app"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" }); + + const paseoHome = join(tempDir, "paseo-home"); + const worktreesRoot = join(tempDir, "worktrees"); + const created = await createWorktree({ + cwd: repoDir, + worktreeSlug: "mixed-project", + source: { kind: "checkout-branch", branchName: branch }, + runSetup: false, + paseoHome, + worktreesRoot, + }); + const worktreeRoot = realpathSync(created.worktreePath); + const workspaceCwd = join(worktreeRoot, "packages", "app"); + rmSync(worktreeRoot, { recursive: true, force: true }); + execFileSync("git", ["worktree", "prune"], { cwd: repoDir, stdio: "pipe" }); + + const projectRoot = join(tempDir, "explicit-non-git-project"); + mkdirSync(projectRoot); + const project = createProject({ + projectId: "explicit-non-git-project", + rootPath: projectRoot, + kind: "non_git", + }); + const workspace = createWorkspace({ + workspaceId: "ws-mixed-project-recreate", + projectId: project.projectId, + cwd: workspaceCwd, + branch, + worktreeRoot, + mainRepoRoot: repoDir, + }); + const unarchived: string[] = []; + const service = createWorkspaceRecoveryService({ + paseoHome, + worktreesRoot, + getWorkspace: async (workspaceId) => + workspaceId === workspace.workspaceId ? workspace : null, + getProject: async (projectId) => (projectId === project.projectId ? project : null), + isDirectory: async (path) => existsSync(path) && statSync(path).isDirectory(), + unarchiveWorkspace: async (record) => { + unarchived.push(record.workspaceId); }, }); - await expect(service.restore("wks_15a1b5630ebaab33")).rejects.toThrow( - "git branch diagnose-repro-tdd is unavailable", - ); - expect(unarchived).toEqual([]); - - await expect(service.restore("wks_15a1b5630ebaab33")).resolves.toEqual({ - workspaceId: "wks_15a1b5630ebaab33", + await expect(service.restore(workspace.workspaceId)).resolves.toEqual({ + workspaceId: workspace.workspaceId, action: "restore", }); - expect(recreated).toEqual(["wks_15a1b5630ebaab33", "wks_15a1b5630ebaab33"]); - expect(unarchived).toEqual(["wks_15a1b5630ebaab33"]); + expect(existsSync(worktreeRoot)).toBe(true); + expect(existsSync(workspaceCwd)).toBe(true); + expect(unarchived).toEqual([workspace.workspaceId]); + }); + + test("keeps an exact-subdirectory workspace archived when its branch lacks that directory", async () => { + const { tempDir, repoDir } = createGitRepository(); + const branch = "feature/without-subproject"; + execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" }); + const paseoHome = join(tempDir, "paseo-home"); + const worktreesRoot = join(tempDir, "worktrees"); + const created = await createWorktree({ + cwd: repoDir, + worktreeSlug: "without-subproject", + source: { kind: "checkout-branch", branchName: branch }, + runSetup: false, + paseoHome, + worktreesRoot, + }); + const worktreeRoot = realpathSync(created.worktreePath); + const workspaceCwd = join(worktreeRoot, "packages", "app"); + rmSync(worktreeRoot, { recursive: true, force: true }); + execFileSync("git", ["worktree", "prune"], { cwd: repoDir, stdio: "pipe" }); + + const project = createProject({ rootPath: repoDir }); + const workspace = createWorkspace({ + workspaceId: "ws-missing-restored-subdirectory", + cwd: workspaceCwd, + branch, + worktreeRoot, + mainRepoRoot: repoDir, + }); + const unarchived: string[] = []; + const service = createWorkspaceRecoveryService({ + paseoHome, + worktreesRoot, + getWorkspace: async (workspaceId) => + workspaceId === workspace.workspaceId ? workspace : null, + getProject: async (projectId) => (projectId === project.projectId ? project : null), + isDirectory: async (targetPath) => + existsSync(targetPath) && statSync(targetPath).isDirectory(), + unarchiveWorkspace: async (record) => { + unarchived.push(record.workspaceId); + }, + }); + + await expect(service.restore(workspace.workspaceId)).rejects.toThrow( + "Selected project directory is missing from the restored worktree", + ); + expect(unarchived).toEqual([]); + expect(existsSync(worktreeRoot)).toBe(false); + expect( + execFileSync("git", ["worktree", "list", "--porcelain"], { + cwd: repoDir, + stdio: "pipe", + }) + .toString() + .includes("without-subproject"), + ).toBe(false); + }); + + test("keeps the workspace archived when its persisted source repository is missing", async () => { + const workspace = createWorkspace({ mainRepoRoot: "/missing-source" }); + const { service, unarchived } = createHarness({ + workspace, + directories: ["/project"], + }); + + await expect(service.inspect(workspace.workspaceId)).resolves.toEqual({ + kind: "unavailable", + workspaceId: workspace.workspaceId, + reason: "project_directory_missing", + message: "The source repository needed to restore this worktree no longer exists.", + }); + await expect(service.restore(workspace.workspaceId)).rejects.toThrow( + "The source repository needed to restore this worktree no longer exists.", + ); + expect(unarchived).toEqual([]); }); }); + +function createGitRepository(): { tempDir: string; repoDir: string } { + const tempDir = mkdtempSync(join(tmpdir(), "paseo-workspace-recovery-")); + tempDirectories.push(tempDir); + const repoDir = join(tempDir, "repo"); + mkdirSync(repoDir); + execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.name", "Paseo Test"], { + cwd: repoDir, + stdio: "pipe", + }); + writeFileSync(join(repoDir, "README.md"), "initial\n"); + execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "initial"], { cwd: repoDir, stdio: "pipe" }); + return { tempDir, repoDir }; +} 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 31a5f0415..1d98a9128 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 @@ -1,3 +1,14 @@ +import { basename } from "node:path"; + +import { createRealpathAwarePathMatcher } from "../../../utils/path.js"; +import { runGitCommand } from "../../../utils/run-git-command.js"; +import { + createWorktree, + isPaseoOwnedWorktreeCwd, + mapWorkspaceCwdToWorktree, + rollbackCreatedPaseoWorktree, +} from "../../../utils/worktree.js"; +import { WorktreeRequestError, toWorktreeRequestError } from "../../worktree-errors.js"; import { resolveWorkspaceDisplayName, type PersistedProjectRecord, @@ -32,14 +43,32 @@ export interface WorkspaceRecoveryService { restore(workspaceId: string): Promise<{ workspaceId: string; action: WorkspaceRecoveryAction }>; } +type RecoveryPlan = + | { + kind: "unarchive"; + state: Extract; + workspace: PersistedWorkspaceRecord; + } + | { + kind: "restore"; + state: Extract; + workspace: PersistedWorkspaceRecord; + sourceRepoRoot: string; + }; + +type UnavailableRecoveryState = Extract; + export function createWorkspaceRecoveryService(deps: { + paseoHome: string; + worktreesRoot?: string; getWorkspace: (workspaceId: string) => Promise; getProject: (projectId: string) => Promise; isDirectory: (path: string) => Promise; - recreateWorktree: (workspace: PersistedWorkspaceRecord) => Promise; unarchiveWorkspace: (workspace: PersistedWorkspaceRecord) => Promise; }): WorkspaceRecoveryService { - async function inspect(workspaceId: string): Promise { + async function resolveRecovery( + workspaceId: string, + ): Promise { const workspace = await deps.getWorkspace(workspaceId); if (!workspace) { return { @@ -69,13 +98,7 @@ export function createWorkspaceRecoveryService(deps: { } if (await deps.isDirectory(workspace.cwd)) { - return { - kind: "recoverable", - workspaceId, - workspaceName: resolveWorkspaceDisplayName(workspace), - action: "unarchive", - branch: workspace.branch, - }; + return createRecoveryPlan({ action: "unarchive", workspace }); } if (workspace.kind !== "worktree") { @@ -94,42 +117,148 @@ export function createWorkspaceRecoveryService(deps: { message: "The archived worktree has no branch recorded, so it cannot be restored.", }; } - if (!(await deps.isDirectory(project.rootPath))) { + + // COMPAT(worktreeRestoreMissingMainRepoRoot): records created before v0.1.110 + // lack placement ownership; remove the project-root fallback after 2027-01-17. + const sourceRepoRoot = workspace.mainRepoRoot ?? project.rootPath; + if (!(await deps.isDirectory(sourceRepoRoot))) { return { kind: "unavailable", workspaceId, reason: "project_directory_missing", - message: "The project directory needed to restore this worktree no longer exists.", + message: "The source repository needed to restore this worktree no longer exists.", }; } - return { - kind: "recoverable", - workspaceId, - workspaceName: resolveWorkspaceDisplayName(workspace), - action: "restore", - branch: workspace.branch, - }; + return createRecoveryPlan({ action: "restore", workspace, sourceRepoRoot }); + } + + async function inspect(workspaceId: string): Promise { + const resolved = await resolveRecovery(workspaceId); + return resolved.kind === "unavailable" ? resolved : resolved.state; } async function restore( workspaceId: string, ): Promise<{ workspaceId: string; action: WorkspaceRecoveryAction }> { - const state = await inspect(workspaceId); - if (state.kind === "unavailable") { - throw new Error(state.message); + const resolved = await resolveRecovery(workspaceId); + if (resolved.kind === "unavailable") { + throw new Error(resolved.message); } - const workspace = await deps.getWorkspace(workspaceId); - if (!workspace?.archivedAt) { - throw new Error("The archived workspace changed before it could be recovered."); + if (resolved.kind === "restore") { + await recreateArchivedWorktree(resolved.workspace, resolved.sourceRepoRoot); } - if (state.action === "restore") { - await deps.recreateWorktree(workspace); + await deps.unarchiveWorkspace(resolved.workspace); + return { workspaceId, action: resolved.kind }; + } + + async function recreateArchivedWorktree( + workspace: PersistedWorkspaceRecord, + sourceRepoRoot: string, + ): Promise { + const branch = workspace.branch; + if (!branch) { + throw new WorktreeRequestError({ + code: "unknown", + message: `Workspace ${workspace.workspaceId} has no branch to restore`, + }); + } + + try { + await runGitCommand(["worktree", "prune"], { cwd: sourceRepoRoot, timeout: 30_000 }); + } catch { + // A stale worktree registration is not guaranteed; creation reports any real conflict. + } + + let previousWorktreePath = workspace.worktreeRoot; + if (!previousWorktreePath) { + // COMPAT(worktreeRestoreMissingWorktreeRoot): records created before v0.1.110 + // lack durable backing placement; remove filesystem discovery after 2027-01-17. + const ownership = await isPaseoOwnedWorktreeCwd(workspace.cwd, { + paseoHome: deps.paseoHome, + worktreesRoot: deps.worktreesRoot, + }); + previousWorktreePath = ownership.allowed + ? (ownership.worktreePath ?? workspace.cwd) + : workspace.cwd; + } + + let recreatedWorktreePath: string; + try { + const result = await createWorktree({ + cwd: sourceRepoRoot, + worktreeSlug: basename(previousWorktreePath), + source: { kind: "checkout-branch", branchName: branch }, + runSetup: false, + paseoHome: deps.paseoHome, + worktreesRoot: deps.worktreesRoot, + }); + recreatedWorktreePath = result.worktreePath; + } catch (error) { + throw toWorktreeRequestError(error); + } + + try { + const recreatedWorkspacePath = mapWorkspaceCwdToWorktree({ + sourceWorktreePath: previousWorktreePath, + workspaceCwd: workspace.cwd, + targetWorktreePath: recreatedWorktreePath, + }); + if (!createRealpathAwarePathMatcher(workspace.cwd)(recreatedWorkspacePath)) { + throw new WorktreeRequestError({ + code: "unknown", + message: `Recreated worktree diverged from ${workspace.cwd}: ${recreatedWorkspacePath}`, + }); + } + if (!(await deps.isDirectory(recreatedWorkspacePath))) { + throw new WorktreeRequestError({ + code: "unknown", + message: `Selected project directory is missing from the restored worktree: ${recreatedWorkspacePath}`, + }); + } + } catch (error) { + return rollbackCreatedPaseoWorktree( + { + cwd: sourceRepoRoot, + worktreePath: recreatedWorktreePath, + teardownCwds: [], + paseoHome: deps.paseoHome, + worktreesBaseRoot: deps.worktreesRoot, + }, + error, + ); } - await deps.unarchiveWorkspace(workspace); - return { workspaceId, action: state.action }; } return { inspect, restore }; } + +function createRecoveryPlan( + input: + | { action: "unarchive"; workspace: PersistedWorkspaceRecord } + | { action: "restore"; workspace: PersistedWorkspaceRecord; sourceRepoRoot: string }, +): RecoveryPlan { + const state = { + kind: "recoverable" as const, + workspaceId: input.workspace.workspaceId, + workspaceName: resolveWorkspaceDisplayName(input.workspace), + branch: input.workspace.branch, + }; + if (input.action === "restore") { + return { + kind: input.action, + state: { ...state, action: input.action }, + workspace: input.workspace, + sourceRepoRoot: input.sourceRepoRoot, + }; + } + return { + kind: input.action, + state: { + ...state, + action: input.action, + }, + workspace: input.workspace, + }; +} 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 fbb0b78c9..137b83ac1 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 @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pino } from "pino"; @@ -6,34 +6,29 @@ import { afterEach, describe, expect, test } from "vitest"; import type { SessionOutboundMessage, StartWorkspaceScriptRequest } from "../../messages.js"; import { createServiceProxySubsystem, type ServiceProxySubsystem } from "../../service-proxy.js"; import type { TerminalManager } from "../../../terminal/terminal-manager.js"; -import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "../../workspace-registry.js"; -import type { WorkspaceGitMetadata } from "../../workspace-git-metadata.js"; +import type { + PersistedProjectRecord, + PersistedWorkspaceRecord, + ProjectRegistry, + WorkspaceRegistry, +} from "../../workspace-registry.js"; +import { createNoGitWorkspaceRuntimeSnapshot } from "../../test-utils/workspace-git-service-stub.js"; import { WorkspaceScriptRuntimeStore } from "../../workspace-script-runtime-store.js"; import type { SpawnWorkspaceScriptOptions, WorktreeScriptResult, } from "../../worktree-bootstrap.js"; +import type { WorkspaceGitService } from "../../workspace-git-service.js"; import { createWorkspaceScriptsService } from "./workspace-scripts-service.js"; +import { deriveProjectServiceSlug } from "../../workspace-git-metadata.js"; -// The production module reads only WorkspaceGitService.{peekSnapshot,getWorkspaceGitMetadata}, +// The production module reads only WorkspaceGitService.{peekSnapshot,getProjectSlug}, // WorkspaceRegistry.get, and forwards the launcher + opaque managers to the injected // spawnWorkspaceScript port. The fakes below implement exactly that slice; the service proxy and // runtime store are the real in-memory implementations, and spawning is injected so no process runs. const logger = pino({ level: "silent" }); -const gitMetadata: WorkspaceGitMetadata = { - projectKind: "git", - projectDisplayName: "repo", - workspaceDisplayName: "repo", - gitRemote: null, - isWorktree: false, - projectSlug: "paseo", - repoRoot: "/tmp/repo", - currentBranch: "feature/scripts", - remoteUrl: null, -}; - function fakeWorkspaceRegistry( record: PersistedWorkspaceRecord | null, ): Pick { @@ -44,13 +39,28 @@ function fakeWorkspaceRegistry( }; } -function fakeGitService(metadata: WorkspaceGitMetadata = gitMetadata) { +function fakeProjectRegistry(record: PersistedProjectRecord | null): Pick { + return { + async get() { + return record; + }, + }; +} + +function fakeGitService() { + const snapshot = createNoGitWorkspaceRuntimeSnapshot("/tmp/repo"); + snapshot.git = { + ...snapshot.git, + isGit: true, + repoRoot: "/tmp/repo", + currentBranch: "feature/scripts", + remoteUrl: "https://github.com/getpaseo/paseo.git", + hasRemote: true, + }; + return { peekSnapshot() { - return null; - }, - async getWorkspaceGitMetadata() { - return metadata; + return snapshot; }, }; } @@ -64,7 +74,9 @@ interface BuildOptions { scriptRuntimeStore?: WorkspaceScriptRuntimeStore | null; terminalManager?: TerminalManager | null; workspace?: PersistedWorkspaceRecord | null; + project?: PersistedProjectRecord | null; spawnThrows?: string; + gitService?: Pick; } function buildService(options: BuildOptions = {}) { @@ -87,7 +99,8 @@ function buildService(options: BuildOptions = {}) { terminalManager: options.terminalManager === undefined ? availableTerminalManager : options.terminalManager, workspaceRegistry: fakeWorkspaceRegistry(workspace), - workspaceGitService: fakeGitService(), + projectRegistry: fakeProjectRegistry(options.project ?? null), + workspaceGitService: options.gitService ?? fakeGitService(), getDaemonTcpPort: () => 6767, getDaemonTcpHost: () => "127.0.0.1", serviceProxyPublicBaseUrl: null, @@ -130,28 +143,77 @@ afterEach(() => { }); describe("buildSnapshot", () => { - test("returns no scripts when the service proxy is unavailable", () => { + test("returns no scripts when the service proxy is unavailable", async () => { const { service } = buildService({ serviceProxy: null }); - expect(service.buildSnapshot("ws-1", "/tmp/repo")).toEqual([]); + expect( + service.buildSnapshot({ workspaceId: "ws-1", cwd: "/tmp/repo" } as PersistedWorkspaceRecord), + ).toEqual([]); }); - test("returns no scripts when the runtime store is unavailable", () => { + test("returns no scripts when the runtime store is unavailable", async () => { const { service } = buildService({ scriptRuntimeStore: null }); - expect(service.buildSnapshot("ws-1", "/tmp/repo")).toEqual([]); + expect( + service.buildSnapshot({ workspaceId: "ws-1", cwd: "/tmp/repo" } as PersistedWorkspaceRecord), + ).toEqual([]); }); - test("returns no scripts for a workspace without a paseo.json", () => { + test("returns no scripts for a workspace without a paseo.json", async () => { const dir = mkdtempSync(join(tmpdir(), "workspace-scripts-")); tempDirs.push(dir); const { service } = buildService(); - expect(service.buildSnapshot("ws-1", dir)).toEqual([]); + expect( + service.buildSnapshot({ workspaceId: "ws-1", cwd: dir } as PersistedWorkspaceRecord), + ).toEqual([]); + }); + + test("projects service hostnames without a Git snapshot", async () => { + const directory = mkdtempSync(join(tmpdir(), "workspace-scripts-")); + tempDirs.push(directory); + writeFileSync( + join(directory, "paseo.json"), + JSON.stringify({ scripts: { app: { type: "service", command: "npm run app", port: 3000 } } }), + ); + const project = { + projectId: "prj_no_snapshot", + rootPath: directory, + kind: "git", + displayName: "app", + customName: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + } as PersistedProjectRecord; + const workspace = { + workspaceId: "ws-no-snapshot", + projectId: project.projectId, + cwd: directory, + branch: "feature/persisted", + } as PersistedWorkspaceRecord; + const serviceProxy = createServiceProxySubsystem({ logger }); + const { service, spawnCalls } = buildService({ + workspace, + project, + serviceProxy, + gitService: { peekSnapshot: () => undefined }, + }); + + expect(service.buildSnapshot(workspace, project)[0]?.hostname).toBe( + serviceProxy.projectWorkspaceService({ + projectSlug: deriveProjectServiceSlug(project), + branchName: workspace.branch, + scriptName: "app", + daemonPort: 6767, + }).hostname, + ); + await service.start({ ...request, workspaceId: workspace.workspaceId }); + expect(spawnCalls[0]?.branchName).toBe(workspace.branch); }); }); describe("emitStatusUpdate", () => { - test("emits one script_status_update carrying the snapshot", () => { + test("emits one script_status_update carrying the snapshot", async () => { const { service, emitted } = buildService(); - service.emitStatusUpdate("ws-1", "/tmp/repo"); + await service.emitStatusUpdate("ws-1", "/tmp/repo"); expect(emitted).toEqual([ { type: "script_status_update", payload: { workspaceId: "ws-1", scripts: [] } }, ]); @@ -225,6 +287,101 @@ describe("start", () => { }); }); + test("uses the exact project root for a service hostname", async () => { + const workspace = { + workspaceId: "ws-app", + projectId: "prj-app", + cwd: "/repo/apps/app", + } as PersistedWorkspaceRecord; + const project = { + projectId: "prj-app", + rootPath: "/repo/apps/app", + kind: "git", + displayName: "app", + customName: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + } as PersistedProjectRecord; + const { service, spawnCalls } = buildService({ workspace, project }); + + await service.start({ ...request, workspaceId: workspace.workspaceId }); + + expect(spawnCalls[0]).toMatchObject({ + projectSlug: deriveProjectServiceSlug(project), + }); + }); + + test("keeps same-named service projects distinct", async () => { + const projectA = { + projectId: "prj-app-a", + rootPath: "/repo-a/app", + kind: "git", + displayName: "app", + customName: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + } as PersistedProjectRecord; + const projectB = { ...projectA, projectId: "prj-app-b", rootPath: "/repo-b/app" }; + const workspaceA = { + workspaceId: "ws-app-a", + projectId: projectA.projectId, + cwd: projectA.rootPath, + } as PersistedWorkspaceRecord; + const workspaceB = { + workspaceId: "ws-app-b", + projectId: projectB.projectId, + cwd: projectB.rootPath, + } as PersistedWorkspaceRecord; + const first = buildService({ workspace: workspaceA, project: projectA }); + const second = buildService({ workspace: workspaceB, project: projectB }); + + await first.service.start({ ...request, workspaceId: workspaceA.workspaceId }); + await second.service.start({ ...request, workspaceId: workspaceB.workspaceId }); + + expect(first.spawnCalls[0]?.projectSlug).not.toBe(second.spawnCalls[0]?.projectSlug); + }); + + test("predicts the same service hostname that start registers", async () => { + const directory = mkdtempSync(join(tmpdir(), "workspace-scripts-")); + tempDirs.push(directory); + writeFileSync( + join(directory, "paseo.json"), + JSON.stringify({ scripts: { app: { type: "service", command: "npm run app", port: 3000 } } }), + ); + const project = { + projectId: "prj_hostname", + rootPath: directory, + kind: "git", + displayName: "app", + customName: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + } as PersistedProjectRecord; + const workspace = { + workspaceId: "ws-hostname", + projectId: project.projectId, + cwd: directory, + } as PersistedWorkspaceRecord; + const serviceProxy = createServiceProxySubsystem({ logger }); + const { service, spawnCalls } = buildService({ workspace, project, serviceProxy }); + + const snapshot = service.buildSnapshot(workspace, project); + await service.start({ ...request, workspaceId: workspace.workspaceId }); + + const started = spawnCalls[0]!; + expect(snapshot[0]?.hostname).toBe( + serviceProxy.projectWorkspaceService({ + projectSlug: started.projectSlug, + branchName: started.branchName, + scriptName: started.scriptName, + daemonPort: started.daemonPort, + }).hostname, + ); + }); + test("reports the launcher error when spawning fails", async () => { const { service, emitted } = buildService({ spawnThrows: "boom" }); await service.start(request); 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 044b23c5d..a17c34b0b 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,12 @@ 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 { WorkspaceRegistry } from "../../workspace-registry.js"; +import type { + PersistedProjectRecord, + PersistedWorkspaceRecord, + ProjectRegistry, + WorkspaceRegistry, +} from "../../workspace-registry.js"; import type { SpawnWorkspaceScriptOptions, WorktreeScriptResult, @@ -18,15 +23,10 @@ import { buildWorkspaceScriptPayloads, readPaseoConfigForProjection, } from "../../script-status-projection.js"; -import { deriveProjectSlug } from "../../workspace-git-metadata.js"; +import { deriveProjectServiceSlug, deriveProjectSlug } from "../../workspace-git-metadata.js"; type WorkspaceScriptsPayload = WorkspaceDescriptorPayload["scripts"]; -interface WorkspaceScriptGitMetadata { - projectSlug: string; - currentBranch: string | null; -} - /** * The service-proxy-backed scripts a workspace exposes: build the scripts payload * snapshot, emit a script_status_update to clients, and start a script. @@ -37,21 +37,22 @@ interface WorkspaceScriptGitMetadata { * that assembly and guard across the session. */ export interface WorkspaceScriptsService { - buildSnapshot(workspaceId: string, workspaceDirectory: string): WorkspaceScriptsPayload; - emitStatusUpdate(workspaceId: string, workspaceDirectory: string): void; + buildSnapshot( + workspace: PersistedWorkspaceRecord, + project?: PersistedProjectRecord | null, + ): WorkspaceScriptsPayload; + emitStatusUpdate(workspaceId: string, workspaceDirectory: string): Promise; start(request: StartWorkspaceScriptRequest): Promise; } -type WorkspaceScriptsGitSource = Pick< - WorkspaceGitService, - "peekSnapshot" | "getWorkspaceGitMetadata" ->; +type WorkspaceScriptsGitSource = Pick; export function createWorkspaceScriptsService(deps: { serviceProxy: ServiceProxySubsystem | null; scriptRuntimeStore: WorkspaceScriptRuntimeStore | null; terminalManager: TerminalManager | null; workspaceRegistry: Pick; + projectRegistry: Pick; workspaceGitService: WorkspaceScriptsGitSource; getDaemonTcpPort: (() => number | null) | null; getDaemonTcpHost: (() => string | null) | null; @@ -66,6 +67,7 @@ export function createWorkspaceScriptsService(deps: { scriptRuntimeStore, terminalManager, workspaceRegistry, + projectRegistry, workspaceGitService, getDaemonTcpPort, getDaemonTcpHost, @@ -76,45 +78,60 @@ export function createWorkspaceScriptsService(deps: { spawnWorkspaceScript, } = deps; - function resolveGitMetadata(workspaceDirectory: string): WorkspaceScriptGitMetadata | undefined { - const snapshot = workspaceGitService.peekSnapshot(workspaceDirectory); - if (!snapshot) { - return undefined; + function resolveGitMetadata( + workspace: PersistedWorkspaceRecord, + project: { projectId: string; rootPath: string } | null, + ) { + const snapshot = workspaceGitService.peekSnapshot(workspace.cwd); + const currentBranch = snapshot?.git.currentBranch ?? workspace.branch ?? null; + if (project) { + return { + projectSlug: deriveProjectServiceSlug(project), + currentBranch, + }; } + if (!snapshot) return undefined; return { projectSlug: deriveProjectSlug( - workspaceDirectory, + workspace.cwd, snapshot.git.isGit ? snapshot.git.remoteUrl : null, ), - currentBranch: snapshot.git.currentBranch, + currentBranch, }; } - function buildSnapshot(workspaceId: string, workspaceDirectory: string): WorkspaceScriptsPayload { + function buildSnapshot( + workspace: PersistedWorkspaceRecord, + project: PersistedProjectRecord | null = null, + ): WorkspaceScriptsPayload { if (!serviceProxy || !scriptRuntimeStore) { return []; } return buildWorkspaceScriptPayloads({ - workspaceId, - workspaceDirectory, - paseoConfig: readPaseoConfigForProjection(workspaceDirectory, logger), + workspaceId: workspace.workspaceId, + workspaceDirectory: workspace.cwd, + paseoConfig: readPaseoConfigForProjection(workspace.cwd, logger), serviceProxy, runtimeStore: scriptRuntimeStore, daemonPort: getDaemonTcpPort?.() ?? null, serviceProxyPublicBaseUrl, - gitMetadata: resolveGitMetadata(workspaceDirectory), + gitMetadata: resolveGitMetadata(workspace, project), resolveHealth: resolveScriptHealth ?? undefined, }); } - function emitStatusUpdate(workspaceId: string, workspaceDirectory: string): void { - emit({ - type: "script_status_update", - payload: { - workspaceId, - scripts: buildSnapshot(workspaceId, workspaceDirectory), - }, - }); + async function emitStatusUpdate(workspaceId: string, _workspaceDirectory: string): Promise { + try { + const workspace = await workspaceRegistry.get(workspaceId); + if (!workspace) return; + const project = await projectRegistry.get(workspace.projectId); + emit({ + type: "script_status_update", + payload: { workspaceId, scripts: buildSnapshot(workspace, project) }, + }); + } catch (error) { + logger.warn({ err: error, workspaceId }, "Failed to project workspace script status"); + } } async function start(request: StartWorkspaceScriptRequest): Promise { @@ -127,13 +144,23 @@ export function createWorkspaceScriptsService(deps: { if (!workspace) { throw new Error(`Workspace not found: ${request.workspaceId}`); } - const gitMetadata = await workspaceGitService.getWorkspaceGitMetadata(workspace.cwd); + const project = await projectRegistry.get(workspace.projectId); + const projectSlug = project + ? deriveProjectServiceSlug(project) + : deriveProjectSlug( + workspace.cwd, + workspaceGitService.peekSnapshot(workspace.cwd)?.git.remoteUrl ?? null, + ); + const branchName = + workspaceGitService.peekSnapshot(workspace.cwd)?.git.currentBranch ?? + workspace.branch ?? + null; const serviceResult = await spawnWorkspaceScript({ repoRoot: workspace.cwd, workspaceId: workspace.workspaceId, - projectSlug: gitMetadata.projectSlug, - branchName: gitMetadata.currentBranch, + projectSlug, + branchName, scriptName: request.scriptName, daemonPort: getDaemonTcpPort?.() ?? null, daemonListenHost: getDaemonTcpHost?.() ?? null, @@ -143,11 +170,11 @@ export function createWorkspaceScriptsService(deps: { terminalManager, logger, onLifecycleChanged: () => { - emitStatusUpdate(workspace.workspaceId, workspace.cwd); + void emitStatusUpdate(workspace.workspaceId, workspace.cwd); }, }); - emitStatusUpdate(workspace.workspaceId, workspace.cwd); + void emitStatusUpdate(workspace.workspaceId, workspace.cwd); emit({ type: "start_workspace_script_response", payload: { diff --git a/packages/server/src/server/snapshot-mutation-ownership.test.ts b/packages/server/src/server/snapshot-mutation-ownership.test.ts index 0f23c2a40..87308a3de 100644 --- a/packages/server/src/server/snapshot-mutation-ownership.test.ts +++ b/packages/server/src/server/snapshot-mutation-ownership.test.ts @@ -99,6 +99,7 @@ describe("snapshot mutation ownership boundary", () => { const session = asInternals( new Session({ clientId: "test-client", + scopes: ["*"], onMessage, logger: createStub(logger), downloadTokenStore: createStub({}), diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index 6468f9fb1..e025634b3 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -57,10 +57,12 @@ interface FakeAgentSessionOptions { sessionId?: string; memoryMarker?: string | null; closeSession?: () => Promise; + onStartTurn?: (prompt: AgentPromptInput) => void; } export interface TestAgentClientOptions { closeSession?: () => Promise; + onStartTurn?: (prompt: AgentPromptInput) => void; } function createDeferred(): Deferred { @@ -329,6 +331,7 @@ class FakeAgentSession implements AgentSession { private activeForegroundTurnId: string | null = null; private readonly closeSession: (() => Promise) | undefined; + private readonly onStartTurn: ((prompt: AgentPromptInput) => void) | undefined; constructor(options: FakeAgentSessionOptions) { this.providerName = options.providerName; @@ -336,6 +339,7 @@ class FakeAgentSession implements AgentSession { this.id = options.sessionId ?? randomUUID(); this.memoryMarker = options.memoryMarker ?? null; this.closeSession = options.closeSession; + this.onStartTurn = options.onStartTurn; this.historyPath = path.join( tmpdir(), "paseo-fake-provider-history", @@ -426,6 +430,7 @@ class FakeAgentSession implements AgentSession { const turnId = `fake-turn-${this.nextTurnOrdinal++}`; this.activeForegroundTurnId = turnId; + this.onStartTurn?.(prompt); void this.emitTurnEvents(prompt); @@ -1180,6 +1185,7 @@ class FakeAgentClient implements AgentClient { providerName: this.provider, config: { ...config }, closeSession: this.options.closeSession, + onStartTurn: this.options.onStartTurn, }); } @@ -1203,6 +1209,7 @@ class FakeAgentClient implements AgentClient { sessionId: handle.sessionId, memoryMarker: typeof marker === "string" ? marker : null, closeSession: this.options.closeSession, + onStartTurn: this.options.onStartTurn, }); } diff --git a/packages/server/src/server/test-utils/hub-cli-entry.ts b/packages/server/src/server/test-utils/hub-cli-entry.ts new file mode 100644 index 000000000..3e64439c3 --- /dev/null +++ b/packages/server/src/server/test-utils/hub-cli-entry.ts @@ -0,0 +1,4 @@ +import { createHubCommand } from "../../../../cli/src/commands/hub/index.js"; + +const argv = [process.argv[0] ?? "node", process.argv[1] ?? "paseo", ...process.argv.slice(3)]; +await createHubCommand().parseAsync(argv, { from: "node" }); 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 91390e4fc..20f5d2ebb 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,9 +1,5 @@ -import { basename } from "node:path"; import type { CheckoutDiffResult } from "../../utils/checkout-git.js"; -import { - buildWorkspaceGitMetadataFromSnapshot, - type WorkspaceGitMetadata, -} from "../workspace-git-metadata.js"; +import { deriveProjectSlug } from "../workspace-git-metadata.js"; import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "../workspace-git-service.js"; export function createNoGitWorkspaceRuntimeSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot { @@ -39,6 +35,9 @@ export function createNoopWorkspaceGitService( registerWorkspace: () => ({ unsubscribe: () => {}, }), + onSnapshotUpdated: () => ({ + unsubscribe: () => {}, + }), peekSnapshot: () => null, getCheckout: async (cwd: string) => ({ cwd, @@ -56,17 +55,9 @@ export function createNoopWorkspaceGitService( suggestBranchesForCwd: async () => [], listStashes: async () => [], listWorktrees: async () => [], - getWorkspaceGitMetadata: async (cwd: string, options): Promise => { + getProjectSlug: async (cwd: string) => { const snapshot = createNoGitWorkspaceRuntimeSnapshot(cwd); - return buildWorkspaceGitMetadataFromSnapshot({ - cwd, - directoryName: options?.directoryName ?? basename(cwd), - isGit: snapshot.git.isGit, - repoRoot: snapshot.git.repoRoot, - mainRepoRoot: snapshot.git.mainRepoRoot, - currentBranch: snapshot.git.currentBranch, - remoteUrl: snapshot.git.remoteUrl, - }); + return deriveProjectSlug(cwd, snapshot.git.isGit ? snapshot.git.remoteUrl : null); }, resolveForge: async () => null, resolveRepoRoot: async (cwd: string) => cwd, diff --git a/packages/server/src/server/websocket-server.notifications.test.ts b/packages/server/src/server/websocket-server.notifications.test.ts index e0eba9362..fb517efe5 100644 --- a/packages/server/src/server/websocket-server.notifications.test.ts +++ b/packages/server/src/server/websocket-server.notifications.test.ts @@ -173,6 +173,8 @@ function createSessionWithActivity( ) { return { getClientActivity: vi.fn(() => activity), + supports: () => false, + supportsForSource: () => false, }; } @@ -188,6 +190,7 @@ function connectClient( ) { const ws = createOpenSocket(); asInternals(server).sessions.set(ws, { + kind: "trusted", session: createSessionWithActivity(activity), clientId: "client-test", appVersion: null, 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 be49b8dae..6a6cef196 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -52,6 +52,10 @@ const sessionMock = vi.hoisted(() => { handleMessage = vi.fn(async () => {}); handleBinaryFrame = vi.fn((_frame: unknown) => {}); supports = vi.fn((capability: string) => this.args.clientCapabilities?.[capability] === true); + updateClientCapabilities = vi.fn((capabilities: Record | null) => { + this.args.clientCapabilities = capabilities; + }); + clearAgentTimelineSubscription = vi.fn(); getClientActivity = vi.fn(() => null); getSessionId = vi.fn(() => "mock-session-id"); resetPeakInflight = vi.fn(() => {}); @@ -917,6 +921,20 @@ describe("relay external socket reconnect behavior", () => { await server.close(); }); + test("advertises stable project identity in initial server_info", async () => { + const server = createServer(); + const socket = new MockSocket(); + + const serverInfo = await attachRelayAndHello({ + server, + socket, + clientId: "cid-stable-project-identity", + }); + + expect(serverInfo.features?.stableProjectIdentity).toBe(true); + await server.close(); + }); + test("includes voice capabilities in initial server_info when speech readiness exists", async () => { const speechReadiness = createReadySpeechReadinessSnapshot(); const server = createServer({ speechReadiness }); diff --git a/packages/server/src/server/websocket-server.terminal-notifications.test.ts b/packages/server/src/server/websocket-server.terminal-notifications.test.ts index 3862f5b4f..7d3cba294 100644 --- a/packages/server/src/server/websocket-server.terminal-notifications.test.ts +++ b/packages/server/src/server/websocket-server.terminal-notifications.test.ts @@ -198,6 +198,7 @@ function createOpenSocket() { function connectClient(server: VoiceAssistantWebSocketServer) { const ws = createOpenSocket(); asInternals<{ sessions: Map }>(server).sessions.set(ws, { + kind: "trusted", session: { getClientActivity: vi.fn(() => null), }, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 705dcc8da..a335d041d 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1,6 +1,6 @@ import { WebSocket, WebSocketServer } from "ws"; import type { IncomingMessage, Server as HTTPServer } from "http"; -import { basename, join } from "path"; +import { join } from "path"; import { hostname as getHostname } from "node:os"; import { randomUUID } from "node:crypto"; import { monitorEventLoopDelay } from "node:perf_hooks"; @@ -10,6 +10,7 @@ import type { DownloadTokenStore } from "./file-download/token-store.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import type pino from "pino"; import type { ProjectRegistry, WorkspaceRegistry } from "./workspace-registry.js"; +import type { ProjectUpdate } from "./workspace-reconciliation-service.js"; import type { FileBackedChatService } from "./chat/chat-service.js"; import type { LoopService } from "./loop-service.js"; import type { ScheduleService } from "./schedule/service.js"; @@ -17,6 +18,7 @@ import type { CheckoutDiffManager, CheckoutDiffMetrics } from "./checkout-diff-m import type { DaemonConfigStore, MutableDaemonConfig } from "./daemon-config-store.js"; import { type ServerInfoStatusPayload, + type SessionOutboundMessage, type WorkspaceSetupSnapshot, type WSHelloMessage, type WSInboundMessage, @@ -31,11 +33,13 @@ import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; import type { HostnamesConfig } from "./hostnames.js"; import { isHostnameAllowed } from "./hostnames.js"; import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js"; +import type { HubRelationshipManagement } from "./hub/relationship-controller.js"; +import type { HubExecutionAgents } from "./hub/daemon-executions.js"; import type { AgentProvider } from "./agent/agent-sdk-types.js"; import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js"; import type { WorkspaceAutoName } from "./workspace-auto-name.js"; -import { buildWorkspaceGitMetadataFromSnapshot } from "./workspace-git-metadata.js"; +import { deriveProjectSlug } from "./workspace-git-metadata.js"; import { PushTokenStore } from "./push/token-store.js"; import { createPushNotificationSender, type PushNotificationSender } from "./push/notifications.js"; import type { ScriptHealthState } from "./script-health-monitor.js"; @@ -97,6 +101,8 @@ interface PendingConnection { interface WebSocketConnectionIdentity { connectionId: string; transport: "direct" | "relay"; + peer: "loopback" | "local_ipc" | "external"; + browserOrigin: boolean; host?: string; origin?: string; userAgent?: string; @@ -190,17 +196,9 @@ function createFallbackWorkspaceGitService(): WorkspaceGitService { suggestBranchesForCwd: async () => [], listStashes: async () => [], listWorktrees: async () => [], - getWorkspaceGitMetadata: async (cwd: string, options) => { + getProjectSlug: async (cwd: string) => { const snapshot = createFallbackWorkspaceGitSnapshot(cwd); - return buildWorkspaceGitMetadataFromSnapshot({ - cwd, - directoryName: options?.directoryName ?? basename(cwd), - isGit: snapshot.git.isGit, - repoRoot: snapshot.git.repoRoot, - mainRepoRoot: snapshot.git.mainRepoRoot, - currentBranch: snapshot.git.currentBranch, - remoteUrl: snapshot.git.remoteUrl, - }); + return deriveProjectSlug(cwd, snapshot.git.isGit ? snapshot.git.remoteUrl : null); }, resolveRepoRoot: async (cwd: string) => cwd, resolveDefaultBranch: async () => "main", @@ -223,6 +221,16 @@ function createNoopProjectRegistry(): ProjectRegistry { existsOnDisk: async () => true, list: async () => [], get: async () => null, + getOrCreateActiveByRoot: async (input) => ({ + projectId: "prj_noop", + rootPath: input.rootPath, + kind: input.kind, + displayName: input.displayName, + customName: null, + createdAt: input.timestamp, + updatedAt: input.timestamp, + archivedAt: null, + }), upsert: async () => {}, archive: async () => {}, remove: async () => {}, @@ -327,7 +335,7 @@ function getBrowserHostCapability( return parsed.success ? parsed.data : null; } -interface WebSocketLike { +export interface WebSocketLike { readyState: number; bufferedAmount?: number; send: (data: string | Uint8Array | ArrayBuffer) => void; @@ -336,7 +344,8 @@ interface WebSocketLike { once: (event: "close" | "error", listener: (...args: unknown[]) => void) => void; } -interface SessionConnection { +interface TrustedSessionConnection { + kind: "trusted"; session: Session; clientId: string; appVersion: string | null; @@ -346,11 +355,46 @@ interface SessionConnection { externalDisconnectCleanupTimeout: ReturnType | null; } +interface HubConnection { + kind: "hub"; + session: Session; + daemonId: string; + connectionLogger: pino.Logger; + socket: WebSocketLike; +} + +type SessionConnection = TrustedSessionConnection | HubConnection; + +type TrustedLifecycleKey = + | "clientId" + | "appVersion" + | "clientCapabilities" + | "sockets" + | "externalDisconnectCleanupTimeout"; +type HubLifecycleOverlap = Extract; +const HUB_HAS_NO_TRUSTED_LIFECYCLE_STATE: HubLifecycleOverlap extends never ? true : never = true; +void HUB_HAS_NO_TRUSTED_LIFECYCLE_STATE; + interface BrowserToolsRegistration { capabilitySignature: string; unregister: () => void; } +interface SocketSessionOptions { + clientId: string; + appVersion: string | null; + clientCapabilities: Record | null; + scopes: readonly string[]; + connectionLogger: pino.Logger; + onMessage: (message: SessionOutboundMessage) => void; + onMessageToSource?: (source: object, message: SessionOutboundMessage) => void; + onBinaryMessage?: (frame: Uint8Array) => void; + getTransportBufferedAmount?: () => number | null; + onLifecycleIntent?: (intent: SessionLifecycleIntent) => void; + hubExecutionAgents?: HubExecutionAgents; + hubRelationships?: HubRelationshipManagement; +} + const SLOW_REQUEST_THRESHOLD_MS = 500; const EXTERNAL_SESSION_DISCONNECT_GRACE_MS = 90_000; const HELLO_TIMEOUT_MS = 15_000; @@ -406,7 +450,7 @@ export class VoiceAssistantWebSocketServer { private readonly pendingConnections: Map = new Map(); private readonly sessions: Map = new Map(); private readonly socketIdentities: Map = new Map(); - private readonly externalSessionsByKey: Map = new Map(); + private readonly externalSessionsByKey: Map = new Map(); private readonly serverId: string; private readonly daemonVersion: string; private readonly daemonRuntimeConfig: DaemonRuntimeConfig | undefined; @@ -457,6 +501,7 @@ export class VoiceAssistantWebSocketServer { private readonly providerUsageService: ProviderUsageService; private unsubscribeTerminalActivity: (() => void) | null = null; private readonly browserToolsBroker: BrowserToolsBroker | null; + private readonly hubRelationships: HubRelationshipManagement | null; private readonly browserToolsRegistrations = new Map(); private acceptingConnections = true; @@ -503,6 +548,7 @@ export class VoiceAssistantWebSocketServer { daemonRuntimeConfig?: DaemonRuntimeConfig, serviceProxyPublicBaseUrl?: string | null, browserToolsBroker?: BrowserToolsBroker | null, + hubRelationships?: HubRelationshipManagement | null, ) { this.logger = logger.child({ module: "websocket-server" }); this.serverId = serverId; @@ -512,6 +558,7 @@ export class VoiceAssistantWebSocketServer { this.daemonVersion = daemonVersion.trim(); this.daemonRuntimeConfig = daemonRuntimeConfig; this.browserToolsBroker = browserToolsBroker ?? null; + this.hubRelationships = hubRelationships ?? null; this.agentManager = agentManager; this.agentStorage = agentStorage; this.projectRegistry = projectRegistry ?? createNoopProjectRegistry(); @@ -748,7 +795,10 @@ export class VoiceAssistantWebSocketServer { public broadcast(message: WSOutboundMessage): void { const payload = JSON.stringify(message); - for (const ws of this.sessions.keys()) { + for (const [ws, connection] of this.sessions) { + if (connection.kind !== "trusted") { + continue; + } // WebSocket.OPEN = 1 if (ws.readyState === 1) { ws.send(payload); @@ -757,16 +807,22 @@ export class VoiceAssistantWebSocketServer { } } - public listActiveSessions(): Session[] { + public listTrustedSessions(): Session[] { return Array.from( new Set( - [...this.sessions.values(), ...this.externalSessionsByKey.values()].map( - (connection) => connection.session, - ), + [...this.sessions.values(), ...this.externalSessionsByKey.values()] + .filter( + (connection): connection is TrustedSessionConnection => connection.kind === "trusted", + ) + .map((connection) => connection.session), ), ); } + public publishProjectUpdate(update: ProjectUpdate): void { + for (const session of this.listTrustedSessions()) session.emitProjectUpdate(update); + } + public publishSpeechReadiness(readiness: SpeechReadinessSnapshot | null): void { this.updateServerCapabilities(buildServerCapabilities({ readiness })); } @@ -790,6 +846,44 @@ export class VoiceAssistantWebSocketServer { await this.attachSocket(ws, undefined, metadata); } + public async attachHubSocket( + ws: WebSocketLike, + options: { + daemonId: string; + scopes: readonly string[]; + agents: HubExecutionAgents; + }, + ): Promise { + if (!this.acceptingConnections) { + ws.close(WS_CLOSE_SERVER_SHUTDOWN, "Server shutting down"); + return; + } + + const connectionLogger = this.logger.child({ + connectionKind: "hub", + daemonId: options.daemonId, + }); + const session = this.createSocketSession({ + clientId: `hub:${options.daemonId}`, + appVersion: null, + clientCapabilities: null, + scopes: options.scopes, + connectionLogger, + onMessage: (message) => this.sendToClient(ws, wrapSessionMessage(message)), + hubExecutionAgents: options.agents, + }); + const connection: HubConnection = { + kind: "hub", + session, + daemonId: options.daemonId, + connectionLogger, + socket: ws, + }; + this.sessions.set(ws, connection); + this.bindSocketHandlers(ws); + connectionLogger.info("Hub session attached"); + } + public prepareForShutdown(): void { this.acceptingConnections = false; } @@ -825,13 +919,14 @@ export class VoiceAssistantWebSocketServer { const cleanupPromises: Promise[] = []; for (const connection of uniqueConnections) { - if (connection.externalDisconnectCleanupTimeout) { + if (connection.kind === "trusted" && connection.externalDisconnectCleanupTimeout) { clearTimeout(connection.externalDisconnectCleanupTimeout); connection.externalDisconnectCleanupTimeout = null; } - cleanupPromises.push(connection.session.cleanup()); - for (const ws of connection.sockets) { + cleanupPromises.push(Promise.resolve(connection.session.cleanup())); + const sockets = connection.kind === "trusted" ? connection.sockets : [connection.socket]; + for (const ws of sockets) { cleanupPromises.push( new Promise((resolve) => { // WebSocket.CLOSED = 3 @@ -901,12 +996,16 @@ export class VoiceAssistantWebSocketServer { } private sendToConnection(connection: SessionConnection, message: WSOutboundMessage): void { - for (const ws of connection.sockets) { + const sockets = connection.kind === "trusted" ? connection.sockets : [connection.socket]; + for (const ws of sockets) { this.sendToClient(ws, message); } } private sendBinaryToConnection(connection: SessionConnection, frame: Uint8Array): void { + if (connection.kind !== "trusted") { + return; + } for (const ws of connection.sockets) { this.sendBinaryToClient(ws, frame); } @@ -974,20 +1073,28 @@ export class VoiceAssistantWebSocketServer { appVersion: string | null; clientCapabilities: Record | null; connectionLogger: pino.Logger; - }): SessionConnection { + }): TrustedSessionConnection { const { ws, clientId, appVersion, clientCapabilities, connectionLogger } = params; - let connection: SessionConnection | null = null; + let connection: TrustedSessionConnection | null = null; - const session = new Session({ + const session = this.createSocketSession({ clientId, appVersion, clientCapabilities, + scopes: ["*"], + connectionLogger, onMessage: (msg) => { if (!connection) { return; } this.sendToConnection(connection, wrapSessionMessage(msg)); }, + onMessageToSource: (source, msg) => { + if (!connection || !connection.sockets.has(source as WebSocketLike)) { + return; + } + this.sendToClient(source as WebSocketLike, wrapSessionMessage(msg)); + }, onBinaryMessage: (frame) => { if (!connection) { return; @@ -1013,14 +1120,42 @@ export class VoiceAssistantWebSocketServer { onLifecycleIntent: (intent) => { this.onLifecycleIntent?.(intent); }, + hubRelationships: this.hubRelationships ?? undefined, + }); + + connection = { + kind: "trusted", + session, + clientId, + appVersion, + clientCapabilities, + connectionLogger, + sockets: new Set([ws]), + externalDisconnectCleanupTimeout: null, + }; + session.updateClientCapabilities(clientCapabilities, ws); + return connection; + } + + private createSocketSession(options: SocketSessionOptions): Session { + return new Session({ + clientId: options.clientId, + appVersion: options.appVersion, + clientCapabilities: options.clientCapabilities, + scopes: options.scopes, + onMessage: options.onMessage, + onMessageToSource: options.onMessageToSource, + onBinaryMessage: options.onBinaryMessage, + getTransportBufferedAmount: options.getTransportBufferedAmount, + onLifecycleIntent: options.onLifecycleIntent, + logger: options.connectionLogger.child({ module: "session" }), onWorkspaceRecovered: async (workspace) => { await Promise.all( - this.listActiveSessions().map((activeSession) => + this.listTrustedSessions().map((activeSession) => activeSession.refreshRecoveredWorkspaceForExternalMutation(workspace), ), ); }, - logger: connectionLogger.child({ module: "session" }), downloadTokenStore: this.downloadTokenStore, pushTokenStore: this.pushTokenStore, paseoHome: this.paseoHome, @@ -1044,6 +1179,8 @@ export class VoiceAssistantWebSocketServer { terminalManager: this.terminalManager, providerSnapshotManager: this.providerSnapshotManager, providerUsageService: this.providerUsageService, + hubExecutionAgents: options.hubExecutionAgents, + hubRelationships: options.hubRelationships, serviceProxy: this.serviceProxy ?? undefined, scriptRuntimeStore: this.scriptRuntimeStore ?? undefined, workspaceSetupSnapshots: this.workspaceSetupSnapshots, @@ -1083,17 +1220,6 @@ export class VoiceAssistantWebSocketServer { daemonRuntimeConfig: this.daemonRuntimeConfig, getWebSocketRuntimeMetrics: () => this.lastRuntimeMetricsSnapshot, }); - - connection = { - session, - clientId, - appVersion, - clientCapabilities, - connectionLogger, - sockets: new Set([ws]), - externalDisconnectCleanupTimeout: null, - }; - return connection; } private clearPendingConnection(ws: WebSocketLike): PendingConnection | null { @@ -1163,12 +1289,15 @@ export class VoiceAssistantWebSocketServer { existing.session.updateAppVersion(newAppVersion); } const newClientCapabilities = message.capabilities ?? null; + // COMPAT(selectiveAgentTimeline): added in v0.1.106. Every capable resumed + // hello resets membership before server_info so stale retained-session + // state cannot leak. Remove after 2027-01-12. + existing.session.updateClientCapabilities(newClientCapabilities, ws); if ( JSON.stringify(existing.clientCapabilities ?? null) !== JSON.stringify(newClientCapabilities ?? null) ) { existing.clientCapabilities = newClientCapabilities; - existing.session.updateClientCapabilities(newClientCapabilities); this.syncBrowserToolsClientRegistration(existing); } existing.sockets.add(ws); @@ -1269,6 +1398,8 @@ export class VoiceAssistantWebSocketServer { providerSubagents: true, // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. workspacePinning: true, + // COMPAT(hubRelationship): added in v0.1.X, drop the gate when floor >= v0.1.X. + hubRelationship: true, // COMPAT(projectGithubClone): added in v0.1.108, remove gate after 2027-01-15. projectGithubClone: true, // COMPAT(workspaceGithubRepositorySearch): added in v0.1.108, remove gate after 2027-01-15. @@ -1283,6 +1414,10 @@ export class VoiceAssistantWebSocketServer { importSessionWorkspaceTarget: true, // COMPAT(forgeProviders): added in v0.1.106, drop the gate when daemon floor >= v0.1.106. forgeProviders: true, + // COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12. + selectiveAgentTimeline: true, + // COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15. + stableProjectIdentity: true, }, }; } @@ -1391,7 +1526,17 @@ export class VoiceAssistantWebSocketServer { } this.sessions.delete(ws); + if (connection.kind === "hub") { + this.socketIdentities.delete(ws); + connection.connectionLogger.info( + { code: details.code, reason: stringifyCloseReason(details.reason) }, + "Hub session disconnected", + ); + connection.session.cleanup(); + return; + } connection.sockets.delete(ws); + connection.session.clearAgentTimelineSubscription(ws); this.socketIdentities.delete(ws); if (connection.sockets.size === 0) { @@ -1439,7 +1584,7 @@ export class VoiceAssistantWebSocketServer { } private async cleanupConnection( - connection: SessionConnection, + connection: TrustedSessionConnection, logMessage: string, ): Promise { this.incrementRuntimeCounter("sessionCleanup"); @@ -1466,7 +1611,7 @@ export class VoiceAssistantWebSocketServer { await connection.session.cleanup(); } - private syncBrowserToolsClientRegistration(connection: SessionConnection): void { + private syncBrowserToolsClientRegistration(connection: TrustedSessionConnection): void { if (!this.browserToolsBroker) { return; } @@ -1542,7 +1687,7 @@ export class VoiceAssistantWebSocketServer { log.warn( { - clientId: activeConnection?.clientId, + clientId: activeConnection?.kind === "trusted" ? activeConnection.clientId : undefined, requestId: requestInfo?.requestId, requestType: requestInfo?.requestType, error: parsedMessage.error.message, @@ -1607,6 +1752,11 @@ export class VoiceAssistantWebSocketServer { } return true; } + if (activeConnection.kind === "hub") { + log.warn("Rejected binary frame on Hub session"); + ws.close(WS_CLOSE_INVALID_HELLO, "Binary frames are not supported on Hub sessions"); + return true; + } void Promise.resolve(activeConnection.session.handleBinaryFrame(decodedFrame)).catch( (error: unknown) => { this.handleRawMessageError({ @@ -1746,25 +1896,36 @@ export class VoiceAssistantWebSocketServer { const controlRpc = getControlRpcLogInfo(message.message); if (controlRpc) { const identity = this.socketIdentities.get(ws); + let connectionFields: Record; + if (identity) { + connectionFields = toConnectionLogFields(identity); + } else if (activeConnection.kind === "trusted") { + connectionFields = { clientId: activeConnection.clientId }; + } else { + connectionFields = { daemonId: activeConnection.daemonId }; + } activeConnection.connectionLogger.warn( { - ...(identity ? toConnectionLogFields(identity) : { clientId: activeConnection.clientId }), + ...connectionFields, ...controlRpc, }, "ws_control_rpc_received", ); } - if (message.message.type === "browser.automation.execute.response") { + if ( + activeConnection.kind === "trusted" && + message.message.type === "browser.automation.execute.response" + ) { this.browserToolsBroker?.receiveResponse(message.message as BrowserAutomationExecuteResponse); return; } const startMs = performance.now(); - await activeConnection.session.handleMessage(message.message); + await activeConnection.session.handleMessage(message.message, ws); const durationMs = performance.now() - startMs; this.recordRequestLatency(message.message.type, durationMs); - if (durationMs >= SLOW_REQUEST_THRESHOLD_MS) { + if (durationMs >= SLOW_REQUEST_THRESHOLD_MS && activeConnection.kind === "trusted") { activeConnection.connectionLogger.warn( { requestType: message.message.type, @@ -1876,7 +2037,9 @@ export class VoiceAssistantWebSocketServer { } private collectSessionRuntimeMetrics(): WebSocketRuntimeMetrics { - const uniqueConnections = new Set(this.externalSessionsByKey.values()); + const uniqueConnections = new Set( + this.externalSessionsByKey.values(), + ); let terminalDirectorySubscriptionCount = 0; let terminalSubscriptionCount = 0; let inflightRequests = 0; @@ -1977,6 +2140,9 @@ export class VoiceAssistantWebSocketServer { }> = []; for (const [ws, connection] of this.sessions) { + if (connection.kind !== "trusted") { + continue; + } clientEntries.push({ ws, state: this.getClientActivityState(connection.session), @@ -2011,21 +2177,36 @@ export class VoiceAssistantWebSocketServer { for (const [clientIndex, { ws }] of clientEntries.entries()) { const shouldNotify = clientIndex === plan.inAppRecipientIndex; const timestamp = new Date().toISOString(); - const message = wrapSessionMessage({ - type: "agent_stream", - payload: { - agentId: params.agentId, - event: { - type: "attention_required", - provider: params.provider, - reason: params.reason, - timestamp, - shouldNotify, - notification, - }, - timestamp, - }, - }); + const connection = this.sessions.get(ws); + const attentionPayload = { + agentId: params.agentId, + reason: params.reason, + timestamp, + shouldNotify, + notification, + }; + const message = wrapSessionMessage( + connection?.session.supportsForSource(CLIENT_CAPS.selectiveAgentTimeline, ws) + ? { + type: "agent_attention_required", + payload: attentionPayload, + } + : { + type: "agent_stream", + payload: { + agentId: params.agentId, + event: { + type: "attention_required", + provider: params.provider, + reason: params.reason, + timestamp, + shouldNotify, + notification, + }, + timestamp, + }, + }, + ); this.sendToClient(ws, message); } @@ -2044,6 +2225,9 @@ export class VoiceAssistantWebSocketServer { }> = []; for (const [ws, connection] of this.sessions) { + if (connection.kind !== "trusted") { + continue; + } clientEntries.push({ ws, state: this.getClientActivityState(connection.session), @@ -2118,6 +2302,8 @@ function createWebSocketConnectionIdentity( return { connectionId: `conn_${randomUUID().replaceAll("-", "")}`, transport: metadata?.transport === "relay" ? "relay" : "direct", + peer: resolveConnectionPeer(requestMetadata, metadata), + browserOrigin: requestMetadata.origin !== undefined, ...(requestMetadata.host ? { host: requestMetadata.host } : {}), ...(requestMetadata.origin ? { origin: requestMetadata.origin } : {}), ...(requestMetadata.userAgent ? { userAgent: requestMetadata.userAgent } : {}), @@ -2130,6 +2316,7 @@ function toConnectionLogFields(identity: WebSocketConnectionIdentity): Record messages.push(message), logger: pino({ level: "silent" }), @@ -288,8 +293,8 @@ function createSessionForWireCompatTest(options?: { async resolveRepoRemoteUrl() { return null; }, - async getWorkspaceGitMetadata() { - return null; + async getProjectSlug() { + return "project"; }, } as unknown as SessionOptions["workspaceGitService"], daemonConfigStore: @@ -326,6 +331,99 @@ async function emitTimelineResponse( } describe("wire compatibility", () => { + test("sends project updates only to clients that declare support", () => { + const project = createPersistedProjectRecord({ + projectId: "project-1", + rootPath: "/tmp/project", + kind: "git", + displayName: "project", + customName: "Favorite project", + createdAt: "2026-07-15T00:00:00.000Z", + updatedAt: "2026-07-15T00:00:00.000Z", + }); + const legacyMessages: SessionOutboundMessage[] = []; + const capableMessages: SessionOutboundMessage[] = []; + const legacy = createSessionForWireCompatTest({ messages: legacyMessages }); + const capable = createSessionForWireCompatTest({ + clientCapabilities: { [CLIENT_CAPS.projectUpdates]: true }, + messages: capableMessages, + }); + + legacy.emitProjectUpdate({ kind: "upsert", project }); + legacy.emitProjectUpdate({ kind: "remove", projectId: project.projectId }); + capable.emitProjectUpdate({ kind: "upsert", project }); + capable.emitProjectUpdate({ kind: "remove", projectId: project.projectId }); + + expect(legacyMessages).toEqual([]); + expect(capableMessages.map((message) => SessionOutboundMessageSchema.parse(message))).toEqual([ + { + type: "project.update", + payload: { + kind: "upsert", + project: { + projectId: "project-1", + projectDisplayName: "Favorite project", + projectCustomName: "Favorite project", + projectRootPath: "/tmp/project", + projectKind: "git", + }, + }, + }, + { + type: "project.update", + payload: { kind: "remove", projectId: "project-1" }, + }, + ]); + }); + + test("hello parses with and without the project update capability", () => { + const legacy = WSHelloMessageSchema.parse({ + type: "hello", + clientId: "legacy-client", + clientType: "mobile", + protocolVersion: 1, + }); + const capable = WSHelloMessageSchema.parse({ + type: "hello", + clientId: "capable-client", + clientType: "mobile", + protocolVersion: 1, + capabilities: { [CLIENT_CAPS.projectUpdates]: true }, + }); + + expect([legacy, capable]).toEqual([ + { + type: "hello", + clientId: "legacy-client", + clientType: "mobile", + protocolVersion: 1, + }, + { + type: "hello", + clientId: "capable-client", + clientType: "mobile", + protocolVersion: 1, + capabilities: { project_updates: true }, + }, + ]); + }); + + test("server info accepts legacy feature payloads without stable project identity", () => { + const parsed = ServerInfoStatusPayloadSchema.parse({ + status: "server_info", + serverId: "legacy-server", + features: { workspaceGithubClone: true }, + }); + + expect(parsed).toEqual({ + status: "server_info", + serverId: "legacy-server", + hostname: null, + version: null, + features: {}, + }); + }); + test("assistant timeline message ids are optional on the wire", () => { expect( AgentTimelineItemPayloadSchema.parse({ diff --git a/packages/server/src/server/workspace-archive-service.test.ts b/packages/server/src/server/workspace-archive-service.test.ts index 958e8b221..4adbadb60 100644 --- a/packages/server/src/server/workspace-archive-service.test.ts +++ b/packages/server/src/server/workspace-archive-service.test.ts @@ -1,11 +1,12 @@ import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import pino, { type Logger } from "pino"; import { afterEach, describe, expect, test, vi } from "vitest"; import type { ForgeService } from "../services/forge-service.js"; +import { createRealpathAwarePathMatcher } from "../utils/path.js"; 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"; @@ -205,7 +206,6 @@ describe("archiveByScope", () => { }), { scope: { kind: "workspace", workspaceId }, - repoRoot: repoDir, requestId: "req-last-ref-workspace", }, ); @@ -217,8 +217,23 @@ describe("archiveByScope", () => { expect(existsSync(worktree.worktreePath)).toBe(false); }); - test("workspace scope keeps the directory when a sibling workspace still references it", async () => { + test("workspace scope runs teardown while keeping a directory referenced by a sibling", async () => { const { tempDir, repoDir } = createGitRepo(); + writeFileSync( + path.join(repoDir, "paseo.json"), + JSON.stringify({ + worktree: { + teardown: [ + "node -e \"require('fs').writeFileSync(process.env.PASEO_SOURCE_CHECKOUT_PATH + '/shared-teardown.log', 'ok')\"", + ], + }, + }), + ); + execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "shared teardown"], { + cwd: repoDir, + stdio: "pipe", + }); const paseoHome = path.join(tempDir, ".paseo"); const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "sibling-workspace"); const workspaceA = "ws-sibling-a"; @@ -234,7 +249,6 @@ describe("archiveByScope", () => { }), { scope: { kind: "workspace", workspaceId: workspaceA }, - repoRoot: repoDir, requestId: "req-sibling-workspace", }, ); @@ -244,34 +258,228 @@ describe("archiveByScope", () => { removedDirectory: false, }); expect(existsSync(worktree.worktreePath)).toBe(true); + expect(readFileSync(path.join(repoDir, "shared-teardown.log"), "utf8")).toBe("ok"); }); - test("worktree scope archives every workspace on the directory and removes it", async () => { + test("workspace scope keeps a worktree for an active workspace in a subdirectory", async () => { const { tempDir, repoDir } = createGitRepo(); const paseoHome = path.join(tempDir, ".paseo"); - const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "worktree-scope"); - const workspaceA = "ws-worktree-a"; - const workspaceB = "ws-worktree-b"; + const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "subdirectory-sibling"); + const sourceWorkspaceId = "ws-subdirectory-source"; + const siblingWorkspaceId = "ws-subdirectory-sibling"; + const siblingDirectory = path.join(worktree.worktreePath, "packages", "app"); + mkdirSync(siblingDirectory, { recursive: true }); const result = await archiveByScope( createArchiveDeps({ paseoHome, activeWorkspaces: [ - { workspaceId: workspaceA, cwd: worktree.worktreePath, kind: "worktree" }, - { workspaceId: workspaceB, cwd: worktree.worktreePath, kind: "local_checkout" }, + { + workspaceId: sourceWorkspaceId, + cwd: worktree.worktreePath, + kind: "worktree", + worktreeRoot: worktree.worktreePath, + isPaseoOwnedWorktree: true, + }, + { + workspaceId: siblingWorkspaceId, + cwd: siblingDirectory, + kind: "worktree", + worktreeRoot: worktree.worktreePath, + isPaseoOwnedWorktree: true, + }, + ], + }), + { + scope: { kind: "workspace", workspaceId: sourceWorkspaceId }, + requestId: "req-subdirectory-sibling", + }, + ); + + assertArchiveResult(result, { + archivedWorkspaceIds: [sourceWorkspaceId], + removedDirectory: false, + }); + expect(existsSync(worktree.worktreePath)).toBe(true); + }); + + test("archiving a subdirectory workspace keeps its active worktree root", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "subdirectory-target"); + const rootWorkspaceId = "ws-subdirectory-root"; + const subdirectoryWorkspaceId = "ws-subdirectory-target"; + const subdirectory = path.join(worktree.worktreePath, "packages", "app"); + mkdirSync(subdirectory, { recursive: true }); + + const result = await archiveByScope( + createArchiveDeps({ + paseoHome, + activeWorkspaces: [ + { + workspaceId: rootWorkspaceId, + cwd: worktree.worktreePath, + kind: "worktree", + worktreeRoot: worktree.worktreePath, + isPaseoOwnedWorktree: true, + }, + { + workspaceId: subdirectoryWorkspaceId, + cwd: subdirectory, + kind: "worktree", + worktreeRoot: worktree.worktreePath, + isPaseoOwnedWorktree: true, + }, + ], + }), + { + scope: { kind: "workspace", workspaceId: subdirectoryWorkspaceId }, + requestId: "req-subdirectory-target", + }, + ); + + assertArchiveResult(result, { + archivedWorkspaceIds: [subdirectoryWorkspaceId], + removedDirectory: false, + }); + expect(existsSync(worktree.worktreePath)).toBe(true); + }); + + test("workspace scope runs teardown from the exact nested workspace before deleting its worktree", async () => { + const { tempDir, repoDir } = createGitRepo(); + const nestedRelative = path.join("packages", "app"); + const sourceNested = path.join(repoDir, nestedRelative); + mkdirSync(sourceNested, { recursive: true }); + writeFileSync( + path.join(sourceNested, "paseo.json"), + JSON.stringify({ + worktree: { + teardown: [ + "node -e \"require('fs').writeFileSync(process.env.PASEO_SOURCE_CHECKOUT_PATH + '/nested-teardown.log', process.cwd())\"", + ], + }, + }), + ); + execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "nested teardown"], { + cwd: repoDir, + stdio: "pipe", + }); + + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "nested-teardown"); + const workspaceCwd = path.join(worktree.worktreePath, nestedRelative); + const matchesWorkspaceCwd = createRealpathAwarePathMatcher(workspaceCwd); + const workspaceId = "ws-nested-teardown"; + + const result = await archiveByScope( + createArchiveDeps({ + paseoHome, + activeWorkspaces: [ + { + workspaceId, + cwd: workspaceCwd, + kind: "worktree", + worktreeRoot: worktree.worktreePath, + isPaseoOwnedWorktree: true, + mainRepoRoot: repoDir, + }, + ], + }), + { + scope: { kind: "workspace", workspaceId }, + requestId: "req-nested-teardown", + }, + ); + + assertArchiveResult(result, { + archivedWorkspaceIds: [workspaceId], + removedDirectory: true, + }); + expect(existsSync(worktree.worktreePath)).toBe(false); + expect( + matchesWorkspaceCwd(readFileSync(path.join(repoDir, "nested-teardown.log"), "utf8")), + ).toBe(true); + }); + + test("worktree scope archives root and subdirectory workspaces before removing the backing worktree", async () => { + const { tempDir, repoDir } = createGitRepo(); + const nestedRelative = path.join("packages", "app"); + const sourceNested = path.join(repoDir, nestedRelative); + mkdirSync(sourceNested, { recursive: true }); + writeFileSync( + path.join(repoDir, "paseo.json"), + JSON.stringify({ + worktree: { + teardown: [ + "node -e \"const fs=require('fs');const out=process.env.PASEO_SOURCE_CHECKOUT_PATH+'/root-scope-teardown.log';if(fs.existsSync(out))process.exit(2);fs.writeFileSync(out,'ok')\"", + ], + }, + }), + ); + writeFileSync( + path.join(sourceNested, "paseo.json"), + JSON.stringify({ + worktree: { + teardown: [ + "node -e \"require('fs').writeFileSync(process.env.PASEO_SOURCE_CHECKOUT_PATH+'/nested-scope-teardown.log','ok')\"", + ], + }, + }), + ); + execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "scope teardown"], { + cwd: repoDir, + stdio: "pipe", + }); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "worktree-scope"); + const workspaceA = "ws-worktree-a"; + const workspaceB = "ws-worktree-b"; + const workspaceC = "ws-worktree-subdirectory"; + const subdirectory = path.join(worktree.worktreePath, nestedRelative); + + const result = await archiveByScope( + createArchiveDeps({ + paseoHome, + activeWorkspaces: [ + { + workspaceId: workspaceA, + cwd: worktree.worktreePath, + kind: "worktree", + worktreeRoot: worktree.worktreePath, + isPaseoOwnedWorktree: true, + }, + { + workspaceId: workspaceB, + cwd: worktree.worktreePath, + kind: "worktree", + worktreeRoot: worktree.worktreePath, + isPaseoOwnedWorktree: true, + }, + { + workspaceId: workspaceC, + cwd: subdirectory, + kind: "worktree", + worktreeRoot: worktree.worktreePath, + isPaseoOwnedWorktree: true, + }, ], }), { scope: { kind: "worktree", targetPath: worktree.worktreePath }, - repoRoot: repoDir, requestId: "req-worktree-scope", }, ); - expect(result.archivedWorkspaceIds).toEqual(expect.arrayContaining([workspaceA, workspaceB])); - expect(result.archivedWorkspaceIds).toHaveLength(2); + expect(result.archivedWorkspaceIds).toEqual( + expect.arrayContaining([workspaceA, workspaceB, workspaceC]), + ); + expect(result.archivedWorkspaceIds).toHaveLength(3); expect(result.removedDirectory).toBe(true); expect(existsSync(worktree.worktreePath)).toBe(false); + expect(readFileSync(path.join(repoDir, "root-scope-teardown.log"), "utf8")).toBe("ok"); + expect(readFileSync(path.join(repoDir, "nested-scope-teardown.log"), "utf8")).toBe("ok"); }); test("workspace scope never removes a non-Paseo-owned directory", async () => { @@ -286,7 +494,6 @@ describe("archiveByScope", () => { }), { scope: { kind: "workspace", workspaceId }, - repoRoot: null, requestId: "req-local-checkout", }, ); @@ -322,7 +529,6 @@ describe("archiveByScope", () => { const result = await archiveByScope(deps, { scope: { kind: "worktree", targetPath: worktree.worktreePath }, - repoRoot: repoDir, requestId: "req-partial-failure", }); @@ -347,7 +553,6 @@ describe("archiveByScope", () => { const result = await archiveByScope(deps, { scope: { kind: "workspace", workspaceId: "ws-does-not-exist" }, - repoRoot: null, requestId: "req-unknown-workspace", }); @@ -372,7 +577,6 @@ describe("archiveByScope", () => { }), { scope: { kind: "worktree", targetPath: worktree.worktreePath }, - repoRoot: repoDir, requestId: "req-zero-records", }, ); @@ -452,7 +656,6 @@ describe("archiveByScope", () => { await archiveByScope(deps, { scope: { kind: "workspace", workspaceId }, - repoRoot: repoDir, requestId: "req-lifecycle", }); @@ -506,7 +709,6 @@ describe("archiveByScope", () => { const result = await archiveByScope(deps, { scope: { kind: "workspace", workspaceId: targetWorkspaceId }, - repoRoot: repoDir, requestId: "req-snapshot-scope", }); @@ -540,7 +742,6 @@ describe("archiveByScope", () => { }), { scope: { kind: "worktree", targetPath: worktree.worktreePath }, - repoRoot: repoDir, requestId: "req-worktree-scope-n3", }, ); diff --git a/packages/server/src/server/workspace-archive-service.ts b/packages/server/src/server/workspace-archive-service.ts index 0988f43d8..8b6045a44 100644 --- a/packages/server/src/server/workspace-archive-service.ts +++ b/packages/server/src/server/workspace-archive-service.ts @@ -9,22 +9,21 @@ import type { ForgeService } from "../services/forge-service.js"; import { deletePaseoWorktree, isPaseoOwnedWorktreeCwd, - resolvePaseoWorktreeRootForCwd, + runWorktreeTeardownCommands, WorktreeTeardownError, } from "../utils/worktree.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "./workspace-registry.js"; +import { createRealpathAwarePathMatcher } from "../utils/path.js"; -export interface ActiveWorkspaceRef { - workspaceId: string; - cwd: string; - kind?: "local_checkout" | "worktree" | "directory"; -} +export type ActiveWorkspaceRef = Pick< + PersistedWorkspaceRecord, + "workspaceId" | "cwd" | "kind" | "worktreeRoot" | "isPaseoOwnedWorktree" | "mainRepoRoot" +>; export interface ArchiveDependencies { paseoHome?: string; - // Base directory that may hold worktrees across repositories. Used as a fallback - // when the request does not supply a per-repo root. + // Base directory that may hold worktrees across repositories. paseoWorktreesBaseRoot?: string; github: ForgeService; workspaceGitService: Pick; @@ -65,22 +64,42 @@ export interface ArchiveResult { export interface ArchiveByScopeRequest { scope: ArchiveScope; - repoRoot: string | null; - // Per-repository worktree root, used to remove the actual directory. - repoWorktreesRoot?: string; - // Base directory that may hold worktrees across repositories; falls back to the - // dependency's base root for ownership checks and path resolution. - paseoWorktreesBaseRoot?: string; requestId: string; } +export async function requireActiveWorkspaceForArchive( + dependencies: Pick, + workspaceId: string, +): Promise { + const workspace = (await dependencies.listActiveWorkspaces()).find( + (candidate) => candidate.workspaceId === workspaceId, + ); + if (!workspace) { + throw new Error(`Workspace not found: ${workspaceId}`); + } + return workspace; +} + +interface BackingDirectory { + path: string; + isPaseoOwnedWorktree: boolean; + mainRepoRoot: string | null; + paseoWorktreesRoot: string | null; +} + +interface ArchiveTarget { + backing: BackingDirectory | null; + teardownTargets: Array<{ workspaceId: string | null; cwd: string }>; + workspaceIds: string[]; +} + export async function resolveWorkspaceIdAtPath( dependencies: Pick, targetPath: string, ): Promise { - const targetDir = resolve(targetPath); + const matchesTarget = createRealpathAwarePathMatcher(targetPath); const activeWorkspaces = await dependencies.listActiveWorkspaces(); - const exactMatches = activeWorkspaces.filter((workspace) => resolve(workspace.cwd) === targetDir); + const exactMatches = activeWorkspaces.filter((workspace) => matchesTarget(workspace.cwd)); const worktreeMatch = exactMatches.find((workspace) => workspace.kind === "worktree"); if (worktreeMatch) { return worktreeMatch.workspaceId; @@ -88,18 +107,15 @@ export async function resolveWorkspaceIdAtPath( return dependencies.findWorkspaceIdForCwd(targetPath); } -// THE single archive entry. Resolves the in-scope record set, tears each down +// Resolves the in-scope record set, tears each down // (agents + terminals + record), then removes the backing directory iff it is // Paseo-owned AND no active workspace still references it. export async function archiveByScope( dependencies: ArchiveDependencies, request: ArchiveByScopeRequest, ): Promise { - const { targetDir, targetWorkspaceIds } = await resolveArchiveTargets( - dependencies, - request.scope, - request.paseoWorktreesBaseRoot, - ); + const target = await resolveArchiveTarget(dependencies, request.scope); + const targetWorkspaceIds = target.workspaceIds; if (targetWorkspaceIds.length > 0) { dependencies.markWorkspaceArchiving(targetWorkspaceIds, new Date().toISOString()); @@ -118,25 +134,25 @@ export async function archiveByScope( request.requestId, ); - if (request.repoRoot) { + if (target.backing?.mainRepoRoot) { try { - await dependencies.workspaceGitService.getSnapshot(request.repoRoot, { + await dependencies.workspaceGitService.getSnapshot(target.backing.mainRepoRoot, { force: true, reason: "archive-worktree", }); } catch (error) { dependencies.sessionLogger?.warn( - { err: error, cwd: request.repoRoot, requestId: request.requestId }, + { err: error, cwd: target.backing.mainRepoRoot, requestId: request.requestId }, "Failed to force-refresh workspace git snapshot after archiving", ); } } - if (targetDir !== null) { + if (target.backing !== null) { removedDirectory = await maybeRemoveDirectory( dependencies, request, - targetDir, + target, archivedWorkspaceIds, ); } @@ -154,11 +170,10 @@ export async function archiveByScope( } } -async function resolveArchiveTargets( +async function resolveArchiveTarget( dependencies: ArchiveDependencies, scope: ArchiveScope, - paseoWorktreesBaseRoot?: string, -): Promise<{ targetDir: string | null; targetWorkspaceIds: string[] }> { +): Promise { const activeWorkspaces = await dependencies.listActiveWorkspaces(); if (scope.kind === "workspace") { @@ -169,24 +184,89 @@ async function resolveArchiveTargets( { workspaceId }, "Workspace not found for archive-by-scope; skipping", ); - return { targetDir: null, targetWorkspaceIds: [] }; + return { backing: null, teardownTargets: [], workspaceIds: [] }; } - return { targetDir: resolve(record.cwd), targetWorkspaceIds: [workspaceId] }; + return { + backing: await resolveWorkspaceBackingDirectory(record, dependencies), + teardownTargets: [{ workspaceId, cwd: record.cwd }], + workspaceIds: [workspaceId], + }; } - let targetPath = scope.targetPath; - const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, { - paseoHome: dependencies.paseoHome, - worktreesRoot: paseoWorktreesBaseRoot ?? dependencies.paseoWorktreesBaseRoot, - }); - if (resolvedWorktree) { - targetPath = resolvedWorktree.worktreePath; + const backing = await resolveBackingDirectory(scope.targetPath, dependencies); + const matchesBackingDirectory = createRealpathAwarePathMatcher(backing.path); + const targetWorkspaces = ( + await Promise.all( + activeWorkspaces.map(async (workspace) => { + const backingDirectory = await resolveWorkspaceBackingDirectory(workspace, dependencies); + return matchesBackingDirectory(backingDirectory.path) ? workspace : null; + }), + ) + ).filter((workspace): workspace is ActiveWorkspaceRef => workspace !== null); + const persistedMainRepoRoot = targetWorkspaces.find( + (workspace) => workspace.mainRepoRoot, + )?.mainRepoRoot; + return { + backing: { + ...backing, + mainRepoRoot: persistedMainRepoRoot ?? backing.mainRepoRoot, + }, + teardownTargets: + targetWorkspaces.length > 0 + ? targetWorkspaces.map((workspace) => ({ + workspaceId: workspace.workspaceId, + cwd: workspace.cwd, + })) + : [{ workspaceId: null, cwd: scope.targetPath }], + workspaceIds: targetWorkspaces.map((workspace) => workspace.workspaceId), + }; +} + +async function resolveWorkspaceBackingDirectory( + workspace: ActiveWorkspaceRef, + dependencies: Pick, +): Promise { + if (workspace.isPaseoOwnedWorktree && workspace.worktreeRoot && workspace.mainRepoRoot) { + return { + path: resolve(workspace.worktreeRoot), + isPaseoOwnedWorktree: true, + mainRepoRoot: workspace.mainRepoRoot, + paseoWorktreesRoot: null, + }; } - const targetDir = resolve(targetPath); - const targetWorkspaceIds = activeWorkspaces - .filter((workspace) => resolve(workspace.cwd) === targetDir) - .map((workspace) => workspace.workspaceId); - return { targetDir, targetWorkspaceIds }; + if (workspace.kind !== "worktree") { + return { + path: resolve(workspace.cwd), + isPaseoOwnedWorktree: false, + mainRepoRoot: workspace.mainRepoRoot ?? null, + paseoWorktreesRoot: null, + }; + } + + // COMPAT(archiveMissingWorkspacePlacement): worktree records created before v0.1.110 + // lack durable backing ownership; remove filesystem discovery after 2027-01-17. + const backing = await resolveBackingDirectory( + workspace.worktreeRoot ?? workspace.cwd, + dependencies, + ); + return { ...backing, mainRepoRoot: workspace.mainRepoRoot ?? backing.mainRepoRoot }; +} + +async function resolveBackingDirectory( + cwd: string, + dependencies: Pick, +): Promise { + const options = { + paseoHome: dependencies.paseoHome, + worktreesRoot: dependencies.paseoWorktreesBaseRoot, + }; + const ownership = await isPaseoOwnedWorktreeCwd(cwd, options); + return { + path: resolve(ownership.allowed && ownership.worktreePath ? ownership.worktreePath : cwd), + isPaseoOwnedWorktree: ownership.allowed, + mainRepoRoot: ownership.repoRoot ?? null, + paseoWorktreesRoot: ownership.worktreeRoot ?? null, + }; } async function archiveTargetRecords( @@ -224,37 +304,72 @@ async function archiveTargetRecords( async function maybeRemoveDirectory( dependencies: ArchiveDependencies, - request: Omit, - targetDir: string, + request: Pick, + target: ArchiveTarget, archivedWorkspaceIds: string[], ): Promise { - const ownership = await isPaseoOwnedWorktreeCwd(targetDir, { - paseoHome: dependencies.paseoHome, - worktreesRoot: request.paseoWorktreesBaseRoot ?? dependencies.paseoWorktreesBaseRoot, - }); - if (!ownership.allowed) { + const backing = target.backing; + if (!backing?.isPaseoOwnedWorktree) { return false; } + const archivedWorkspaceIdSet = new Set(archivedWorkspaceIds); + const teardownCwds = uniqueFilesystemPaths( + target.teardownTargets + .filter( + (teardownTarget) => + teardownTarget.workspaceId === null || + archivedWorkspaceIdSet.has(teardownTarget.workspaceId), + ) + .map((teardownTarget) => teardownTarget.cwd), + ); + + try { + for (const teardownCwd of teardownCwds) { + await runWorktreeTeardownCommands({ + worktreePath: backing.path, + teardownCwd, + repoRootPath: backing.mainRepoRoot ?? undefined, + }); + } + } catch (error) { + if (error instanceof WorktreeTeardownError) { + dependencies.sessionLogger?.warn( + { err: error, targetPath: backing.path, requestId: request.requestId }, + "Worktree teardown failed during archive; workspace already archived", + ); + return false; + } + throw error; + } + const remainingActive = await dependencies.listActiveWorkspaces(); - if (!isDirectoryUnreferenced(remainingActive, targetDir, new Set(archivedWorkspaceIds))) { + if ( + !(await isDirectoryUnreferenced( + remainingActive, + backing.path, + new Set(archivedWorkspaceIds), + dependencies, + )) + ) { return false; } try { await deletePaseoWorktree({ - cwd: request.repoRoot, - worktreePath: targetDir, - worktreesRoot: request.repoWorktreesRoot ?? ownership.worktreeRoot, + cwd: backing.mainRepoRoot, + worktreePath: backing.path, + teardownCwds: [], + worktreesRoot: backing.paseoWorktreesRoot ?? undefined, paseoHome: dependencies.paseoHome, - worktreesBaseRoot: request.paseoWorktreesBaseRoot ?? dependencies.paseoWorktreesBaseRoot, + worktreesBaseRoot: dependencies.paseoWorktreesBaseRoot, }); - dependencies.github.invalidate({ cwd: targetDir }); + dependencies.github.invalidate({ cwd: backing.path }); return true; } catch (error) { if (error instanceof WorktreeTeardownError) { dependencies.sessionLogger?.warn( - { err: error, targetPath: targetDir, requestId: request.requestId }, + { err: error, targetPath: backing.path, requestId: request.requestId }, "Worktree disk removal failed during archive; workspace already archived", ); return false; @@ -263,6 +378,16 @@ async function maybeRemoveDirectory( } } +function uniqueFilesystemPaths(paths: string[]): string[] { + const unique: string[] = []; + for (const candidate of paths) { + if (!unique.some((existing) => createRealpathAwarePathMatcher(existing)(candidate))) { + unique.push(candidate); + } + } + return unique; +} + export type ArchiveWorkspaceContentsDependencies = Pick< ArchiveDependencies, "agentManager" | "agentStorage" | "killTerminalsForWorkspace" | "sessionLogger" @@ -323,19 +448,23 @@ export async function archiveWorkspaceContents( return archivedAgents; } -// EXACTLY one last-reference predicate in the module. True when, after archiving +// True when, after archiving // the in-scope records, no active workspace still points at targetDir. Derived // from records each call — no stored counter. -function isDirectoryUnreferenced( +async function isDirectoryUnreferenced( activeWorkspaces: ActiveWorkspaceRef[], targetDir: string, archivedWorkspaceIds: ReadonlySet, -): boolean { + dependencies: Pick, +): Promise { const target = resolve(targetDir); - return !activeWorkspaces.some( - (workspace) => - !archivedWorkspaceIds.has(workspace.workspaceId) && resolve(workspace.cwd) === target, - ); + const matchesTarget = createRealpathAwarePathMatcher(target); + for (const workspace of activeWorkspaces) { + if (archivedWorkspaceIds.has(workspace.workspaceId)) continue; + const backingDirectory = await resolveWorkspaceBackingDirectory(workspace, dependencies); + if (matchesTarget(backingDirectory.path)) return false; + } + return true; } export async function killTerminalsForWorkspace( diff --git a/packages/server/src/server/workspace-auto-name.ts b/packages/server/src/server/workspace-auto-name.ts index 2bf573620..558f2fac7 100644 --- a/packages/server/src/server/workspace-auto-name.ts +++ b/packages/server/src/server/workspace-auto-name.ts @@ -108,13 +108,14 @@ export class WorkspaceAutoName { firstAgentContext: FirstAgentContext; currentSelection: CurrentSelection; }): Promise { + const worktreeRoot = input.workspace.worktreeRoot ?? input.workspace.cwd; let generated: GeneratedWorkspaceName | null = null; const result: AttemptFirstAgentBranchAutoNameResult = await attemptFirstAgentBranchAutoName({ - cwd: input.workspace.cwd, + cwd: worktreeRoot, firstAgentContext: input.firstAgentContext, - generateBranchNameFromContext: ({ cwd, firstAgentContext }) => { + generateBranchNameFromContext: ({ firstAgentContext }) => { return this.generateFromContext({ - cwd, + cwd: input.workspace.cwd, firstAgentContext, currentSelection: input.currentSelection, }).then((nextGenerated) => { @@ -146,7 +147,7 @@ export class WorkspaceAutoName { promptTitle: resolveFirstAgentPromptTitle(input.firstAgentContext), }); if (result.renamed) { - await this.gitMutation.notifyGitMutation(input.workspace.cwd, "rename-branch"); + await this.gitMutation.notifyGitMutation(worktreeRoot, "rename-branch"); } await this.emitWorkspaceUpdateForCwd(input.workspace.cwd); } diff --git a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts index f2b2b299e..2fbdc930c 100644 --- a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts +++ b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts @@ -8,7 +8,7 @@ import { DaemonClient } from "./test-utils/index.js"; import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; // The reshaped workspace.create.request forwards its worktree `source` -// (action/refName/githubPrNumber/worktreeSlug) verbatim into createWorktreeCore. +// (action/refName/branchName/githubPrNumber/worktreeSlug) into createWorktreeCore. // The regression these tests guard against is the daemon dropping action/refName // while subsetting the request. We prove forwarding through the real workflow: // the created worktree's observable branch is the only honest evidence the @@ -64,7 +64,7 @@ test("workspace.create worktree source forwards action=checkout + refName into t } }, 180000); -test("workspace.create worktree source forwards branch-off + refName as the new branch", async () => { +test("workspace.create keeps a branch-off name separate from its worktree slug", async () => { const daemon = await createTestPaseoDaemon(); const { repoDir, tempRoot } = createGitRepoWithBranch(); const client = new DaemonClient({ @@ -80,15 +80,15 @@ test("workspace.create worktree source forwards branch-off + refName as the new kind: "worktree", cwd: repoDir, action: "branch-off", - worktreeSlug: "brand-new-branch", + branchName: "feature/auth", + worktreeSlug: "feature-auth", baseBranch: "main", }, }); expect(result.error).toBeNull(); - // branch-off cuts a new branch named after worktreeSlug from baseBranch; the - // worktree landing on that branch proves worktreeSlug/baseBranch forwarded. - expect(result.workspace?.gitRuntime?.currentBranch).toBe("brand-new-branch"); + expect(result.workspace?.gitRuntime?.currentBranch).toBe("feature/auth"); + expect(path.basename(result.workspace?.workspaceDirectory ?? "")).toBe("feature-auth"); } finally { await client.close().catch(() => undefined); await daemon.close(); diff --git a/packages/server/src/server/workspace-directory.ts b/packages/server/src/server/workspace-directory.ts index 6e2193227..0e85f0dac 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -130,6 +130,17 @@ export function workspaceIdsOnCheckout( .map((workspace) => workspace.workspaceId); } +export function workspaceIdsForProjects( + workspaces: Iterable, + projectIds: ReadonlySet, +): string[] { + const workspaceIds = new Set(); + for (const workspace of workspaces) { + if (projectIds.has(workspace.projectId)) workspaceIds.add(workspace.workspaceId); + } + return Array.from(workspaceIds); +} + export class WorkspaceDirectory { private readonly archivingByWorkspaceId = new Map(); /** diff --git a/packages/server/src/server/workspace-git-metadata.test.ts b/packages/server/src/server/workspace-git-metadata.test.ts index d1a824b41..441246420 100644 --- a/packages/server/src/server/workspace-git-metadata.test.ts +++ b/packages/server/src/server/workspace-git-metadata.test.ts @@ -6,7 +6,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { - buildWorkspaceGitMetadataFromSnapshot, + deriveProjectServiceSlug, deriveProjectSlug, parseGitHubRepoNameFromRemote, } from "./workspace-git-metadata.js"; @@ -171,46 +171,12 @@ describe("deriveProjectSlug", () => { }); }); -describe("buildWorkspaceGitMetadataFromSnapshot", () => { - test("uses owner/repo as the display name for GitHub remotes", () => { - const result = buildWorkspaceGitMetadataFromSnapshot({ - cwd: "/repos/some-dir", - directoryName: "some-dir", - isGit: true, - repoRoot: "/repos/some-dir", - mainRepoRoot: null, - currentBranch: "main", - remoteUrl: "git@github.com:acme/widgets.git", - }); +describe("deriveProjectServiceSlug", () => { + test("keeps same-basename exact projects distinct and stable", () => { + const first = { projectId: "prj_aaaaaaaaaaaaaaaa", rootPath: "/repo-a/app" }; + const second = { projectId: "prj_bbbbbbbbbbbbbbbb", rootPath: "/repo-b/app" }; - expect(result.projectDisplayName).toBe("acme/widgets"); - }); - - test("uses owner/repo as the display name for non-GitHub remotes", () => { - const result = buildWorkspaceGitMetadataFromSnapshot({ - cwd: "/repos/random-name", - directoryName: "random-name", - isGit: true, - repoRoot: "/repos/random-name", - mainRepoRoot: null, - currentBranch: "main", - remoteUrl: "git@gitlab.com:acme/app.git", - }); - - expect(result.projectDisplayName).toBe("acme/app"); - }); - - test("falls back to the directory name when there is no remote", () => { - const result = buildWorkspaceGitMetadataFromSnapshot({ - cwd: "/repos/local-only", - directoryName: "local-only", - isGit: true, - repoRoot: "/repos/local-only", - mainRepoRoot: null, - currentBranch: "main", - remoteUrl: null, - }); - - expect(result.projectDisplayName).toBe("local-only"); + expect(deriveProjectServiceSlug(first)).toBe(deriveProjectServiceSlug(first)); + expect(deriveProjectServiceSlug(first)).not.toBe(deriveProjectServiceSlug(second)); }); }); diff --git a/packages/server/src/server/workspace-git-metadata.ts b/packages/server/src/server/workspace-git-metadata.ts index a2d53a380..dd38679f9 100644 --- a/packages/server/src/server/workspace-git-metadata.ts +++ b/packages/server/src/server/workspace-git-metadata.ts @@ -1,19 +1,7 @@ import { basename } from "path"; +import { createHash } from "node:crypto"; import { parseGitHubRemoteUrl } from "@getpaseo/protocol/git-remote"; import { slugify } from "../utils/worktree.js"; -import { deriveProjectGroupingKey, deriveProjectGroupingName } from "./workspace-registry-model.js"; - -export interface WorkspaceGitMetadata { - projectKind: "git" | "directory"; - projectDisplayName: string; - workspaceDisplayName: string; - gitRemote: string | null; - isWorktree: boolean; - projectSlug: string; - repoRoot: string | null; - currentBranch: string | null; - remoteUrl: string | null; -} export function parseGitHubRepoFromRemote(remoteUrl: string): string | null { return parseGitHubRemoteUrl(remoteUrl)?.repo ?? null; @@ -34,49 +22,7 @@ export function deriveProjectSlug(cwd: string, remoteUrl: string | null = null): return slugify(sourceName) || "untitled"; } -export function buildWorkspaceGitMetadataFromSnapshot(input: { - cwd: string; - directoryName: string; - isGit: boolean; - repoRoot: string | null; - mainRepoRoot: string | null; - currentBranch: string | null; - remoteUrl: string | null; -}): WorkspaceGitMetadata { - if (!input.isGit) { - return { - projectKind: "directory", - projectDisplayName: input.directoryName, - workspaceDisplayName: input.directoryName, - gitRemote: null, - isWorktree: false, - projectSlug: deriveProjectSlug(input.cwd), - repoRoot: null, - currentBranch: null, - remoteUrl: null, - }; - } - - const isWorktree = - input.mainRepoRoot !== null && input.repoRoot !== null && input.mainRepoRoot !== input.repoRoot; - const projectKey = deriveProjectGroupingKey({ - cwd: input.repoRoot ?? input.cwd, - remoteUrl: input.remoteUrl, - mainRepoRoot: input.mainRepoRoot, - }); - const projectDisplayName = projectKey.startsWith("remote:") - ? deriveProjectGroupingName(projectKey) - : input.directoryName; - - return { - projectKind: "git", - projectDisplayName, - workspaceDisplayName: input.currentBranch ?? input.directoryName, - gitRemote: input.remoteUrl, - isWorktree, - projectSlug: deriveProjectSlug(input.cwd, input.remoteUrl), - repoRoot: input.repoRoot, - currentBranch: input.currentBranch, - remoteUrl: input.remoteUrl, - }; +export function deriveProjectServiceSlug(project: { projectId: string; rootPath: string }): string { + const identity = createHash("sha256").update(project.projectId).digest("hex").slice(0, 8); + return `${deriveProjectSlug(project.rootPath)}-${identity}`; } 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 f3be87aa1..99664c530 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -390,6 +390,18 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { vi.useRealTimers(); }); + test("getCheckout surfaces an unexpected Git read failure", async () => { + const service = createService({ + getCheckoutStatus: vi.fn(async () => { + throw new Error("Git read failed"); + }), + }); + + await expect(service.getCheckout(REPO_CWD)).rejects.toThrow("Git read failed"); + + service.dispose(); + }); + test("getSnapshot returns the current snapshot without shelling out", async () => { let nowMs = Date.parse("2026-04-12T00:00:00.000Z"); const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); @@ -1132,7 +1144,10 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { createCheckoutFacts(cwd, { currentBranch: "feature", remoteUrl: "https://forge-self-heal.test/acme/repo.git", - pullRequestLookupTarget: { headRef: "feature" }, + pullRequestLookupTarget: { + headRef: "feature", + headSha: "1111111111111111111111111111111111111111", + }, }), ); const getCheckoutStatus = vi.fn(async (cwd: string) => @@ -1151,12 +1166,13 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener); await flushPromises(); - await vi.advanceTimersByTimeAsync(60_000); + await vi.advanceTimersByTimeAsync(120_000); await flushPromises(); expect(forge.getCurrentPullRequestStatus).toHaveBeenCalledWith({ cwd: REPO_CWD, headRef: "feature", + headSha: "1111111111111111111111111111111111111111", reason: "self-heal-forge-pr-status", }); expect(listener).toHaveBeenCalledWith( @@ -1177,6 +1193,108 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { } }); + test("generic forge self-heal uses the fast poll window while checks are pending", async () => { + const forge = { + ...createGitHubServiceStub(), + retainCurrentPullRequestStatusPoll: undefined, + getCurrentPullRequestStatus: vi.fn(async () => + createCurrentPullRequestStatus({ checksStatus: "pending" }), + ), + }; + const unregister = defaultForgeRegistry.register("forge-pending-test", { + createService: () => forge, + matchesHost: (host) => host === "forge-pending.test", + }); + const pendingResult = createPullRequestStatusResult(); + if (pendingResult.status) { + pendingResult.status.checksStatus = "pending"; + pendingResult.status.checks = [{ name: "ci", status: "pending" }]; + } + const service = createService({ + getCheckoutSnapshotFacts: vi.fn(async (cwd: string) => + createCheckoutFacts(cwd, { + currentBranch: "feature", + remoteUrl: "https://forge-pending.test/acme/repo.git", + pullRequestLookupTarget: { headRef: "feature" }, + }), + ), + getCheckoutStatus: vi.fn(async (cwd: string) => + createCheckoutStatus(cwd, { + currentBranch: "feature", + remoteUrl: "https://forge-pending.test/acme/repo.git", + }), + ), + getPullRequestStatus: vi.fn(async () => pendingResult), + }); + + try { + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + await flushPromises(); + await vi.advanceTimersByTimeAsync(20_000); + await flushPromises(); + + expect(forge.getCurrentPullRequestStatus).toHaveBeenCalledTimes(1); + subscription.unsubscribe(); + } finally { + service.dispose(); + unregister(); + } + }); + + test("generic forge poll refreshes immediately when checkout HEAD changes", async () => { + let nowMs = 0; + let headSha = "1111111111111111111111111111111111111111"; + const forge = { + ...createGitHubServiceStub(), + retainCurrentPullRequestStatusPoll: undefined, + getCurrentPullRequestStatus: vi.fn(async () => createCurrentPullRequestStatus()), + }; + const unregister = defaultForgeRegistry.register("forge-head-change-test", { + createService: () => forge, + matchesHost: (host) => host === "forge-head-change.test", + }); + const service = createService({ + now: () => new Date(nowMs), + getCheckoutSnapshotFacts: vi.fn(async (cwd: string) => + createCheckoutFacts(cwd, { + currentBranch: "feature", + remoteUrl: "https://forge-head-change.test/acme/repo.git", + pullRequestLookupTarget: { headRef: "feature", headSha }, + }), + ), + getCheckoutStatus: vi.fn(async (cwd: string) => + createCheckoutStatus(cwd, { + currentBranch: "feature", + remoteUrl: "https://forge-head-change.test/acme/repo.git", + }), + ), + getPullRequestStatus: vi.fn(async () => createPullRequestStatusResult("Visible PR")), + }); + + try { + await service.getSnapshot(REPO_CWD); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + expect(service.peekSnapshot(REPO_CWD)?.forge.pullRequest?.title).toBe("Visible PR"); + + headSha = "2222222222222222222222222222222222222222"; + nowMs = 3_000; + await service.refresh(REPO_CWD); + + expect(service.peekSnapshot(REPO_CWD)?.forge.pullRequest).toBeNull(); + expect(forge.getCurrentPullRequestStatus).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(0); + await flushPromises(); + + expect(forge.getCurrentPullRequestStatus).toHaveBeenCalledTimes(1); + expect(service.peekSnapshot(REPO_CWD)?.forge.pullRequest?.title).toBe("MR self-healed"); + subscription.unsubscribe(); + } finally { + service.dispose(); + unregister(); + } + }); + test("subscription cancels generic forge PR status self-heal polling after unsubscribe", async () => { const forge = { ...createGitHubServiceStub(), @@ -1209,7 +1327,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { await flushPromises(); subscription.unsubscribe(); - await vi.advanceTimersByTimeAsync(60_000); + await vi.advanceTimersByTimeAsync(120_000); await flushPromises(); expect(forge.getCurrentPullRequestStatus).not.toHaveBeenCalled(); @@ -1735,7 +1853,7 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { service.dispose(); }); - test("getWorkspaceGitMetadata derives reconciliation metadata from the snapshot cache", async () => { + test("getProjectSlug derives the slug from the snapshot cache", async () => { let nowMs = 0; const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd, { @@ -1749,22 +1867,10 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { now: () => new Date(nowMs), }); - await expect( - service.getWorkspaceGitMetadata(REPO_CWD, { directoryName: "Local Repo" }), - ).resolves.toEqual({ - projectKind: "git", - projectDisplayName: "getpaseo/paseo", - workspaceDisplayName: "feature/service-metadata", - gitRemote: "https://github.com/getpaseo/paseo.git", - isWorktree: false, - projectSlug: "paseo", - repoRoot: REPO_CWD, - currentBranch: "feature/service-metadata", - remoteUrl: "https://github.com/getpaseo/paseo.git", - }); + await expect(service.getProjectSlug(REPO_CWD)).resolves.toBe("paseo"); nowMs = 1_000; - await service.getWorkspaceGitMetadata(join(REPO_CWD, "."), { directoryName: "Local Repo" }); + await service.getProjectSlug(join(REPO_CWD, ".")); expect(getCheckoutStatus).toHaveBeenCalledTimes(1); service.dispose(); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 24edaf90c..71209da34 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -1,6 +1,6 @@ import { watch, type FSWatcher } from "node:fs"; import { readFile, readdir } from "node:fs/promises"; -import { basename, join, resolve } from "node:path"; +import { join, resolve } from "node:path"; import { LRUCache } from "lru-cache"; import pLimit from "p-limit"; import type pino from "pino"; @@ -41,16 +41,15 @@ import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js"; import { runGitCommand } from "../utils/run-git-command.js"; import { listPaseoWorktrees, type PaseoWorktreeInfo } from "../utils/worktree.js"; import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js"; -import { - buildWorkspaceGitMetadataFromSnapshot, - type WorkspaceGitMetadata, -} from "./workspace-git-metadata.js"; +import { deriveProjectSlug } from "./workspace-git-metadata.js"; import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js"; const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000; const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000; export const WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS = 60_000; -const FORGE_PR_STATUS_POLL_INTERVAL_MS = 60_000; +const FORGE_PR_STATUS_POLL_FAST_INTERVAL_MS = 20_000; +const FORGE_PR_STATUS_POLL_SLOW_INTERVAL_MS = 120_000; +const FORGE_PR_STATUS_POLL_ERROR_BACKOFF_CAP_MS = 300_000; const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_000; // Auxiliary reads may reuse cached values within this window; snapshots do not expire on read. const WORKSPACE_GIT_AUXILIARY_READ_TTL_MS = 15_000; @@ -162,10 +161,7 @@ export interface WorkspaceGitService { cwdOrRepoRoot: string, options?: WorkspaceGitReadOptions, ): Promise; - getWorkspaceGitMetadata( - cwd: string, - options?: WorkspaceGitReadOptions & { directoryName?: string }, - ): Promise; + getProjectSlug(cwd: string, options?: WorkspaceGitReadOptions): Promise; resolveRepoRoot(cwd: string, options?: WorkspaceGitReadOptions): Promise; resolveDefaultBranch(cwdOrRepoRoot: string, options?: WorkspaceGitReadOptions): Promise; resolveRepoRemoteUrl(cwd: string, options?: WorkspaceGitReadOptions): Promise; @@ -342,6 +338,7 @@ interface WorkspaceGitAuxiliaryReadCacheEntry { interface WorkspaceForgePrStatusPollTarget { headRef: string; + headSha?: string; headRepositoryOwner?: string; } @@ -473,31 +470,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { async getCheckout(cwd: string): Promise { const normalizedCwd = resolve(cwd); - try { - const status = await this.deps.getCheckoutStatus(normalizedCwd, { - paseoHome: this.paseoHome, - worktreesRoot: this.worktreesRoot, - logger: this.logger, - }); - if (!status.isGit) { - return checkoutLiteFromGitSnapshot(normalizedCwd, { - isGit: false, - currentBranch: null, - remoteUrl: null, - repoRoot: null, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }); - } - return checkoutLiteFromGitSnapshot(normalizedCwd, { - isGit: true, - currentBranch: status.currentBranch, - remoteUrl: status.remoteUrl, - repoRoot: status.repoRoot, - isPaseoOwnedWorktree: status.isPaseoOwnedWorktree, - mainRepoRoot: status.mainRepoRoot, - }); - } catch { + const status = await this.deps.getCheckoutStatus(normalizedCwd, { + paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, + logger: this.logger, + }); + if (!status.isGit) { return checkoutLiteFromGitSnapshot(normalizedCwd, { isGit: false, currentBranch: null, @@ -507,6 +485,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { mainRepoRoot: null, }); } + return checkoutLiteFromGitSnapshot(normalizedCwd, { + isGit: true, + currentBranch: status.currentBranch, + remoteUrl: status.remoteUrl, + repoRoot: status.repoRoot, + isPaseoOwnedWorktree: status.isPaseoOwnedWorktree, + mainRepoRoot: status.mainRepoRoot, + }); } peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null { @@ -654,21 +640,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { }); } - async getWorkspaceGitMetadata( - cwd: string, - options?: WorkspaceGitReadOptions & { directoryName?: string }, - ): Promise { + async getProjectSlug(cwd: string, options?: WorkspaceGitReadOptions): Promise { const snapshot = await this.getSnapshot(cwd, options); - const directoryName = options?.directoryName ?? basename(cwd) ?? cwd; - return buildWorkspaceGitMetadataFromSnapshot({ - cwd: resolve(cwd), - directoryName, - isGit: snapshot.git.isGit, - repoRoot: snapshot.git.repoRoot, - mainRepoRoot: snapshot.git.mainRepoRoot, - currentBranch: snapshot.git.currentBranch, - remoteUrl: snapshot.git.remoteUrl, - }); + return deriveProjectSlug(resolve(cwd), snapshot.git.isGit ? snapshot.git.remoteUrl : null); } async resolveRepoRemoteUrl( @@ -1235,9 +1209,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { remoteUrl, target: pollTarget, }); + const previousPollKey = target.forgePrStatusPollKey; if (target.forgePrStatusPollKey === pollKey && target.forgePrStatusPollSubscription) { return; } + const pollImmediately = previousPollKey !== null && previousPollKey !== pollKey; this.stopForgePrStatusPollForTarget(target); target.forgePrStatusPollKey = pollKey; @@ -1245,6 +1221,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { target.forgePrStatusPollSubscription = resolution.service.retainCurrentPullRequestStatusPoll({ cwd: target.cwd, headRef: pollTarget.headRef, + ...(pollTarget.headSha ? { headSha: pollTarget.headSha } : {}), ...(pollTarget.headRepositoryOwner ? { headRepositoryOwner: pollTarget.headRepositoryOwner } : {}), @@ -1282,6 +1259,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { forge: resolution.forge, service: resolution.service, pollTarget, + pollImmediately, }); } @@ -1290,23 +1268,28 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { forge, service, pollTarget, + pollImmediately, }: { target: WorkspaceGitTarget; forge: string; service: ForgeService; pollTarget: WorkspaceForgePrStatusPollTarget; + pollImmediately: boolean; }): { unsubscribe: () => void } { let closed = false; let timer: NodeJS.Timeout | null = null; + let latestStatus: WorkspaceGitRuntimeSnapshot["forge"]["pullRequest"] = + target.latestForge?.pullRequest ?? null; + let consecutiveErrors = 0; - const schedule = () => { + const schedule = (delayMs: number) => { if (closed) { return; } timer = setTimeout(() => { timer = null; void poll(); - }, FORGE_PR_STATUS_POLL_INTERVAL_MS); + }, delayMs); }; const poll = async () => { @@ -1317,17 +1300,21 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const status = await service.getCurrentPullRequestStatus({ cwd: target.cwd, headRef: pollTarget.headRef, + ...(pollTarget.headSha ? { headSha: pollTarget.headSha } : {}), ...(pollTarget.headRepositoryOwner ? { headRepositoryOwner: pollTarget.headRepositoryOwner } : {}), reason: "self-heal-forge-pr-status", }); if (!closed && this.isActiveObservedWorkspaceTarget(target)) { + latestStatus = status; + consecutiveErrors = 0; this.rememberForgePrStatusSnapshot(target, buildForgeSnapshotFromStatus(status, forge), { notify: true, }); } } catch (error) { + consecutiveErrors += 1; this.logger.warn( { err: error, @@ -1340,11 +1327,16 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { "Failed to run forge PR status self-heal refresh", ); } finally { - schedule(); + schedule(computeGenericForgeNextInterval(latestStatus, consecutiveErrors)); } }; - schedule(); + // A git-only refresh clears forge state when the commit-aware poll identity + // changes. Revalidate that new identity immediately instead of leaving the + // PR panel empty for the full stable polling interval. + schedule( + pollImmediately ? 0 : computeGenericForgeNextInterval(latestStatus, consecutiveErrors), + ); return { unsubscribe: () => { closed = true; @@ -2200,7 +2192,32 @@ function buildWorkspaceForgePrStatusPollKey({ remoteUrl: string; target: WorkspaceForgePrStatusPollTarget; }): string { - return JSON.stringify([forge, remoteUrl, target.headRef, target.headRepositoryOwner ?? null]); + return JSON.stringify([ + forge, + remoteUrl, + target.headRef, + target.headSha ?? null, + target.headRepositoryOwner ?? null, + ]); +} + +function computeGenericForgeNextInterval( + status: WorkspaceGitRuntimeSnapshot["forge"]["pullRequest"], + consecutiveErrors: number, +): number { + const isPending = + status?.checksStatus === "pending" || + status?.checks?.some((check) => check.status === "pending") === true; + const baseInterval = isPending + ? FORGE_PR_STATUS_POLL_FAST_INTERVAL_MS + : FORGE_PR_STATUS_POLL_SLOW_INTERVAL_MS; + if (consecutiveErrors <= 1) { + return baseInterval; + } + return Math.min( + baseInterval * 2 ** (consecutiveErrors - 1), + FORGE_PR_STATUS_POLL_ERROR_BACKOFF_CAP_MS, + ); } async function runGitFetch(cwd: string): Promise { diff --git a/packages/server/src/server/workspace-reconciliation-observation.test.ts b/packages/server/src/server/workspace-reconciliation-observation.test.ts new file mode 100644 index 000000000..a5fa20c97 --- /dev/null +++ b/packages/server/src/server/workspace-reconciliation-observation.test.ts @@ -0,0 +1,584 @@ +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages"; +import { afterEach, describe, expect, test } from "vitest"; + +import { createTestLogger } from "../test-utils/test-logger.js"; +import { areEquivalentPaths } from "../utils/path.js"; +import { + createPersistedProjectRecord, + createPersistedWorkspaceRecord, + FileBackedProjectRegistry, + FileBackedWorkspaceRegistry, + type PersistedWorkspaceRecord, +} from "./workspace-registry.js"; +import { + type ProjectRootWatch, + type ProjectUpdate, + type ReconciliationClock, + type ReconciliationTimer, + WorkspaceReconciliationService, +} from "./workspace-reconciliation-service.js"; + +const TIMESTAMP = "2026-07-15T00:00:00.000Z"; +const DEBOUNCE_MS = 10; +const RESCAN_INTERVAL_MS = 50; +const cleanupPaths: string[] = []; + +afterEach(() => { + for (const target of cleanupPaths.splice(0)) rmSync(target, { recursive: true, force: true }); +}); + +interface ProjectSpec { + id: string; + root: string; + workspaces?: Array<{ id: string; cwd: string }>; + archived?: boolean; +} + +interface Gate { + started: Promise; + release(): void; +} + +function createGate(): Gate & { arrive(): Promise } { + let markStarted!: () => void; + let release!: () => void; + const started = new Promise((resolve) => (markStarted = resolve)); + const released = new Promise((resolve) => (release = resolve)); + return { started, release, arrive: async () => (markStarted(), released) }; +} + +class ObservedProjectRegistry extends FileBackedProjectRegistry { + private nextRead: ReturnType | null = null; + + holdNextRead(): Gate { + return (this.nextRead = createGate()); + } + + override async list() { + if (this.nextRead) { + const pending = this.nextRead; + this.nextRead = null; + await pending.arrive(); + } + return super.list(); + } +} + +class ObservedWorkspaceRegistry extends FileBackedWorkspaceRegistry { + private nextRead: ReturnType | null = null; + private nextError: Error | null = null; + + holdNextRead(): Gate { + return (this.nextRead = createGate()); + } + + failNextRead(error: Error): void { + this.nextError = error; + } + + override async list() { + if (this.nextRead) { + const pending = this.nextRead; + this.nextRead = null; + await pending.arrive(); + } + if (this.nextError) { + const error = this.nextError; + this.nextError = null; + throw error; + } + return super.list(); + } +} + +interface TestTimer extends ReconciliationTimer { + callback: () => void | Promise; + dueAt: number; + intervalMs: number | null; +} + +class TestClock implements ReconciliationClock { + private now = 0; + private readonly timers = new Set(); + + get pendingCount(): number { + return this.timers.size; + } + + setTimeout(callback: () => void | Promise, delayMs: number): TestTimer { + return this.add(callback, delayMs, null); + } + + clearTimeout(timer: TestTimer): void { + this.timers.delete(timer); + } + + setInterval(callback: () => void | Promise, delayMs: number): TestTimer { + return this.add(callback, delayMs, delayMs); + } + + clearInterval(timer: TestTimer): void { + this.timers.delete(timer); + } + + async advanceBy(elapsedMs: number): Promise { + const target = this.now + elapsedMs; + for (;;) { + const next = [...this.timers] + .filter((timer) => timer.dueAt <= target) + .sort((left, right) => left.dueAt - right.dueAt)[0]; + if (!next) break; + this.now = next.dueAt; + if (next.intervalMs === null) this.timers.delete(next); + else next.dueAt += next.intervalMs; + await next.callback(); + } + this.now = target; + } + + private add( + callback: () => void | Promise, + delayMs: number, + intervalMs: number | null, + ): TestTimer { + const timer = { callback, dueAt: this.now + delayMs, intervalMs, unref: () => undefined }; + this.timers.add(timer); + return timer; + } +} + +interface RootWatch { + rootPath: string; + onChange: (event: string, filename: string | Buffer | null) => void; + onError: (error: Error) => void; + closed: boolean; +} + +/** One public behavioral seam for the complete observed-placement lifecycle. */ +class ObservedPlacements { + private readonly home = mkdtempSync(path.join(tmpdir(), "observed-placement-")); + private readonly projects: ObservedProjectRegistry; + private readonly workspaces: ObservedWorkspaceRegistry; + private readonly clock = new TestClock(); + private readonly watches: RootWatch[] = []; + private readonly checkoutByCwd = new Map(); + private readonly rootByProjectId = new Map(); + private readonly failedWatchRoots = new Set(); + private readonly projectEvents: ProjectUpdate[] = []; + private readonly workspaceEvents: string[][] = []; + private readonly workspaceEventWaiters: Array<() => void> = []; + private readonly lifecycleEvents: string[] = []; + private readonly service: WorkspaceReconciliationService; + private started = false; + private checkoutReadCount = 0; + + constructor(private readonly specs: ProjectSpec[]) { + cleanupPaths.push(this.home); + const logger = createTestLogger(); + this.projects = new ObservedProjectRegistry(path.join(this.home, "projects.json"), logger); + this.workspaces = new ObservedWorkspaceRegistry( + path.join(this.home, "workspaces.json"), + logger, + ); + const watchProjectRoot: ProjectRootWatch = (rootPath, _options, onChange, onError) => { + if (this.failedWatchRoots.delete(rootPath)) throw new Error("root unavailable"); + const watch = { rootPath, onChange, onError, closed: false }; + this.watches.push(watch); + this.lifecycleEvents.push(`watch installed:${path.relative(this.home, rootPath)}`); + return { close: () => (watch.closed = true) }; + }; + this.service = new WorkspaceReconciliationService({ + projectRegistry: this.projects, + workspaceRegistry: this.workspaces, + workspaceGitService: { getCheckout: async (cwd) => this.readCheckout(cwd) }, + logger, + watchProjectRoot, + clock: this.clock, + debounceMs: DEBOUNCE_MS, + rescanIntervalMs: RESCAN_INTERVAL_MS, + onProjectUpdate: (update) => { + this.projectEvents.push(update); + this.lifecycleEvents.push( + update.kind === "upsert" + ? `project published:upsert:${update.project.projectId}` + : `project published:remove:${update.projectId}`, + ); + }, + onWorkspacesChanged: async (workspaceIds) => { + this.workspaceEvents.push(workspaceIds); + this.workspaceEventWaiters.shift()?.(); + }, + }); + } + + async start(): Promise { + if (!this.started) { + for (const spec of this.specs) await this.seed(spec); + this.started = true; + } + await this.service.start(); + } + + async add(spec: ProjectSpec): Promise { + await this.seed(spec); + this.lifecycleEvents.push(`registry mutation resolved:${spec.id}`); + } + + async archive(projectId: string): Promise { + await this.projects.archive(projectId, TIMESTAMP); + } + + async remove(projectId: string): Promise { + await this.projects.remove(projectId); + } + + failNextWatch(root: string): void { + this.failedWatchRoots.add(this.absolute(root)); + } + + watcherFailed(root: string): void { + this.activeWatch(root)?.onError(new Error("watch failed")); + } + + change(root: string, filename: string | null): void { + this.activeWatch(root)?.onChange("rename", filename); + } + + makeProjectGit(projectId: string, branch = "main"): void { + const rootPath = this.rootByProjectId.get(projectId); + if (!rootPath) throw new Error(`Unknown project: ${projectId}`); + this.checkoutByCwd.set(rootPath, { + cwd: rootPath, + isGit: true, + currentBranch: branch, + remoteUrl: null, + worktreeRoot: rootPath, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }); + } + + failNextReconciliation(error: Error): void { + this.workspaces.failNextRead(error); + } + + holdNextRegistryRead(): Gate { + return this.projects.holdNextRead(); + } + + holdNextReconciliation(): Gate { + return this.workspaces.holdNextRead(); + } + + async advanceBy(elapsedMs: number): Promise { + await this.clock.advanceBy(elapsedMs); + } + + dispose(): void { + this.service.dispose(); + } + + watchedRoots(): string[] { + return this.watches + .filter((watch) => !watch.closed) + .map((watch) => path.relative(this.home, watch.rootPath)); + } + + closedRoots(): string[] { + return this.watches + .filter((watch) => watch.closed) + .map((watch) => path.relative(this.home, watch.rootPath)); + } + + async placement(workspaceId: string): Promise { + return this.workspaces.get(workspaceId); + } + + async deleteWorkspaceDirectory(workspaceId: string): Promise { + const workspace = await this.workspaces.get(workspaceId); + if (!workspace) throw new Error(`Unknown workspace: ${workspaceId}`); + rmSync(workspace.cwd, { recursive: true, force: true }); + } + + get projectUpdates(): ProjectUpdate[] { + return [...this.projectEvents]; + } + + get workspaceBatches(): string[][] { + return this.workspaceEvents.map((batch) => [...batch]); + } + + waitForWorkspaceBatch(): Promise { + return new Promise((resolve) => this.workspaceEventWaiters.push(resolve)); + } + + get lifecycle(): string[] { + return [...this.lifecycleEvents]; + } + + get gitReads(): number { + return this.checkoutReadCount; + } + + get pendingTimers(): number { + return this.clock.pendingCount; + } + + private async seed(spec: ProjectSpec): Promise { + const rootPath = this.absolute(spec.root); + mkdirSync(rootPath, { recursive: true }); + this.rootByProjectId.set(spec.id, rootPath); + await this.projects.upsert( + createPersistedProjectRecord({ + projectId: spec.id, + rootPath, + kind: "non_git", + displayName: spec.id, + createdAt: TIMESTAMP, + updatedAt: TIMESTAMP, + archivedAt: spec.archived ? TIMESTAMP : null, + }), + ); + for (const workspace of spec.workspaces ?? []) { + const cwd = this.absolute(workspace.cwd); + mkdirSync(cwd, { recursive: true }); + await this.workspaces.upsert( + createPersistedWorkspaceRecord({ + workspaceId: workspace.id, + projectId: spec.id, + cwd, + kind: "directory", + displayName: `Durable ${workspace.id}`, + createdAt: TIMESTAMP, + updatedAt: TIMESTAMP, + }), + ); + } + } + + private async readCheckout(cwd: string): Promise { + this.checkoutReadCount += 1; + const configured = [...this.checkoutByCwd.entries()].find(([root]) => + areEquivalentPaths(root, cwd), + )?.[1]; + return ( + configured ?? { + cwd, + isGit: false, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + } + ); + } + + private activeWatch(root: string): RootWatch | undefined { + const target = this.absolute(root); + return this.watches.find( + (watch) => !watch.closed && areEquivalentPaths(watch.rootPath, target), + ); + } + + private absolute(relative: string): string { + return path.join(this.home, relative); + } +} + +describe("observed workspace placement", () => { + test("installs and publishes a new project before add resolves without Git feedback", async () => { + const observed = new ObservedPlacements([]); + await observed.start(); + + await observed.add({ id: "project-new", root: "new" }); + await observed.advanceBy(DEBOUNCE_MS); + + expect(observed.watchedRoots()).toEqual(["new"]); + expect(observed.projectUpdates).toEqual([ + { kind: "upsert", project: expect.objectContaining({ projectId: "project-new" }) }, + ]); + expect(observed.lifecycle).toEqual([ + "watch installed:new", + "project published:upsert:project-new", + "registry mutation resolved:project-new", + ]); + expect(observed.gitReads).toBe(0); + observed.dispose(); + }); + + test("deduplicates active root watches and tears them down on archive and remove", async () => { + const observed = new ObservedPlacements([ + { id: "project-one", root: "repo" }, + { id: "project-duplicate", root: "repo" }, + { id: "project-remove", root: "other" }, + { id: "project-archived", root: "archived", archived: true }, + ]); + + await observed.start(); + await observed.start(); + expect(observed.watchedRoots()).toEqual(["repo", "other"]); + + await observed.archive("project-one"); + expect(observed.watchedRoots()).toEqual(["repo", "other"]); + await observed.remove("project-duplicate"); + await observed.remove("project-remove"); + + expect(observed.watchedRoots()).toEqual([]); + expect(observed.closedRoots()).toEqual(["repo", "other"]); + expect(observed.projectUpdates).toEqual([ + { kind: "remove", projectId: "project-one" }, + { kind: "remove", projectId: "project-duplicate" }, + { kind: "remove", projectId: "project-remove" }, + ]); + observed.dispose(); + }); + + test("filters unrelated files and coalesces Git change bursts", async () => { + const observed = new ObservedPlacements([{ id: "project-one", root: "repo" }]); + await observed.start(); + + observed.change("repo", "README.md"); + await observed.advanceBy(DEBOUNCE_MS); + expect(observed.gitReads).toBe(0); + + observed.change("repo", ".git"); + observed.change("repo", ".git"); + observed.change("repo", null); + await observed.advanceBy(DEBOUNCE_MS); + expect(observed.gitReads).toBe(1); + observed.dispose(); + }); + + test("recovers errored and temporarily unavailable watchers on the periodic pass", async () => { + const observed = new ObservedPlacements([ + { id: "project-errored", root: "errored" }, + { id: "project-unavailable", root: "unavailable" }, + ]); + observed.failNextWatch("unavailable"); + await observed.start(); + expect(observed.watchedRoots()).toEqual(["errored"]); + + observed.watcherFailed("errored"); + expect(observed.watchedRoots()).toEqual([]); + await observed.advanceBy(RESCAN_INTERVAL_MS); + + expect(observed.watchedRoots()).toEqual(["errored", "unavailable"]); + expect(observed.closedRoots()).toEqual(["errored"]); + observed.dispose(); + }); + + test("archives missing workspace directories on the periodic pass", async () => { + const observed = new ObservedPlacements([ + { id: "project-one", root: "repo", workspaces: [{ id: "workspace-one", cwd: "repo" }] }, + ]); + await observed.start(); + await observed.deleteWorkspaceDirectory("workspace-one"); + + await observed.advanceBy(RESCAN_INTERVAL_MS); + + expect((await observed.placement("workspace-one"))?.archivedAt).toEqual(expect.any(String)); + expect(observed.workspaceBatches).toEqual([["workspace-one"]]); + observed.dispose(); + }); + + test("preserves a periodic full pass queued behind metadata reconciliation", async () => { + const observed = new ObservedPlacements([ + { id: "project-one", root: "repo", workspaces: [{ id: "workspace-one", cwd: "repo" }] }, + ]); + await observed.start(); + const metadataRead = observed.holdNextReconciliation(); + observed.change("repo", ".git"); + const metadataPass = observed.advanceBy(DEBOUNCE_MS); + await metadataRead.started; + await observed.deleteWorkspaceDirectory("workspace-one"); + const workspaceBatch = observed.waitForWorkspaceBatch(); + + await observed.advanceBy(RESCAN_INTERVAL_MS); + metadataRead.release(); + await metadataPass; + await workspaceBatch; + + expect((await observed.placement("workspace-one"))?.archivedAt).toEqual(expect.any(String)); + expect(observed.workspaceBatches).toEqual([["workspace-one"]]); + observed.dispose(); + }); + + test("contains a failed reconciliation and converges on the next change", async () => { + const observed = new ObservedPlacements([ + { id: "project-one", root: "repo", workspaces: [{ id: "workspace-one", cwd: "repo" }] }, + ]); + await observed.start(); + observed.makeProjectGit("project-one", "main"); + observed.failNextReconciliation(new Error("registry unavailable")); + + observed.change("repo", ".git"); + await observed.advanceBy(DEBOUNCE_MS); + expect((await observed.placement("workspace-one"))?.kind).toBe("directory"); + + observed.change("repo", ".git"); + await observed.advanceBy(DEBOUNCE_MS); + expect(await observed.placement("workspace-one")).toMatchObject({ + kind: "local_checkout", + branch: "main", + displayName: "Durable workspace-one", + }); + observed.dispose(); + }); + + test("deduplicates direct placement and project-derived workspace fanout", async () => { + const observed = new ObservedPlacements([ + { + id: "project-one", + root: "repo", + workspaces: [ + { id: "workspace-one", cwd: "repo" }, + { id: "workspace-two", cwd: "repo/feature" }, + ], + }, + ]); + await observed.start(); + observed.makeProjectGit("project-one", "main"); + + observed.change("repo", ".git"); + await observed.advanceBy(DEBOUNCE_MS); + + expect(observed.projectUpdates).toHaveLength(1); + expect(observed.workspaceBatches).toEqual([["workspace-one", "workspace-two"]]); + expect(new Set(observed.workspaceBatches[0]).size).toBe(2); + observed.dispose(); + }); + + test("disposal suppresses in-flight mutations and reconciliation fanout", async () => { + const mutation = new ObservedPlacements([{ id: "project-one", root: "repo" }]); + await mutation.start(); + const registryRead = mutation.holdNextRegistryRead(); + const adding = mutation.add({ id: "project-late", root: "late" }); + await registryRead.started; + mutation.dispose(); + registryRead.release(); + await adding; + expect(mutation.projectUpdates).toEqual([]); + expect(mutation.watchedRoots()).toEqual([]); + + const reconciliation = new ObservedPlacements([ + { id: "project-one", root: "repo", workspaces: [{ id: "workspace-one", cwd: "repo" }] }, + ]); + await reconciliation.start(); + reconciliation.makeProjectGit("project-one"); + const workspaceRead = reconciliation.holdNextReconciliation(); + reconciliation.change("repo", ".git"); + const advancing = reconciliation.advanceBy(DEBOUNCE_MS); + await workspaceRead.started; + reconciliation.dispose(); + workspaceRead.release(); + await advancing; + + expect(reconciliation.workspaceBatches).toEqual([]); + expect(reconciliation.watchedRoots()).toEqual([]); + expect(reconciliation.pendingTimers).toBe(0); + }); +}); diff --git a/packages/server/src/server/workspace-reconciliation-service.test.ts b/packages/server/src/server/workspace-reconciliation-service.test.ts index 556b0ccad..4cdebd624 100644 --- a/packages/server/src/server/workspace-reconciliation-service.test.ts +++ b/packages/server/src/server/workspace-reconciliation-service.test.ts @@ -2,8 +2,9 @@ import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; +import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages"; import type pino from "pino"; -import { describe, expect, test, vi, afterEach } from "vitest"; +import { afterEach, describe, expect, test } from "vitest"; import { createPersistedProjectRecord, createPersistedWorkspaceRecord, @@ -14,7 +15,10 @@ import type { ProjectRegistry, WorkspaceRegistry, } from "./workspace-registry.js"; -import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js"; +import { + type ReconciliationChange, + WorkspaceReconciliationService, +} from "./workspace-reconciliation-service.js"; function createTestRegistries() { const projects = new Map(); @@ -25,6 +29,22 @@ function createTestRegistries() { existsOnDisk: async () => true, list: async () => Array.from(projects.values()), get: async (id: string) => projects.get(id) ?? null, + getOrCreateActiveByRoot: async (input) => { + const existing = Array.from(projects.values()).find( + (project) => !project.archivedAt && project.rootPath === input.rootPath, + ); + if (existing) return existing; + const record = createPersistedProjectRecord({ + projectId: `prj_${projects.size}`, + rootPath: input.rootPath, + kind: input.kind, + displayName: input.displayName, + createdAt: input.timestamp, + updatedAt: input.timestamp, + }); + projects.set(record.projectId, record); + return record; + }, upsert: async (record: PersistedProjectRecord) => { projects.set(record.projectId, record); }, @@ -64,11 +84,11 @@ function createTestRegistries() { function createTestLogger() { const logger = { child: () => logger, - trace: vi.fn(), - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), + trace: () => undefined, + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined, }; return logger as unknown as pino.Logger; } @@ -106,35 +126,62 @@ function createWorkspaceGitServiceStub( >, ) { return { - getWorkspaceGitMetadata: vi.fn(async (cwd: string, options?: { directoryName?: string }) => { + getCheckout: async (cwd: string) => { const metadata = metadataByCwd[cwd]; - const directoryName = options?.directoryName ?? path.basename(cwd); if (!metadata) { return { - projectKind: "directory" as const, - projectDisplayName: directoryName, - workspaceDisplayName: directoryName, - gitRemote: null, - isWorktree: false, - projectSlug: "untitled", - repoRoot: null, + cwd, + isGit: false as const, currentBranch: null, remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, }; } return { - gitRemote: metadata.gitRemote ?? null, - isWorktree: false, - projectSlug: "repo", - repoRoot: cwd, - currentBranch: metadata.workspaceDisplayName, + cwd, + isGit: metadata.projectKind === "git", + currentBranch: metadata.currentBranch ?? metadata.workspaceDisplayName, remoteUrl: metadata.gitRemote ?? null, - ...metadata, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, }; - }), + }, }; } +function createCheckout( + cwd: string, + overrides: Partial = {}, +): ProjectCheckoutLitePayload { + return { + cwd, + isGit: false, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + ...overrides, + }; +} + +class TestCheckouts { + readonly reads: string[] = []; + private readonly checkouts = new Map(); + + set(cwd: string, checkout: ProjectCheckoutLitePayload): void { + this.checkouts.set(cwd, checkout); + } + + async getCheckout(cwd: string): Promise { + this.reads.push(cwd); + return this.checkouts.get(cwd) ?? createCheckout(cwd); + } +} + function initGitRepoInDir(dir: string): void { execFileSync("git", ["init", "-b", "main"], { cwd: dir, stdio: "ignore" }); execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: dir, stdio: "ignore" }); @@ -169,6 +216,262 @@ describe("WorkspaceReconciliationService", () => { tempDirs.length = 0; }); + test("metadata reconciliation leaves missing workspaces active while a full pass archives them", async () => { + const projectRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-metadata-only-"))); + const missingWorkspace = path.join(projectRoot, "missing-workspace"); + tempDirs.push(projectRoot); + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + + projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + rootPath: projectRoot, + kind: "non_git", + displayName: "metadata-only", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + workspaces.set( + "w1", + createPersistedWorkspaceRecord({ + workspaceId: "w1", + projectId: "p1", + cwd: missingWorkspace, + kind: "directory", + displayName: "missing-workspace", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + }); + + const metadataResult = await service.reconcileGitMetadata(); + + expect(metadataResult.changesApplied).toEqual([]); + expect(workspaces.get("w1")?.archivedAt).toBeNull(); + + const fullResult = await service.runOnce(); + + expect(fullResult.changesApplied).toEqual([ + { + kind: "workspace_archived", + workspaceId: "w1", + directory: missingWorkspace, + reason: "directory_missing", + }, + ]); + expect(workspaces.get("w1")?.archivedAt).toEqual(expect.any(String)); + }); + + test("reads fresh checkout facts on every metadata pass", async () => { + const projectRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-fresh-git-"))); + tempDirs.push(projectRoot); + const { projects, projectRegistry, workspaceRegistry } = createTestRegistries(); + const git = new TestCheckouts(); + git.set(projectRoot, createCheckout(projectRoot)); + projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + rootPath: projectRoot, + kind: "non_git", + displayName: "fresh-git", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + workspaceGitService: git, + }); + + const beforeGitInit = await service.reconcileGitMetadata(); + git.set( + projectRoot, + createCheckout(projectRoot, { + isGit: true, + currentBranch: "main", + worktreeRoot: projectRoot, + }), + ); + const afterGitInit = await service.reconcileGitMetadata(); + + expect(beforeGitInit.changesApplied).toEqual([]); + expect(afterGitInit.changesApplied).toEqual([ + { + kind: "project_updated", + projectId: "p1", + directory: projectRoot, + fields: { kind: "git" }, + }, + ]); + expect(git.reads).toEqual([projectRoot, projectRoot]); + expect(projects.get("p1")?.kind).toBe("git"); + }); + + test("deduplicates equivalent project and workspace paths across legacy duplicate projects", async () => { + const projectRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-global-root-"))); + const workspaceRoot = realpathSync( + mkdtempSync(path.join(tmpdir(), "reconcile-global-workspace-")), + ); + tempDirs.push(projectRoot, workspaceRoot); + const equivalentProjectRoot = `${projectRoot}${path.sep}.`; + const equivalentWorkspaceRoot = `${workspaceRoot}${path.sep}.`; + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + const git = new TestCheckouts(); + const projectCheckout = createCheckout(projectRoot, { + isGit: true, + currentBranch: "main", + worktreeRoot: projectRoot, + }); + const workspaceCheckout = createCheckout(workspaceRoot, { + isGit: true, + currentBranch: "topic", + worktreeRoot: workspaceRoot, + }); + git.set(projectRoot, projectCheckout); + git.set(equivalentProjectRoot, projectCheckout); + git.set(workspaceRoot, workspaceCheckout); + git.set(equivalentWorkspaceRoot, workspaceCheckout); + + for (const [projectId, rootPath] of [ + ["p1", projectRoot], + ["p2", equivalentProjectRoot], + ] as const) { + projects.set( + projectId, + createPersistedProjectRecord({ + projectId, + rootPath, + kind: "git", + displayName: projectId, + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + } + for (const [workspaceId, projectId, cwd] of [ + ["w1", "p1", workspaceRoot], + ["w2", "p2", equivalentWorkspaceRoot], + ] as const) { + workspaces.set( + workspaceId, + createPersistedWorkspaceRecord({ + workspaceId, + projectId, + cwd, + kind: "local_checkout", + displayName: workspaceId, + branch: "topic", + worktreeRoot: workspaceRoot, + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + } + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + workspaceGitService: git, + }); + + const result = await service.reconcileGitMetadata(); + + expect(result.changesApplied).toEqual([]); + expect(git.reads).toEqual([projectRoot, workspaceRoot]); + }); + + test("updates mutable Git facts without changing project or workspace identity", async () => { + const projectRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-stable-project-"))); + const workspaceRoot = realpathSync( + mkdtempSync(path.join(tmpdir(), "reconcile-explicit-workspace-")), + ); + tempDirs.push(projectRoot, workspaceRoot); + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + const originalProject = createPersistedProjectRecord({ + projectId: "p1", + rootPath: projectRoot, + kind: "non_git", + displayName: "Stable project name", + customName: "Pinned project name", + createdAt: timestamp, + updatedAt: timestamp, + }); + const originalWorkspace = createPersistedWorkspaceRecord({ + workspaceId: "w1", + projectId: "p1", + cwd: workspaceRoot, + kind: "local_checkout", + displayName: "Stable workspace name", + title: "Pinned workspace name", + branch: "stale-branch", + baseBranch: "main", + createdAt: timestamp, + updatedAt: timestamp, + }); + projects.set(originalProject.projectId, originalProject); + workspaces.set(originalWorkspace.workspaceId, originalWorkspace); + const git = new TestCheckouts(); + git.set( + projectRoot, + createCheckout(projectRoot, { + isGit: true, + currentBranch: "main", + worktreeRoot: projectRoot, + }), + ); + git.set(workspaceRoot, createCheckout(workspaceRoot)); + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + workspaceGitService: git, + }); + + const result = await service.reconcileGitMetadata(); + + expect(result.changesApplied).toEqual( + expect.arrayContaining([ + { + kind: "project_updated", + projectId: "p1", + directory: projectRoot, + fields: { kind: "git" }, + }, + { + kind: "workspace_updated", + workspaceId: "w1", + directory: workspaceRoot, + fields: { + branch: null, + kind: "directory", + }, + }, + ]), + ); + expect(result.changesApplied).toHaveLength(2); + expect(projects.get("p1")).toEqual({ + ...originalProject, + kind: "git", + updatedAt: expect.any(String), + }); + expect(workspaces.get("w1")).toEqual({ + ...originalWorkspace, + kind: "directory", + branch: null, + updatedAt: expect.any(String), + }); + }); + test("archives workspaces whose directories no longer exist", async () => { const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); @@ -213,17 +516,15 @@ describe("WorkspaceReconciliationService", () => { test("keeps a project active after all its workspaces are archived", async () => { const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); - projects.set( - "p1", - createPersistedProjectRecord({ - projectId: "p1", - rootPath: "/tmp/does-not-exist-reconcile-orphan", - kind: "non_git", - displayName: "orphan", - createdAt: timestamp, - updatedAt: timestamp, - }), - ); + const project = createPersistedProjectRecord({ + projectId: "p1", + rootPath: "/tmp/does-not-exist-reconcile-orphan", + kind: "non_git", + displayName: "orphan", + createdAt: timestamp, + updatedAt: timestamp, + }); + projects.set(project.projectId, project); workspaces.set( "w1", createPersistedWorkspaceRecord({ @@ -245,9 +546,32 @@ describe("WorkspaceReconciliationService", () => { const result = await service.runOnce(); - const projChange = result.changesApplied.find((c) => c.kind === "project_archived"); - expect(projChange).toBeUndefined(); - expect(projects.get("p1")!.archivedAt).toBeFalsy(); + expect(result.changesApplied).toEqual([ + { + kind: "workspace_archived", + workspaceId: "w1", + directory: "/tmp/does-not-exist-reconcile-orphan", + reason: "directory_missing", + }, + ]); + expect(workspaces.get("w1")).toEqual({ + workspaceId: "w1", + projectId: "p1", + cwd: "/tmp/does-not-exist-reconcile-orphan", + kind: "directory", + displayName: "orphan", + title: null, + pinnedAt: null, + branch: null, + worktreeRoot: null, + baseBranch: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + createdAt: timestamp, + updatedAt: expect.any(String), + archivedAt: expect.any(String), + }); + expect(projects.get("p1")).toEqual(project); }); test("updates project kind when a directory becomes a git repo", async () => { @@ -357,7 +681,7 @@ describe("WorkspaceReconciliationService", () => { expect(workspaces.get("w1")!.kind).toBe("local_checkout"); }); - test("moves workspaces from a path-keyed duplicate project to the existing remote-keyed project", async () => { + test("keeps legacy duplicate projects and workspace membership intact", async () => { const repoDir = createTempGitRepo("reconcile-duplicate-project-"); tempDirs.push(repoDir); const canonicalWorktreeDir = path.join(repoDir, ".paseo", "worktrees", "focused-bat"); @@ -442,33 +766,35 @@ describe("WorkspaceReconciliationService", () => { const result = await service.runOnce(); - expect(result.changesApplied).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - kind: "workspace_updated", - workspaceId: "gigantic-blowfish", - fields: { projectId: "remote:github.com/blank-dot-page/editor" }, - }), - expect.objectContaining({ - kind: "project_updated", - projectId: "remote:github.com/blank-dot-page/editor", - fields: { customName: "Editor" }, - }), - expect.objectContaining({ - kind: "project_archived", - projectId: repoDir, - reason: "merged_duplicate", - }), - ]), - ); - expect(workspaces.get("gigantic-blowfish")!.projectId).toBe( - "remote:github.com/blank-dot-page/editor", - ); - expect(projects.get("remote:github.com/blank-dot-page/editor")!.customName).toBe("Editor"); - expect(projects.get(repoDir)!.archivedAt).toBeTruthy(); + expect(result.changesApplied.map((change) => change.kind).sort()).toEqual([ + "workspace_updated", + "workspace_updated", + ]); + expect(projects.get("remote:github.com/blank-dot-page/editor")).toMatchObject({ + projectId: "remote:github.com/blank-dot-page/editor", + rootPath: repoDir, + displayName: "blank-dot-page/editor", + customName: null, + archivedAt: null, + }); + expect(projects.get(repoDir)).toMatchObject({ + projectId: repoDir, + rootPath: repoDir, + displayName: "editor", + customName: "Editor", + archivedAt: null, + }); + expect(workspaces.get("focused-bat")).toMatchObject({ + projectId: "remote:github.com/blank-dot-page/editor", + archivedAt: null, + }); + expect(workspaces.get("gigantic-blowfish")).toMatchObject({ + projectId: repoDir, + archivedAt: null, + }); }); - test("updates project display name when git remote changes", async () => { + test("keeps project display name stable when git remote changes", async () => { const dir = createTempGitRepo("reconcile-remote-"); tempDirs.push(dir); @@ -520,12 +846,11 @@ describe("WorkspaceReconciliationService", () => { const result = await service.runOnce(); - const projUpdate = result.changesApplied.find((c) => c.kind === "project_updated"); - expect(projUpdate).toBeDefined(); - expect(projects.get("p1")!.displayName).toBe("new-owner/new-repo"); + expect(result.changesApplied.find((c) => c.kind === "project_updated")).toBeUndefined(); + expect(projects.get("p1")!.displayName).toBe("old-owner/old-repo"); }); - test("preserves customName even when the derived displayName changes", async () => { + test("keeps custom and default names stable when the remote changes", async () => { const dir = createTempGitRepo("reconcile-customname-"); tempDirs.push(dir); @@ -577,10 +902,151 @@ describe("WorkspaceReconciliationService", () => { await service.runOnce(); - expect(projects.get("p1")!.displayName).toBe("new-owner/new-repo"); + expect(projects.get("p1")!.displayName).toBe("old-owner/old-repo"); expect(projects.get("p1")!.customName).toBe("My Fork"); }); + test("keeps persisted Git metadata when a workspace checkout read fails", async () => { + const projectRoot = mkdtempSync(path.join(tmpdir(), "reconcile-checkout-read-project-")); + const workspaceRoot = path.join(projectRoot, "workspace"); + mkdirSync(workspaceRoot); + tempDirs.push(projectRoot); + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + const project = createPersistedProjectRecord({ + projectId: "p1", + rootPath: projectRoot, + kind: "non_git", + displayName: "project", + createdAt: timestamp, + updatedAt: timestamp, + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "w1", + projectId: project.projectId, + cwd: workspaceRoot, + kind: "local_checkout", + displayName: "workspace", + branch: "feature", + createdAt: timestamp, + updatedAt: timestamp, + }); + projects.set(project.projectId, project); + workspaces.set(workspace.workspaceId, workspace); + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + workspaceGitService: { + getCheckout: async (cwd) => { + if (cwd === workspaceRoot) throw new Error("Git read failed"); + return createCheckout(cwd, { isGit: true, currentBranch: "main", worktreeRoot: cwd }); + }, + }, + }); + + const result = await service.reconcileGitMetadata(); + + expect(result.changesApplied).toEqual([]); + expect(projects.get(project.projectId)).toEqual(project); + expect(workspaces.get(workspace.workspaceId)).toEqual(workspace); + }); + + test("archives non-directory workspaces without blocking sibling reconciliation", async () => { + const projectRoot = mkdtempSync(path.join(tmpdir(), "reconcile-file-workspace-")); + const replacedWorkspace = path.join(projectRoot, "replaced-workspace"); + const siblingWorkspace = path.join(projectRoot, "sibling-workspace"); + writeFileSync(replacedWorkspace, "not a directory\n"); + mkdirSync(siblingWorkspace); + tempDirs.push(projectRoot); + + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + rootPath: projectRoot, + kind: "non_git", + displayName: "project", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + workspaces.set( + "replaced", + createPersistedWorkspaceRecord({ + workspaceId: "replaced", + projectId: "p1", + cwd: replacedWorkspace, + kind: "directory", + displayName: "replaced-workspace", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + workspaces.set( + "sibling", + createPersistedWorkspaceRecord({ + workspaceId: "sibling", + projectId: "p1", + cwd: siblingWorkspace, + kind: "directory", + displayName: "sibling-workspace", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + workspaceGitService: { + getCheckout: async (cwd) => { + if (cwd === replacedWorkspace) { + throw new Error("Git cannot use a regular file as cwd"); + } + return createCheckout(cwd, { + isGit: true, + currentBranch: cwd === siblingWorkspace ? "feature/sibling" : "main", + worktreeRoot: cwd, + }); + }, + }, + }); + + const result = await service.runOnce(); + + expect(result.changesApplied).toEqual([ + { + kind: "workspace_archived", + workspaceId: "replaced", + directory: replacedWorkspace, + reason: "directory_missing", + }, + { + kind: "project_updated", + projectId: "p1", + directory: projectRoot, + fields: { kind: "git" }, + }, + { + kind: "workspace_updated", + workspaceId: "sibling", + directory: siblingWorkspace, + fields: { + kind: "local_checkout", + branch: "feature/sibling", + worktreeRoot: siblingWorkspace, + }, + }, + ]); + expect(workspaces.get("replaced")?.archivedAt).toEqual(expect.any(String)); + expect(workspaces.get("sibling")).toMatchObject({ + kind: "local_checkout", + branch: "feature/sibling", + }); + }); + test("updates workspace branch metadata without clobbering the workspace name", async () => { const dir = createTempGitRepo("reconcile-branch-"); tempDirs.push(dir); @@ -707,18 +1173,24 @@ describe("WorkspaceReconciliationService", () => { }), ); - const onChanges = vi.fn(); + const reportedChanges: ReconciliationChange[] = []; const service = new WorkspaceReconciliationService({ projectRegistry, workspaceRegistry, logger: createTestLogger(), - onChanges, + onChanges: (changes) => reportedChanges.push(...changes), }); await service.runOnce(); - expect(onChanges).toHaveBeenCalledTimes(1); - expect(onChanges.mock.calls[0][0].length).toBeGreaterThan(0); + expect(reportedChanges).toEqual([ + { + kind: "workspace_archived", + workspaceId: "w1", + directory: "/tmp/does-not-exist-callback-test", + reason: "directory_missing", + }, + ]); }); test("logs reconciliation changes with affected paths and reasons", async () => { @@ -791,4 +1263,69 @@ describe("WorkspaceReconciliationService", () => { expect(infoRecords).toEqual([]); }); + + test("backfills persisted worktree ownership from the current checkout", async () => { + const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-worktree-owner-"))); + tempDirs.push(rootPath); + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + const checkouts = new TestCheckouts(); + checkouts.set( + rootPath, + createCheckout(rootPath, { + isGit: true, + worktreeRoot: rootPath, + isPaseoOwnedWorktree: true, + mainRepoRoot: "/tmp/main-repo", + }), + ); + projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + rootPath, + kind: "git", + displayName: "worktree", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + workspaces.set( + "w1", + createPersistedWorkspaceRecord({ + workspaceId: "w1", + projectId: "p1", + cwd: rootPath, + kind: "worktree", + displayName: "worktree", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + workspaceGitService: checkouts, + logger: createTestLogger(), + }); + + const result = await service.reconcileGitMetadata(); + + expect(result.changesApplied).toEqual([ + { + kind: "workspace_updated", + workspaceId: "w1", + directory: rootPath, + fields: { + worktreeRoot: rootPath, + isPaseoOwnedWorktree: true, + mainRepoRoot: "/tmp/main-repo", + }, + }, + ]); + expect(workspaces.get("w1")).toMatchObject({ + worktreeRoot: rootPath, + isPaseoOwnedWorktree: true, + mainRepoRoot: "/tmp/main-repo", + }); + }); }); diff --git a/packages/server/src/server/workspace-reconciliation-service.ts b/packages/server/src/server/workspace-reconciliation-service.ts index acf1cd650..28ca9e219 100644 --- a/packages/server/src/server/workspace-reconciliation-service.ts +++ b/packages/server/src/server/workspace-reconciliation-service.ts @@ -1,5 +1,5 @@ -import { existsSync } from "node:fs"; -import { resolve } from "node:path"; +import { statSync, watch as watchPath } from "node:fs"; +import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages"; import type pino from "pino"; import type { ProjectRegistry, @@ -8,49 +8,71 @@ import type { PersistedWorkspaceRecord, } from "./workspace-registry.js"; import type { WorkspaceGitService } from "./workspace-git-service.js"; +import { areEquivalentPaths } from "../utils/path.js"; +import { + deriveProjectKind, + reconcileWorkspacePlacement, + type MutableWorkspacePlacement, +} from "./workspace-registry-model.js"; +import { workspaceIdsForProjects } from "./workspace-directory.js"; -const DEFAULT_RECONCILE_INTERVAL_MS = 60_000; +const DEFAULT_RESCAN_INTERVAL_MS = 5 * 60_000; +const DEFAULT_DEBOUNCE_MS = 100; -function deriveWorkspaceKindFromMetadata(metadata: { - projectKind: "git" | "directory"; - isWorktree: boolean; -}): PersistedWorkspaceRecord["kind"] { - if (metadata.projectKind !== "git") return "directory"; - if (metadata.isWorktree) return "worktree"; - return "local_checkout"; +export type ProjectUpdate = + | { kind: "upsert"; project: PersistedProjectRecord } + | { kind: "remove"; projectId: string }; + +interface ProjectRootWatcher { + close(): void; } -function chooseCanonicalProject(projects: PersistedProjectRecord[]): PersistedProjectRecord { - return [...projects].sort((left, right) => { - const leftRemote = left.projectId.startsWith("remote:"); - const rightRemote = right.projectId.startsWith("remote:"); - if (leftRemote !== rightRemote) { - return leftRemote ? -1 : 1; - } - const createdAt = Date.parse(left.createdAt) - Date.parse(right.createdAt); - if (createdAt !== 0) { - return createdAt; - } - return left.projectId.localeCompare(right.projectId); - })[0]!; +export interface ProjectRootWatch { + ( + rootPath: string, + options: { recursive: false }, + onChange: (event: string, filename: string | Buffer | null) => void, + onError: (error: Error) => void, + ): ProjectRootWatcher; } +export interface ReconciliationTimer { + unref?(): void; +} + +export interface ReconciliationClock { + setTimeout(callback: () => void | Promise, delayMs: number): ReconciliationTimer; + clearTimeout(timer: ReconciliationTimer): void; + setInterval(callback: () => void | Promise, delayMs: number): ReconciliationTimer; + clearInterval(timer: ReconciliationTimer): void; +} + +const systemClock: ReconciliationClock = { + setTimeout: (callback, delayMs) => setTimeout(() => void callback(), delayMs), + clearTimeout: (timer) => clearTimeout(timer as ReturnType), + setInterval: (callback, delayMs) => setInterval(() => void callback(), delayMs), + clearInterval: (timer) => clearInterval(timer as ReturnType), +}; + +const watchProjectRoot: ProjectRootWatch = (rootPath, options, onChange, onError) => { + const watcher = watchPath(rootPath, options, onChange); + watcher.on("error", onError); + return watcher; +}; + export type ReconciliationChange = | { kind: "workspace_archived"; workspaceId: string; directory: string; reason: string } - | { kind: "project_archived"; projectId: string; directory: string; reason: string } | { kind: "project_updated"; projectId: string; directory: string; - fields: Partial< - Pick - >; + fields: Partial>; } | { kind: "workspace_updated"; workspaceId: string; directory: string; - fields: Partial>; + fields: Partial; }; export interface ReconciliationResult { @@ -62,62 +84,130 @@ export interface WorkspaceReconciliationServiceOptions { projectRegistry: ProjectRegistry; workspaceRegistry: WorkspaceRegistry; logger: pino.Logger; - intervalMs?: number; onChanges?: (changes: ReconciliationChange[]) => void; - workspaceGitService?: Pick; + workspaceGitService?: Pick; + onProjectUpdate?: (update: ProjectUpdate) => void; + onWorkspacesChanged?: (workspaceIds: string[]) => Promise; + watchProjectRoot?: ProjectRootWatch; + clock?: ReconciliationClock; + rescanIntervalMs?: number; + debounceMs?: number; } +interface ProjectReconciliationInput { + project: PersistedProjectRecord; + siblings: PersistedWorkspaceRecord[]; + currentGit: ProjectCheckoutLitePayload; + readCheckout: (cwd: string) => Promise; + changes: ReconciliationChange[]; +} + +interface CachedCheckoutRead { + cwd: string; + checkout: Promise; +} + +type DirectoryState = "directory" | "missing" | "unreadable"; + export class WorkspaceReconciliationService { private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; private readonly logger: pino.Logger; - private readonly intervalMs: number; private readonly onChanges: ((changes: ReconciliationChange[]) => void) | null; - private readonly workspaceGitService: Pick | null; - private timer: ReturnType | null = null; - private running = false; + private readonly workspaceGitService: Pick | null; + private readonly onProjectUpdate: ((update: ProjectUpdate) => void) | null; + private readonly onWorkspacesChanged: ((workspaceIds: string[]) => Promise) | null; + private readonly watchProjectRoot: ProjectRootWatch; + private readonly clock: ReconciliationClock; + private readonly rescanIntervalMs: number; + private readonly debounceMs: number; + private readonly watchers: Array<{ rootPath: string; watcher: ProjectRootWatcher }> = []; + private unsubscribeRegistry: (() => void) | null = null; + private rescanTimer: ReconciliationTimer | null = null; + private debounceTimer: ReconciliationTimer | null = null; + private disposed = false; + private started = false; + private reconciling = false; + private reconcileQueuedMode: "metadata" | "full" | null = null; constructor(options: WorkspaceReconciliationServiceOptions) { this.projectRegistry = options.projectRegistry; this.workspaceRegistry = options.workspaceRegistry; this.logger = options.logger.child({ module: "workspace-reconciliation" }); - this.intervalMs = options.intervalMs ?? DEFAULT_RECONCILE_INTERVAL_MS; this.onChanges = options.onChanges ?? null; this.workspaceGitService = options.workspaceGitService ?? null; + this.onProjectUpdate = options.onProjectUpdate ?? null; + this.onWorkspacesChanged = options.onWorkspacesChanged ?? null; + this.watchProjectRoot = options.watchProjectRoot ?? watchProjectRoot; + this.clock = options.clock ?? systemClock; + this.rescanIntervalMs = options.rescanIntervalMs ?? DEFAULT_RESCAN_INTERVAL_MS; + this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS; } - start(): void { - if (this.timer) return; - this.logger.info({ intervalMs: this.intervalMs }, "Starting workspace reconciliation service"); - this.timer = setInterval(() => void this.runSafe(), this.intervalMs); - // Run once immediately on start - void this.runSafe(); + async start(): Promise { + if (this.started) return; + this.started = true; + this.unsubscribeRegistry = + this.projectRegistry.subscribeToMutations?.(async (mutation) => { + try { + // Project creation does not resolve until its root watch is installed, + // closing the git-init race for newly added empty projects. + await this.syncProjectRootWatches(); + if (this.disposed) return; + if (mutation.kind === "upsert" && mutation.project && !mutation.project.archivedAt) { + this.onProjectUpdate?.({ kind: "upsert", project: mutation.project }); + } else { + this.onProjectUpdate?.({ kind: "remove", projectId: mutation.projectId }); + } + } catch (error) { + this.logger.warn({ err: error }, "Project reconciliation mutation handling failed"); + } + }) ?? null; + await this.syncProjectRootWatches(); + this.rescanTimer = this.clock.setInterval( + () => this.reconcileObservedGitMetadata("full"), + this.rescanIntervalMs, + ); + this.rescanTimer.unref?.(); } - stop(): void { - if (this.timer) { - clearInterval(this.timer); - this.timer = null; + dispose(): void { + this.disposed = true; + this.unsubscribeRegistry?.(); + this.unsubscribeRegistry = null; + if (this.rescanTimer) this.clock.clearInterval(this.rescanTimer); + if (this.debounceTimer) this.clock.clearTimeout(this.debounceTimer); + for (const { watcher } of this.watchers) watcher.close(); + this.watchers.length = 0; + } + + /** Reconciles mutable Git facts only; never archives missing records. */ + async reconcileGitMetadata(): Promise { + const start = Date.now(); + const changes: ReconciliationChange[] = []; + const [projects, workspaces] = await Promise.all([ + this.projectRegistry.list(), + this.workspaceRegistry.list(), + ]); + const workspacesByProject = new Map(); + for (const workspace of workspaces) { + if (workspace.archivedAt || this.inspectDirectory(workspace.cwd) !== "directory") continue; + const siblings = workspacesByProject.get(workspace.projectId) ?? []; + siblings.push(workspace); + workspacesByProject.set(workspace.projectId, siblings); } + await this.reconcileGitMetadataForProjects( + projects.filter( + (project) => !project.archivedAt && this.inspectDirectory(project.rootPath) === "directory", + ), + workspacesByProject, + changes, + ); + if (changes.length > 0) this.onChanges?.(changes); + return { changesApplied: changes, durationMs: Date.now() - start }; } async runOnce(): Promise { - return this.reconcile(); - } - - private async runSafe(): Promise { - if (this.running) return; - this.running = true; - try { - await this.reconcile(); - } catch (error) { - this.logger.error({ err: error }, "Reconciliation pass failed"); - } finally { - this.running = false; - } - } - - private async reconcile(): Promise { const start = Date.now(); const changes: ReconciliationChange[] = []; @@ -126,16 +216,23 @@ export class WorkspaceReconciliationService { const activeProjects = allProjects.filter((p) => !p.archivedAt); const activeWorkspaces = allWorkspaces.filter((w) => !w.archivedAt); + const workspaceDirectoryStates = activeWorkspaces.map((workspace) => ({ + workspace, + state: this.inspectDirectory(workspace.cwd), + })); const workspacesByProject = new Map(); - for (const workspace of activeWorkspaces) { + for (const { workspace, state } of workspaceDirectoryStates) { + if (state !== "directory") continue; const list = workspacesByProject.get(workspace.projectId) ?? []; list.push(workspace); workspacesByProject.set(workspace.projectId, list); } // 1. Archive workspaces whose directories no longer exist - const missingWorkspaces = activeWorkspaces.filter((workspace) => !existsSync(workspace.cwd)); + const missingWorkspaces = workspaceDirectoryStates + .filter(({ state }) => state === "missing") + .map(({ workspace }) => workspace); await Promise.all( missingWorkspaces.map(async (workspace) => { const timestamp = new Date().toISOString(); @@ -156,29 +253,13 @@ export class WorkspaceReconciliationService { }), ); - // 2. Merge duplicate active project records that point at the same repo root. - await this.mergeDuplicateProjectsByRoot(activeProjects, workspacesByProject, changes); - - // 3. Reconcile git metadata for active projects whose directories still exist. + // 2. Reconcile mutable git metadata without changing identity or membership. // Projects persist until explicitly removed, even when they currently have // zero active workspaces, so they still reconcile their own metadata. - // Skip projects archived earlier in this pass (e.g. merged duplicates) so we - // don't resurrect them by upserting a stale, non-archived copy. - const archivedProjectIds = new Set( - changes - .filter((change) => change.kind === "project_archived") - .map((change) => change.projectId), - ); - const projectsToReconcile = activeProjects.filter((project) => { - if (project.archivedAt) return false; - if (archivedProjectIds.has(project.projectId)) return false; - if (!existsSync(project.rootPath)) return false; - return true; - }); - await Promise.all( - projectsToReconcile.map((project) => - this.reconcileProject(project, workspacesByProject.get(project.projectId) ?? [], changes), - ), + await this.reconcileGitMetadataForProjects( + activeProjects.filter((project) => this.inspectDirectory(project.rootPath) === "directory"), + workspacesByProject, + changes, ); if (changes.length > 0 && this.onChanges) { @@ -188,136 +269,72 @@ export class WorkspaceReconciliationService { const result = { changesApplied: changes, durationMs: Date.now() - start }; if (changes.length > 0) { this.logger.info( - { - changeCount: changes.length, - durationMs: result.durationMs, - changes, - }, + { changeCount: changes.length, durationMs: result.durationMs, changes }, "Workspace reconciliation applied changes", ); } - return result; } - private async mergeDuplicateProjectsByRoot( - activeProjects: PersistedProjectRecord[], + private async reconcileGitMetadataForProjects( + projectsToReconcile: PersistedProjectRecord[], workspacesByProject: Map, changes: ReconciliationChange[], ): Promise { - const projectsByRoot = new Map(); - for (const project of activeProjects) { - if (project.kind !== "git") { - continue; - } - const rootKey = resolve(project.rootPath); - const group = projectsByRoot.get(rootKey) ?? []; - group.push(project); - projectsByRoot.set(rootKey, group); - } - - for (const duplicates of projectsByRoot.values()) { - if (duplicates.length < 2) { - continue; - } - const canonical = chooseCanonicalProject(duplicates); - const duplicateProjects = duplicates.filter( - (project) => project.projectId !== canonical.projectId, + const checkoutReads: CachedCheckoutRead[] = []; + const readCheckout = (cwd: string): Promise => { + const existing = checkoutReads.find((read) => areEquivalentPaths(read.cwd, cwd)); + if (existing) return existing.checkout; + const checkout = this.readCheckout(cwd); + checkoutReads.push({ cwd, checkout }); + return checkout; + }; + const roots: Array<{ rootPath: string; projects: PersistedProjectRecord[] }> = []; + for (const project of projectsToReconcile) { + const root = roots.find((candidate) => + areEquivalentPaths(candidate.rootPath, project.rootPath), ); - await this.mergeDuplicateProjectCustomName(canonical, duplicateProjects, changes); - await Promise.all( - duplicateProjects.flatMap((project) => - (workspacesByProject.get(project.projectId) ?? []).map(async (workspace) => { - const timestamp = new Date().toISOString(); - const updatedWorkspace = { - ...workspace, - projectId: canonical.projectId, - updatedAt: timestamp, - }; - await this.workspaceRegistry.upsert(updatedWorkspace); - changes.push({ - kind: "workspace_updated", - workspaceId: workspace.workspaceId, - directory: workspace.cwd, - fields: { - projectId: canonical.projectId, - }, - }); - - const canonicalSiblings = workspacesByProject.get(canonical.projectId) ?? []; - canonicalSiblings.push(updatedWorkspace); - workspacesByProject.set(canonical.projectId, canonicalSiblings); - }), - ), - ); - - for (const project of duplicateProjects) { - workspacesByProject.set(project.projectId, []); - const timestamp = new Date().toISOString(); - await this.projectRegistry.archive(project.projectId, timestamp); - changes.push({ - kind: "project_archived", - projectId: project.projectId, - directory: project.rootPath, - reason: "merged_duplicate", - }); - } + if (root) root.projects.push(project); + else roots.push({ rootPath: project.rootPath, projects: [project] }); } + await Promise.all( + roots.map(async ({ rootPath, projects }) => { + try { + const rootGit = await readCheckout(rootPath); + await Promise.all( + projects.map((project) => + this.reconcileProject({ + project, + siblings: workspacesByProject.get(project.projectId) ?? [], + currentGit: rootGit, + readCheckout, + changes, + }), + ), + ); + } catch (error) { + this.logger.warn( + { err: error, rootPath }, + "Skipped workspace reconciliation after Git read failed", + ); + } + }), + ); } - private async mergeDuplicateProjectCustomName( - canonical: PersistedProjectRecord, - duplicateProjects: PersistedProjectRecord[], - changes: ReconciliationChange[], - ): Promise { - if (canonical.customName) { - return; - } - const customName = duplicateProjects.find((project) => project.customName)?.customName ?? null; - if (!customName) { - return; - } - - const timestamp = new Date().toISOString(); - await this.projectRegistry.upsert({ - ...canonical, - customName, - updatedAt: timestamp, - }); - canonical.customName = customName; - changes.push({ - kind: "project_updated", - projectId: canonical.projectId, - directory: canonical.rootPath, - fields: { customName }, - }); - } - - private async reconcileProject( - project: PersistedProjectRecord, - siblings: PersistedWorkspaceRecord[], - changes: ReconciliationChange[], - ): Promise { - const directoryName = project.rootPath.split(/[\\/]/).findLast(Boolean) ?? project.rootPath; - const currentGit = await this.readWorkspaceGitMetadata(project.rootPath, directoryName); - - const projectUpdates: Partial< - Pick - > = {}; - - const mappedKind = currentGit.projectKind === "git" ? "git" : "non_git"; + private async reconcileProject(input: ProjectReconciliationInput): Promise { + const { project, siblings, currentGit, readCheckout, changes } = input; + const workspaceCheckouts = await Promise.all( + siblings.map(async (workspace) => ({ + workspace, + checkout: await readCheckout(workspace.cwd), + })), + ); + const projectUpdates: Partial> = {}; + const mappedKind = deriveProjectKind(currentGit); if (project.kind !== mappedKind) { projectUpdates.kind = mappedKind; - projectUpdates.displayName = currentGit.projectDisplayName; - } - - if ( - project.kind === "git" && - currentGit.projectKind === "git" && - project.displayName !== currentGit.projectDisplayName - ) { - projectUpdates.displayName = currentGit.projectDisplayName; } if (Object.keys(projectUpdates).length > 0) { @@ -335,58 +352,163 @@ export class WorkspaceReconciliationService { }); } - const existingSiblings = siblings.filter((workspace) => existsSync(workspace.cwd)); await Promise.all( - existingSiblings.map(async (workspace) => { - const wsDirName = workspace.cwd.split(/[\\/]/).findLast(Boolean) ?? workspace.cwd; - const wsGit = await this.readWorkspaceGitMetadata(workspace.cwd, wsDirName); - - const expectedKind = deriveWorkspaceKindFromMetadata(wsGit); - - const workspaceUpdates: Partial> = {}; - - if (wsGit.projectKind === "git" && workspace.branch !== wsGit.currentBranch) { - workspaceUpdates.branch = wsGit.currentBranch; - } - - if (workspace.kind !== expectedKind) { - workspaceUpdates.kind = expectedKind; - } - - if (Object.keys(workspaceUpdates).length === 0) { - return; - } - + workspaceCheckouts.map(async ({ workspace, checkout: wsGit }) => { const timestamp = new Date().toISOString(); - await this.workspaceRegistry.upsert({ - ...workspace, - ...workspaceUpdates, + const update = reconcileWorkspacePlacement({ + workspace, + checkout: wsGit, updatedAt: timestamp, }); + if (!update) return; + + await this.workspaceRegistry.upsert(update.workspace); changes.push({ kind: "workspace_updated", workspaceId: workspace.workspaceId, directory: workspace.cwd, - fields: workspaceUpdates, + fields: update.fields, }); }), ); } - private async readWorkspaceGitMetadata(cwd: string, directoryName: string) { + private async syncProjectRootWatches(): Promise { + if (this.disposed) return; + const projects = await this.projectRegistry.list(); + if (this.disposed) return; + const activeProjects = projects.filter((project) => !project.archivedAt); + + for (let index = this.watchers.length - 1; index >= 0; index -= 1) { + const target = this.watchers[index]!; + const stillActive = activeProjects.some((project) => + areEquivalentPaths(project.rootPath, target.rootPath), + ); + if (stillActive) continue; + target.watcher.close(); + this.watchers.splice(index, 1); + } + + for (const project of activeProjects) { + const alreadyWatching = this.watchers.some((target) => + areEquivalentPaths(target.rootPath, project.rootPath), + ); + if (alreadyWatching) continue; + try { + let watcher: ProjectRootWatcher; + watcher = this.watchProjectRoot( + project.rootPath, + { recursive: false }, + (_event, filename) => { + if (filename === null || filename.toString() === ".git") { + this.scheduleObservedReconciliation(); + } + }, + (error) => { + watcher.close(); + const index = this.watchers.findIndex((target) => target.watcher === watcher); + if (index >= 0) this.watchers.splice(index, 1); + this.logger.warn( + { err: error, rootPath: project.rootPath }, + "Project root watch failed", + ); + }, + ); + this.watchers.push({ rootPath: project.rootPath, watcher }); + } catch (error) { + // The periodic reconciliation is the convergence path for roots that + // are temporarily missing or unwatchable. + this.logger.debug( + { err: error, rootPath: project.rootPath }, + "Project root is not watchable yet", + ); + } + } + } + + private scheduleObservedReconciliation(): void { + if (this.disposed || this.debounceTimer) return; + this.debounceTimer = this.clock.setTimeout(() => { + this.debounceTimer = null; + return this.reconcileObservedGitMetadata(); + }, this.debounceMs); + } + + private async reconcileObservedGitMetadata( + mode: "metadata" | "full" = "metadata", + ): Promise { + if (this.disposed) return; + if (this.reconciling) { + if (mode === "full" || this.reconcileQueuedMode === null) { + this.reconcileQueuedMode = mode; + } + return; + } + this.reconciling = true; + try { + await this.syncProjectRootWatches(); + const result = mode === "full" ? await this.runOnce() : await this.reconcileGitMetadata(); + const workspaceIds = new Set(); + const projectIds = new Set(); + for (const change of result.changesApplied) { + if (change.kind === "workspace_updated" || change.kind === "workspace_archived") { + workspaceIds.add(change.workspaceId); + } + if (change.kind === "project_updated") projectIds.add(change.projectId); + } + if (projectIds.size > 0) { + const workspaces = await this.workspaceRegistry.list(); + for (const workspaceId of workspaceIdsForProjects(workspaces, projectIds)) { + workspaceIds.add(workspaceId); + } + } + if (!this.disposed && workspaceIds.size > 0) { + await this.onWorkspacesChanged?.(Array.from(workspaceIds)); + } + } catch (error) { + if (!this.disposed) { + this.logger.warn({ err: error }, "Workspace reconciliation failed"); + } + } finally { + this.reconciling = false; + if (this.reconcileQueuedMode) { + const queuedMode = this.reconcileQueuedMode; + this.reconcileQueuedMode = null; + void this.reconcileObservedGitMetadata(queuedMode); + } + } + } + + private async readCheckout(cwd: string): Promise { if (!this.workspaceGitService) { return { - projectKind: "directory" as const, - projectDisplayName: directoryName, - workspaceDisplayName: directoryName, - gitRemote: null, - isWorktree: false, - projectSlug: "untitled", - repoRoot: null, + cwd, + isGit: false as const, currentBranch: null, remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false as const, + mainRepoRoot: null, }; } - return this.workspaceGitService.getWorkspaceGitMetadata(cwd, { directoryName }); + return this.workspaceGitService.getCheckout(cwd); + } + + private inspectDirectory(targetPath: string): DirectoryState { + try { + return statSync(targetPath).isDirectory() ? "directory" : "missing"; + } catch (error) { + if (isMissingPathError(error)) return "missing"; + this.logger.warn( + { err: error, targetPath }, + "Skipped workspace reconciliation after directory inspection failed", + ); + return "unreadable"; + } } } + +function isMissingPathError(error: unknown): boolean { + if (typeof error !== "object" || error === null || !("code" in error)) return false; + return error.code === "ENOENT" || error.code === "ENOTDIR"; +} diff --git a/packages/server/src/server/workspace-registry-bootstrap-legacy.ts b/packages/server/src/server/workspace-registry-bootstrap-legacy.ts new file mode 100644 index 000000000..2a712e6a5 --- /dev/null +++ b/packages/server/src/server/workspace-registry-bootstrap-legacy.ts @@ -0,0 +1,120 @@ +import { resolve } from "node:path"; + +import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages"; + +import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js"; +import { + deriveProjectKind, + deriveWorkspaceDisplayName, + deriveWorkspaceKind, + type PersistedProjectKind, + type PersistedWorkspaceKind, +} from "./workspace-registry-model.js"; + +// COMPAT(legacyRegistryBootstrap): added in v0.1.109 on 2026-07-15; remove after +// 2027-01-15, once every supported install has materialized its registry files. +interface DirectoryProjectMembership { + cwd: string; + checkout: ProjectCheckoutLitePayload; + workspaceDirectoryKey: string; + workspaceKind: PersistedWorkspaceKind; + workspaceDisplayName: string; + projectKey: string; + projectName: string; + projectRootPath: string; + projectKind: PersistedProjectKind; +} + +export function classifyDirectoryForProjectMembership(input: { + cwd: string; + checkout: ProjectCheckoutLitePayload; +}): DirectoryProjectMembership { + const cwd = resolve(input.cwd); + const checkout: ProjectCheckoutLitePayload = { ...input.checkout, cwd }; + const projectKey = deriveProjectGroupingKey({ + cwd: checkout.worktreeRoot ?? cwd, + remoteUrl: checkout.remoteUrl, + mainRepoRoot: checkout.mainRepoRoot, + }); + + return { + cwd, + checkout, + workspaceDirectoryKey: deriveWorkspaceDirectoryKey(cwd, checkout), + workspaceKind: deriveWorkspaceKind(checkout), + workspaceDisplayName: deriveWorkspaceDisplayName({ cwd, checkout }), + projectKey, + projectName: deriveProjectGroupingName(projectKey), + projectRootPath: deriveProjectRootPath({ cwd, checkout }), + projectKind: deriveProjectKind(checkout), + }; +} + +function deriveWorkspaceDirectoryKey(cwd: string, checkout: ProjectCheckoutLitePayload): string { + const worktreeRoot = checkout.worktreeRoot ? parseGitRevParsePath(checkout.worktreeRoot) : null; + return worktreeRoot ?? resolve(cwd); +} + +function deriveRemoteProjectKey(remoteUrl: string | null): string | null { + if (!remoteUrl) return null; + + const trimmed = remoteUrl.trim(); + if (!trimmed) return null; + + let host: string | null = null; + let remotePath: string | null = null; + const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/); + if (scpLike) { + host = scpLike[1] ?? null; + remotePath = scpLike[2] ?? null; + } else if (trimmed.includes("://")) { + try { + const parsed = new URL(trimmed); + host = parsed.hostname || null; + remotePath = parsed.pathname ? parsed.pathname.replace(/^\/+/, "") : null; + } catch { + return null; + } + } + + if (!host || !remotePath) return null; + + let cleanedPath = remotePath.trim().replace(/^\/+/, "").replace(/\/+$/, ""); + if (cleanedPath.endsWith(".git")) cleanedPath = cleanedPath.slice(0, -4); + if (!cleanedPath.includes("/")) return null; + + return `remote:${host.toLowerCase()}/${cleanedPath}`; +} + +function deriveProjectGroupingKey(options: { + cwd: string; + remoteUrl: string | null; + mainRepoRoot: string | null; +}): string { + const remoteKey = deriveRemoteProjectKey(options.remoteUrl); + if (remoteKey) return remoteKey; + + const mainRepoRoot = options.mainRepoRoot?.trim(); + return mainRepoRoot || options.cwd; +} + +function deriveProjectGroupingName(projectKey: string): string { + if (projectKey.startsWith("remote:")) { + const pathSegments = projectKey.slice("remote:".length).split("/").filter(Boolean).slice(1); + if (pathSegments.length >= 2) return pathSegments.slice(-2).join("/"); + if (pathSegments.length === 1) return pathSegments[0]; + return projectKey; + } + + const segments = projectKey.split(/[\\/]/).filter(Boolean); + return segments[segments.length - 1] || projectKey; +} + +function deriveProjectRootPath(input: { + cwd: string; + checkout: ProjectCheckoutLitePayload; +}): string { + return input.checkout.isGit && input.checkout.mainRepoRoot + ? input.checkout.mainRepoRoot + : input.cwd; +} diff --git a/packages/server/src/server/workspace-registry-bootstrap.test.ts b/packages/server/src/server/workspace-registry-bootstrap.test.ts index e97b83112..56ccf4d0b 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.test.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.test.ts @@ -1,6 +1,6 @@ import os from "node:os"; import path from "node:path"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; @@ -11,8 +11,10 @@ import type { WorkspaceGitService } from "./workspace-git-service.js"; import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js"; import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js"; -const NON_GIT_PROJECT = path.resolve("/tmp/non-git-project"); -const ARCHIVED_PROJECT = path.resolve("/tmp/archived-project"); +let NON_GIT_PROJECT: string; +let ARCHIVED_PROJECT: string; +let GIT_PROJECT: string; +let GIT_WORKTREE: string; describe("bootstrapWorkspaceRegistries", () => { let tmpDir: string; @@ -25,6 +27,10 @@ describe("bootstrapWorkspaceRegistries", () => { beforeEach(() => { tmpDir = mkdtempSync(path.join(os.tmpdir(), "workspace-bootstrap-")); + NON_GIT_PROJECT = path.join(tmpDir, "non-git-project"); + ARCHIVED_PROJECT = path.join(tmpDir, "archived-project"); + GIT_PROJECT = path.join(tmpDir, "legacy-git-project"); + GIT_WORKTREE = path.join(tmpDir, "legacy-git-project-feature"); paseoHome = path.join(tmpDir, ".paseo"); agentStorage = new AgentStorage(path.join(paseoHome, "agents"), logger); projectRegistry = new FileBackedProjectRegistry( @@ -36,12 +42,133 @@ describe("bootstrapWorkspaceRegistries", () => { logger, ); workspaceGitService = createNoopWorkspaceGitService(); + for (const directory of [NON_GIT_PROJECT, ARCHIVED_PROJECT, GIT_PROJECT, GIT_WORKTREE]) { + mkdirSync(directory, { recursive: true }); + } }); afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); + test("skips a legacy agent whose directory no longer exists", async () => { + const missingDirectory = path.join(tmpDir, "missing-project"); + const getCheckout = async () => { + throw new Error("Git must not inspect a missing directory"); + }; + workspaceGitService = { ...createNoopWorkspaceGitService(), getCheckout }; + await agentStorage.initialize(); + await agentStorage.upsert({ + id: "agent-missing-directory", + provider: "codex", + cwd: missingDirectory, + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + lastActivityAt: null, + lastUserMessageAt: null, + title: null, + labels: {}, + lastStatus: "idle", + lastModeId: null, + config: null, + runtimeInfo: { provider: "codex", sessionId: null }, + persistence: null, + archivedAt: null, + }); + + await bootstrapWorkspaceRegistries({ + paseoHome, + agentStorage, + projectRegistry, + workspaceRegistry, + workspaceGitService, + logger, + }); + + expect(await projectRegistry.list()).toEqual([]); + expect(await workspaceRegistry.list()).toEqual([]); + }); + + test("skips a legacy agent whose cwd is a file", async () => { + const cwd = path.join(tmpDir, "not-a-directory"); + writeFileSync(cwd, "not a directory"); + workspaceGitService = { + ...createNoopWorkspaceGitService(), + getCheckout: async () => { + throw new Error("Git must not inspect a file"); + }, + }; + await agentStorage.initialize(); + await agentStorage.upsert({ + id: "agent-file-cwd", + provider: "codex", + cwd, + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + lastActivityAt: null, + lastUserMessageAt: null, + title: null, + labels: {}, + lastStatus: "idle", + lastModeId: null, + config: null, + runtimeInfo: { provider: "codex", sessionId: null }, + persistence: null, + archivedAt: null, + }); + + await bootstrapWorkspaceRegistries({ + paseoHome, + agentStorage, + projectRegistry, + workspaceRegistry, + workspaceGitService, + logger, + }); + + expect(await projectRegistry.list()).toEqual([]); + expect(await workspaceRegistry.list()).toEqual([]); + }); + + test("propagates a Git failure for an existing legacy directory", async () => { + const gitFailure = new Error("Git is unavailable"); + workspaceGitService = { + ...createNoopWorkspaceGitService(), + getCheckout: async () => { + throw gitFailure; + }, + }; + await agentStorage.initialize(); + await agentStorage.upsert({ + id: "agent-existing-directory", + provider: "codex", + cwd: NON_GIT_PROJECT, + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + lastActivityAt: null, + lastUserMessageAt: null, + title: null, + labels: {}, + lastStatus: "idle", + lastModeId: null, + config: null, + runtimeInfo: { provider: "codex", sessionId: null }, + persistence: null, + archivedAt: null, + }); + + await expect( + bootstrapWorkspaceRegistries({ + paseoHome, + agentStorage, + projectRegistry, + workspaceRegistry, + workspaceGitService, + logger, + }), + ).rejects.toBe(gitFailure); + }); + test("materializes workspace registries from non-archived agent records", async () => { await agentStorage.initialize(); await agentStorage.upsert({ @@ -175,6 +302,81 @@ describe("bootstrapWorkspaceRegistries", () => { expect((await workspaceRegistry.list())[0]?.workspaceId).toBe("ws-existing"); }); + test("materializes legacy remote worktrees into one readable project", async () => { + workspaceGitService = createNoopWorkspaceGitService({ + getCheckout: async (cwd) => ({ + cwd, + isGit: true, + currentBranch: cwd === GIT_PROJECT ? "main" : "feature/plain", + remoteUrl: "git@github.com:acme/legacy-project.git", + worktreeRoot: cwd, + isPaseoOwnedWorktree: false, + mainRepoRoot: cwd === GIT_PROJECT ? null : GIT_PROJECT, + }), + }); + await agentStorage.initialize(); + for (const [id, cwd] of [ + ["main-agent", GIT_PROJECT], + ["worktree-agent", GIT_WORKTREE], + ]) { + await agentStorage.upsert({ + id, + provider: "codex", + cwd, + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + lastActivityAt: "2026-03-02T00:00:00.000Z", + lastUserMessageAt: null, + title: null, + labels: {}, + lastStatus: "idle", + lastModeId: null, + config: null, + runtimeInfo: { provider: "codex", sessionId: null }, + persistence: null, + archivedAt: null, + }); + } + + await bootstrapWorkspaceRegistries({ + paseoHome, + agentStorage, + projectRegistry, + workspaceRegistry, + workspaceGitService, + logger, + }); + + const projects = await projectRegistry.list(); + expect(projects).toHaveLength(1); + expect(projects[0]).toMatchObject({ + projectId: "remote:github.com/acme/legacy-project", + rootPath: GIT_PROJECT, + kind: "git", + displayName: "acme/legacy-project", + }); + + const workspaces = await workspaceRegistry.list(); + expect( + workspaces + .map(({ projectId, cwd, kind, displayName }) => ({ projectId, cwd, kind, displayName })) + .sort((left, right) => left.cwd.localeCompare(right.cwd)), + ).toEqual([ + { + projectId: "remote:github.com/acme/legacy-project", + cwd: GIT_PROJECT, + kind: "local_checkout", + displayName: "main", + }, + { + projectId: "remote:github.com/acme/legacy-project", + cwd: GIT_WORKTREE, + kind: "worktree", + displayName: "feature/plain", + }, + ]); + }); + test("migrates cwd-only agents to the oldest existing same-cwd workspace", async () => { await projectRegistry.initialize(); await workspaceRegistry.initialize(); diff --git a/packages/server/src/server/workspace-registry-bootstrap.ts b/packages/server/src/server/workspace-registry-bootstrap.ts index c673059e3..8a75d5323 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.ts @@ -1,13 +1,12 @@ import path from "node:path"; +import { statSync } from "node:fs"; import type { Logger } from "pino"; import type { StoredAgentRecord } from "./agent/agent-storage.js"; import type { AgentStorage } from "./agent/agent-storage.js"; -import { - classifyDirectoryForProjectMembership, - generateWorkspaceId, -} from "./workspace-registry-model.js"; +import { classifyDirectoryForProjectMembership } from "./workspace-registry-bootstrap-legacy.js"; +import { generateWorkspaceId } from "./workspace-registry-model.js"; import { backfillWorkspaceIdForLegacyAgents } from "./migrations/backfill-workspace-id.migration.js"; import type { WorkspaceGitService } from "./workspace-git-service.js"; import { @@ -72,7 +71,17 @@ export async function bootstrapWorkspaceRegistries(options: { ]), ); const records = await options.agentStorage.list(); - const activeRecords = records.filter((record) => !record.archivedAt); + // A legacy agent can outlive its working directory. Reconciliation treats a + // missing directory as absent rather than asking Git about it; bootstrap must + // do the same before materializing its first workspace record. + const activeRecords = records.filter((record) => { + if (record.archivedAt) return false; + try { + return statSync(record.cwd).isDirectory(); + } catch { + return false; + } + }); const recordsByDirectoryKey = new Map< string, { diff --git a/packages/server/src/server/workspace-registry-model.test.ts b/packages/server/src/server/workspace-registry-model.test.ts index 115d8048a..1f51a0c4a 100644 --- a/packages/server/src/server/workspace-registry-model.test.ts +++ b/packages/server/src/server/workspace-registry-model.test.ts @@ -1,226 +1,30 @@ import { describe, expect, test } from "vitest"; -import { basename, isAbsolute, resolve } from "node:path"; +import { isAbsolute } from "node:path"; import { - classifyDirectoryForProjectMembership, - deriveProjectGroupingName, - deriveProjectRootPath, - deriveWorkspaceDirectoryKey, + checkoutFromPersistedWorkspacePlacement, deriveWorkspaceKind, - detectStaleWorkspaces, generateWorkspaceId, + generateProjectId, + initialWorkspacePlacement, + reconcileWorkspacePlacement, } from "./workspace-registry-model.js"; import { createPersistedWorkspaceRecord } from "./workspace-registry.js"; -function createWorkspaceRecord( - cwd: string, - workspaceId: string, - overrides?: { createdAt?: string; archivedAt?: string }, -) { - return createPersistedWorkspaceRecord({ - workspaceId, - projectId: workspaceId, - cwd, - kind: "directory", - displayName: basename(cwd) || cwd, - createdAt: overrides?.createdAt ?? "2026-03-01T00:00:00.000Z", - updatedAt: overrides?.createdAt ?? "2026-03-01T00:00:00.000Z", - archivedAt: overrides?.archivedAt ?? null, - }); -} - -describe("deriveProjectGroupingName", () => { - test("returns owner/repo for a github remote project key", () => { - expect(deriveProjectGroupingName("remote:github.com/acme/app")).toBe("acme/app"); +describe("opaque registry ids", () => { + test("generates opaque project ids", () => { + expect(generateProjectId()).toMatch(/^prj_[0-9a-f]{16}$/); }); - test("returns owner/repo for a gitlab remote project key", () => { - expect(deriveProjectGroupingName("remote:gitlab.com/acme/app")).toBe("acme/app"); - }); - - test("returns last two segments for a self-hosted remote project key", () => { - expect(deriveProjectGroupingName("remote:git.acme.internal/platform/api")).toBe("platform/api"); - }); - - test("returns last two segments for a deeply-nested remote project key", () => { - expect(deriveProjectGroupingName("remote:gitlab.com/group/sub/app")).toBe("sub/app"); - }); - - test("returns the lone path segment when only one segment follows the host", () => { - expect(deriveProjectGroupingName("remote:github.com/solo")).toBe("solo"); - }); - - test("returns the trailing path segment for a non-remote project key", () => { - expect(deriveProjectGroupingName("/repo/local")).toBe("local"); - }); - - test("returns the project key itself when no segments are present", () => { - expect(deriveProjectGroupingName("")).toBe(""); - }); -}); - -describe("detectStaleWorkspaces", () => { - test("returns workspace ids whose directories no longer exist", async () => { - const checkedDirectories: string[] = []; - const existingDirectories = new Set(["/tmp/existing"]); - - const staleWorkspaceIds = await detectStaleWorkspaces({ - activeWorkspaces: [ - createWorkspaceRecord("/tmp/existing", "ws-existing"), - createWorkspaceRecord("/tmp/missing", "ws-missing"), - ], - checkDirectoryExists: async (cwd) => { - checkedDirectories.push(cwd); - return existingDirectories.has(cwd); - }, - }); - - expect(Array.from(staleWorkspaceIds)).toEqual(["ws-missing"]); - expect(checkedDirectories).toEqual(["/tmp/existing", "/tmp/missing"]); - }); - - test("keeps workspaces whose directories exist even when all agents are archived", async () => { - const staleWorkspaceIds = await detectStaleWorkspaces({ - activeWorkspaces: [ - createWorkspaceRecord("/tmp/repo", "ws-repo"), - createWorkspaceRecord("/tmp/other", "ws-other"), - ], - checkDirectoryExists: async () => true, - }); - - expect(Array.from(staleWorkspaceIds)).toEqual([]); - }); - - test("keeps workspaces with no agents when directory exists", async () => { - const staleWorkspaceIds = await detectStaleWorkspaces({ - activeWorkspaces: [ - createWorkspaceRecord("/tmp/active", "ws-active"), - createWorkspaceRecord("/tmp/no-agents", "ws-no-agents"), - ], - checkDirectoryExists: async () => true, - }); - - expect(Array.from(staleWorkspaceIds)).toEqual([]); - }); -}); - -describe("deriveWorkspaceDirectoryKey", () => { - test("uses git worktree root when available", () => { - expect( - deriveWorkspaceDirectoryKey("/tmp/repo/packages/app", { - cwd: "/tmp/repo/packages/app", - isGit: true, - currentBranch: "main", - remoteUrl: "https://github.com/acme/repo.git", - worktreeRoot: "/tmp/repo", - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }), - ).toBe("/tmp/repo"); - }); - - test("falls back to normalized cwd when git worktree root contains multiple lines", () => { - const cwd = String.raw`E:\project\node-ai`; - - expect( - deriveWorkspaceDirectoryKey(cwd, { - cwd, - isGit: true, - currentBranch: "main", - remoteUrl: null, - worktreeRoot: `--path-format=absolute\n${cwd}`, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }), - ).toBe(resolve(cwd)); - }); - - test("falls back to normalized cwd for non-git directories", () => { - const cwd = "/tmp/repo/../repo/scratch"; - - expect( - deriveWorkspaceDirectoryKey(cwd, { - cwd, - isGit: false, - currentBranch: null, - remoteUrl: null, - worktreeRoot: null, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }), - ).toBe(resolve("/tmp/repo/scratch")); - }); -}); - -describe("opaque workspace id versus directory key", () => { test("generates opaque workspace ids that are not filesystem paths", () => { const workspaceId = generateWorkspaceId(); expect(workspaceId).toMatch(/^wks_[0-9a-f]+$/); expect(isAbsolute(workspaceId)).toBe(false); }); - - test("derives a path-shaped directory key that is never an opaque workspace id", () => { - const directoryKey = deriveWorkspaceDirectoryKey("/tmp/repo/scratch", { - cwd: "/tmp/repo/scratch", - isGit: false, - currentBranch: null, - remoteUrl: null, - worktreeRoot: null, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }); - - expect(directoryKey).toBe(resolve("/tmp/repo/scratch")); - expect(directoryKey.startsWith("wks_")).toBe(false); - }); }); -describe("git worktree grouping", () => { - test("classifies plain git worktrees for project membership from git facts", () => { - const membership = classifyDirectoryForProjectMembership({ - cwd: "/tmp/repo-feature", - checkout: { - cwd: "/tmp/repo-feature", - isGit: true, - currentBranch: "feature/plain", - remoteUrl: "https://github.com/acme/repo.git", - worktreeRoot: "/tmp/repo-feature", - isPaseoOwnedWorktree: false, - mainRepoRoot: "/tmp/repo", - }, - }); - - expect(membership).toMatchObject({ - // Path-derived directory key, distinct from the opaque workspace id (generated separately). - cwd: resolve("/tmp/repo-feature"), - workspaceDirectoryKey: "/tmp/repo-feature", - workspaceKind: "worktree", - workspaceDisplayName: "feature/plain", - projectKey: "remote:github.com/acme/repo", - projectName: "acme/repo", - projectRootPath: "/tmp/repo", - projectKind: "git", - }); - }); - - test("uses mainRepoRoot as the project root for plain git worktrees", () => { - expect( - deriveProjectRootPath({ - cwd: "/tmp/repo-feature", - checkout: { - cwd: "/tmp/repo-feature", - isGit: true, - currentBranch: "feature/plain", - remoteUrl: "https://github.com/acme/repo.git", - worktreeRoot: "/tmp/repo-feature", - isPaseoOwnedWorktree: false, - mainRepoRoot: "/tmp/repo", - }, - }), - ).toBe("/tmp/repo"); - }); - +describe("workspace kind", () => { test("classifies plain git worktrees as workspaces of kind worktree", () => { expect( deriveWorkspaceKind({ @@ -235,3 +39,119 @@ describe("git worktree grouping", () => { ).toBe("worktree"); }); }); + +describe("workspace placement", () => { + test("defines checkout and created-worktree placement completely", () => { + expect( + initialWorkspacePlacement({ + source: "checkout", + cwd: "/repo", + checkout: { + cwd: "/repo", + isGit: true, + currentBranch: " main ", + remoteUrl: null, + worktreeRoot: "/repo", + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }), + ).toEqual({ + cwd: "/repo", + kind: "local_checkout", + displayName: "main", + branch: "main", + worktreeRoot: "/repo", + baseBranch: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }); + expect( + initialWorkspacePlacement({ + source: "created_worktree", + cwd: "/repo-feature/app", + worktreeRoot: "/repo-feature", + branch: "feature/placement", + baseBranch: "main", + mainRepoRoot: "/repo", + }), + ).toEqual({ + cwd: "/repo-feature/app", + kind: "worktree", + displayName: "feature/placement", + branch: "feature/placement", + worktreeRoot: "/repo-feature", + baseBranch: "main", + isPaseoOwnedWorktree: true, + mainRepoRoot: "/repo", + }); + }); + + test("updates live placement while preserving its durable name and base branch", () => { + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "workspace-one", + projectId: "project-one", + cwd: "/repo-feature", + kind: "worktree", + displayName: "Keep this name", + branch: "old-branch", + worktreeRoot: "/old-root", + baseBranch: "release", + isPaseoOwnedWorktree: true, + mainRepoRoot: "/repo", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + + const update = reconcileWorkspacePlacement({ + workspace, + checkout: { + cwd: workspace.cwd, + isGit: true, + currentBranch: "renamed-branch", + remoteUrl: null, + worktreeRoot: "/repo-feature", + isPaseoOwnedWorktree: false, + mainRepoRoot: "/repo", + }, + updatedAt: "2026-03-02T00:00:00.000Z", + }); + + expect(update?.fields).toEqual({ + branch: "renamed-branch", + worktreeRoot: "/repo-feature", + isPaseoOwnedWorktree: false, + }); + expect(update?.workspace).toMatchObject({ + displayName: "Keep this name", + baseBranch: "release", + branch: "renamed-branch", + }); + }); + + test("projects persisted placement to the wire checkout", () => { + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "workspace-one", + projectId: "project-one", + cwd: "/repo-feature/app", + kind: "worktree", + displayName: "feature", + branch: "feature", + worktreeRoot: "/repo-feature", + isPaseoOwnedWorktree: true, + mainRepoRoot: "/repo", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + + expect(checkoutFromPersistedWorkspacePlacement({ workspace })).toEqual({ + cwd: "/repo-feature/app", + isGit: true, + currentBranch: "feature", + remoteUrl: null, + worktreeRoot: "/repo-feature", + isPaseoOwnedWorktree: true, + mainRepoRoot: "/repo", + }); + }); +}); diff --git a/packages/server/src/server/workspace-registry-model.ts b/packages/server/src/server/workspace-registry-model.ts index 08af1ad27..acc4d4953 100644 --- a/packages/server/src/server/workspace-registry-model.ts +++ b/packages/server/src/server/workspace-registry-model.ts @@ -1,154 +1,20 @@ import { randomBytes } from "node:crypto"; -import { resolve } from "node:path"; import type { ProjectCheckoutLitePayload, ProjectPlacementPayload, } from "@getpaseo/protocol/messages"; -import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js"; import type { PersistedWorkspaceRecord } from "./workspace-registry.js"; export type PersistedProjectKind = "git" | "non_git"; export type PersistedWorkspaceKind = "local_checkout" | "worktree" | "directory"; -export interface DirectoryProjectMembership { - cwd: string; - checkout: ProjectCheckoutLitePayload; - workspaceDirectoryKey: string; - workspaceKind: PersistedWorkspaceKind; - workspaceDisplayName: string; - projectKey: string; - projectName: string; - projectRootPath: string; - projectKind: PersistedProjectKind; -} - -export interface DetectStaleWorkspacesInput { - activeWorkspaces: PersistedWorkspaceRecord[]; - checkDirectoryExists: (cwd: string) => Promise; -} - export function generateWorkspaceId(): string { return `wks_${randomBytes(8).toString("hex")}`; } -// Path-derived grouping key for a workspace directory. This is NOT the opaque -// workspace identity (see generateWorkspaceId); never persist or compare it as one. -export function deriveWorkspaceDirectoryKey( - cwd: string, - checkout: ProjectCheckoutLitePayload, -): string { - const worktreeRoot = checkout.worktreeRoot ? parseGitRevParsePath(checkout.worktreeRoot) : null; - return worktreeRoot ?? resolve(cwd); -} - -function deriveRemoteProjectKey(remoteUrl: string | null): string | null { - if (!remoteUrl) { - return null; - } - - const trimmed = remoteUrl.trim(); - if (!trimmed) { - return null; - } - - let host: string | null = null; - let remotePath: string | null = null; - - const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/); - if (scpLike) { - host = scpLike[1] ?? null; - remotePath = scpLike[2] ?? null; - } else if (trimmed.includes("://")) { - try { - const parsed = new URL(trimmed); - host = parsed.hostname || null; - remotePath = parsed.pathname ? parsed.pathname.replace(/^\/+/, "") : null; - } catch { - return null; - } - } - - if (!host || !remotePath) { - return null; - } - - let cleanedPath = remotePath.trim().replace(/^\/+/, "").replace(/\/+$/, ""); - if (cleanedPath.endsWith(".git")) { - cleanedPath = cleanedPath.slice(0, -4); - } - if (!cleanedPath.includes("/")) { - return null; - } - - const cleanedHost = host.toLowerCase(); - if (cleanedHost === "github.com") { - return `remote:github.com/${cleanedPath}`; - } - - return `remote:${cleanedHost}/${cleanedPath}`; -} - -export function deriveProjectGroupingKey(options: { - cwd: string; - remoteUrl: string | null; - mainRepoRoot: string | null; -}): string { - const remoteKey = deriveRemoteProjectKey(options.remoteUrl); - if (remoteKey) { - return remoteKey; - } - - const mainRepoRoot = options.mainRepoRoot?.trim(); - if (mainRepoRoot) { - return mainRepoRoot; - } - - return options.cwd; -} - -export function deriveProjectGroupingName(projectKey: string): string { - if (projectKey.startsWith("remote:")) { - const remainder = projectKey.slice("remote:".length); - const pathSegments = remainder.split("/").filter(Boolean).slice(1); - if (pathSegments.length >= 2) { - return pathSegments.slice(-2).join("/"); - } - if (pathSegments.length === 1) { - return pathSegments[0]; - } - return projectKey; - } - - const segments = projectKey.split(/[\\/]/).filter(Boolean); - return segments[segments.length - 1] || projectKey; -} - -function deriveWorkspaceDirectoryName(cwd: string): string { - const normalized = cwd.replace(/\\/g, "/"); - const segments = normalized.split("/").filter(Boolean); - return segments[segments.length - 1] ?? cwd; -} - -export function deriveWorkspaceDisplayName(input: { - cwd: string; - checkout: ProjectCheckoutLitePayload; -}): string { - const branch = input.checkout.currentBranch?.trim() ?? null; - if (branch && branch.toUpperCase() !== "HEAD") { - return branch; - } - return deriveWorkspaceDirectoryName(input.cwd); -} - -export function deriveProjectRootPath(input: { - cwd: string; - checkout: ProjectCheckoutLitePayload; -}): string { - if (input.checkout.isGit && input.checkout.mainRepoRoot) { - return input.checkout.mainRepoRoot; - } - return input.cwd; +export function generateProjectId(): string { + return `prj_${randomBytes(8).toString("hex")}`; } export function deriveProjectKind(checkout: ProjectCheckoutLitePayload): PersistedProjectKind { @@ -162,6 +28,161 @@ export function deriveWorkspaceKind(checkout: ProjectCheckoutLitePayload): Persi return checkout.mainRepoRoot ? "worktree" : "local_checkout"; } +export function deriveWorkspaceDisplayName(input: { + cwd: string; + checkout: ProjectCheckoutLitePayload; +}): string { + const branch = input.checkout.currentBranch?.trim() ?? null; + if (branch && branch.toUpperCase() !== "HEAD") return branch; + + const segments = input.cwd.replace(/\\/g, "/").split("/").filter(Boolean); + return segments[segments.length - 1] ?? input.cwd; +} + +export type PersistedWorkspacePlacement = Pick< + PersistedWorkspaceRecord, + | "cwd" + | "kind" + | "displayName" + | "branch" + | "worktreeRoot" + | "baseBranch" + | "isPaseoOwnedWorktree" + | "mainRepoRoot" +>; + +export type MutableWorkspacePlacement = Pick< + PersistedWorkspaceRecord, + "kind" | "branch" | "worktreeRoot" | "isPaseoOwnedWorktree" | "mainRepoRoot" +>; + +export type InitialWorkspacePlacementInput = + | { + source: "checkout"; + cwd: string; + checkout: ProjectCheckoutLitePayload; + } + | { + source: "created_worktree"; + cwd: string; + worktreeRoot: string; + branch: string | null; + baseBranch: string | null; + mainRepoRoot: string; + }; + +export interface WorkspacePlacementUpdate { + workspace: PersistedWorkspaceRecord; + fields: Partial; +} + +/** Defines the complete persisted placement for every new workspace. */ +export function initialWorkspacePlacement( + input: InitialWorkspacePlacementInput, +): PersistedWorkspacePlacement { + if (input.source === "created_worktree") { + return { + cwd: input.cwd, + kind: "worktree", + displayName: input.branch || input.cwd, + branch: input.branch, + worktreeRoot: input.worktreeRoot, + baseBranch: input.baseBranch, + isPaseoOwnedWorktree: true, + mainRepoRoot: input.mainRepoRoot, + }; + } + + const branch = normalizeBranch(input.checkout.currentBranch); + return { + cwd: input.cwd, + kind: deriveWorkspaceKind(input.checkout), + displayName: deriveWorkspaceDisplayName(input), + branch, + worktreeRoot: input.checkout.isGit ? (input.checkout.worktreeRoot ?? input.cwd) : null, + baseBranch: null, + isPaseoOwnedWorktree: input.checkout.isGit && input.checkout.isPaseoOwnedWorktree, + mainRepoRoot: input.checkout.isGit ? input.checkout.mainRepoRoot : null, + }; +} + +/** + * Applies live placement facts without rewriting the workspace's durable name + * or its creation-time base branch. + */ +export function reconcileWorkspacePlacement(input: { + workspace: PersistedWorkspaceRecord; + checkout: ProjectCheckoutLitePayload; + updatedAt: string; +}): WorkspacePlacementUpdate | null { + const observed = initialWorkspacePlacement({ + source: "checkout", + cwd: input.workspace.cwd, + checkout: input.checkout, + }); + const fields: Partial = {}; + if (input.workspace.kind !== observed.kind) fields.kind = observed.kind; + if (input.workspace.branch !== observed.branch) fields.branch = observed.branch; + if (input.workspace.worktreeRoot !== observed.worktreeRoot) + fields.worktreeRoot = observed.worktreeRoot; + if (input.workspace.isPaseoOwnedWorktree !== observed.isPaseoOwnedWorktree) + fields.isPaseoOwnedWorktree = observed.isPaseoOwnedWorktree; + if (input.workspace.mainRepoRoot !== observed.mainRepoRoot) + fields.mainRepoRoot = observed.mainRepoRoot; + + if (Object.keys(fields).length === 0) return null; + return { + workspace: { ...input.workspace, ...fields, updatedAt: input.updatedAt }, + fields, + }; +} + +/** Projects persisted placement onto the checkout shape sent over the wire. */ +export function checkoutFromPersistedWorkspacePlacement(input: { + workspace: PersistedWorkspaceRecord; + fallbackBranch?: string | null; + fallbackWorktreeRoot?: string | null; +}): ProjectPlacementPayload["checkout"] { + const { workspace } = input; + if (workspace.kind === "directory") { + return { + cwd: workspace.cwd, + isGit: false, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }; + } + + const checkout = { + cwd: workspace.cwd, + currentBranch: workspace.branch ?? input.fallbackBranch ?? null, + remoteUrl: null, + worktreeRoot: workspace.worktreeRoot ?? input.fallbackWorktreeRoot ?? workspace.cwd, + }; + if (workspace.isPaseoOwnedWorktree && workspace.mainRepoRoot) { + return { + ...checkout, + isGit: true, + isPaseoOwnedWorktree: true, + mainRepoRoot: workspace.mainRepoRoot, + }; + } + return { + ...checkout, + isGit: true, + isPaseoOwnedWorktree: false, + mainRepoRoot: workspace.mainRepoRoot ?? null, + }; +} + +function normalizeBranch(branch: string | null | undefined): string | null { + const normalized = branch?.trim() ?? null; + return normalized && normalized.toUpperCase() !== "HEAD" ? normalized : null; +} + export function checkoutLiteFromGitSnapshot( cwd: string, git: { @@ -205,70 +226,3 @@ export function checkoutLiteFromGitSnapshot( mainRepoRoot: git.mainRepoRoot, }; } - -export async function detectStaleWorkspaces( - input: DetectStaleWorkspacesInput, -): Promise> { - const staleWorkspaceIds = new Set(); - - const existenceChecks = await Promise.all( - input.activeWorkspaces.map(async (workspace) => ({ - workspace, - exists: await input.checkDirectoryExists(workspace.cwd), - })), - ); - for (const { workspace, exists } of existenceChecks) { - if (!exists) { - staleWorkspaceIds.add(workspace.workspaceId); - } - } - - return staleWorkspaceIds; -} - -export function buildProjectPlacementForCwd(input: { - cwd: string; - checkout: ProjectCheckoutLitePayload; -}): ProjectPlacementPayload { - const membership = classifyDirectoryForProjectMembership(input); - return { - projectKey: membership.projectKey, - projectName: membership.projectName, - checkout: membership.checkout, - }; -} - -export function classifyDirectoryForProjectMembership(input: { - cwd: string; - checkout: ProjectCheckoutLitePayload; -}): DirectoryProjectMembership { - const normalizedCwd = resolve(input.cwd); - const checkout: ProjectCheckoutLitePayload = { - ...input.checkout, - cwd: normalizedCwd, - }; - - const projectKey = deriveProjectGroupingKey({ - cwd: checkout.worktreeRoot ?? normalizedCwd, - remoteUrl: checkout.remoteUrl, - mainRepoRoot: checkout.mainRepoRoot, - }); - - return { - cwd: normalizedCwd, - checkout, - workspaceDirectoryKey: deriveWorkspaceDirectoryKey(normalizedCwd, checkout), - workspaceKind: deriveWorkspaceKind(checkout), - workspaceDisplayName: deriveWorkspaceDisplayName({ - cwd: normalizedCwd, - checkout, - }), - projectKey, - projectName: deriveProjectGroupingName(projectKey), - projectRootPath: deriveProjectRootPath({ - cwd: normalizedCwd, - checkout, - }), - projectKind: deriveProjectKind(checkout), - }; -} diff --git a/packages/server/src/server/workspace-registry.test.ts b/packages/server/src/server/workspace-registry.test.ts index 0d14cea39..7c5f13ef2 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 { mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; import { beforeEach, afterEach, describe, expect, test } from "vitest"; @@ -99,43 +99,228 @@ describe("workspace registries", () => { expect(await projectRegistry.list()).toEqual([]); }); - test("PIN: two checkouts of the same git remote collapse into a single project record", async () => { - // Reproduces the situation in #987: two directories that share a git remote - // both derive the same projectKey/displayName. Because the registry is keyed - // by projectId, the second upsert overwrites the first — so the registry can - // only ever hold one record per remote, and there is no way to distinguish - // the two checkouts in the UI. + test("publishes only project mutations that change the persisted lifecycle", async () => { await projectRegistry.initialize(); + const mutations: Array<{ + kind: "upsert" | "archive" | "remove"; + projectId: string; + project: ReturnType | null; + }> = []; + const unsubscribe = projectRegistry.subscribeToMutations((mutation) => { + mutations.push(mutation); + }); + const active = createPersistedProjectRecord({ + projectId: "project-one", + rootPath: "/tmp/project-one", + kind: "non_git", + displayName: "project-one", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + const archived = { + ...active, + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: "2026-03-02T00:00:00.000Z", + }; - const remoteKey = "remote:github.com/acme/repo"; + await projectRegistry.upsert(active); + await projectRegistry.archive(active.projectId, archived.archivedAt); + await projectRegistry.archive(active.projectId, "2026-03-03T00:00:00.000Z"); + await projectRegistry.archive("project-unknown", "2026-03-03T00:00:00.000Z"); + await projectRegistry.remove(active.projectId); + await projectRegistry.remove(active.projectId); + await projectRegistry.remove("project-unknown"); + expect(mutations).toEqual([ + { kind: "upsert", projectId: active.projectId, project: active }, + { kind: "archive", projectId: active.projectId, project: archived }, + { kind: "remove", projectId: active.projectId, project: null }, + ]); + unsubscribe(); + }); + + test("atomically allocates one opaque project for concurrent exact-root adds", async () => { + await projectRegistry.initialize(); + const rootPath = path.join(tmpDir, "same-root"); + const projects = await Promise.all( + Array.from({ length: 20 }, () => + projectRegistry.getOrCreateActiveByRoot({ + rootPath, + kind: "non_git", + displayName: "same-root", + timestamp: "2026-03-01T00:00:00.000Z", + }), + ), + ); + + expect(new Set(projects.map((project) => project.projectId))).toEqual( + new Set([projects[0]!.projectId]), + ); + expect(projects[0]!.projectId).toMatch(/^prj_[0-9a-f]{16}$/); + expect(await projectRegistry.list()).toHaveLength(1); + }); + + test("keeps readable legacy IDs alongside newly allocated opaque IDs", async () => { + await projectRegistry.initialize(); await projectRegistry.upsert( createPersistedProjectRecord({ - projectId: remoteKey, - rootPath: "/home/me/work/repo", + projectId: "remote:github.com/acme/repo", + rootPath: "/tmp/legacy", kind: "git", - displayName: "acme/repo", + displayName: "repo", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }), + ); + const opaque = await projectRegistry.getOrCreateActiveByRoot({ + rootPath: "/tmp/new", + kind: "non_git", + displayName: "new", + timestamp: "2026-03-01T00:00:00.000Z", + }); + expect((await projectRegistry.get("remote:github.com/acme/repo"))?.rootPath).toBe( + "/tmp/legacy", + ); + expect(opaque.projectId).toMatch(/^prj_[0-9a-f]{16}$/); + }); + + test("allocates a fresh opaque ID when only an archived exact root exists", async () => { + await projectRegistry.initialize(); + const rootPath = path.join(tmpDir, "archived-root"); + const archived = createPersistedProjectRecord({ + projectId: "prj_archived", + rootPath, + kind: "non_git", + displayName: "archived-root", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + archivedAt: "2026-03-02T00:00:00.000Z", + }); + await projectRegistry.upsert(archived); + + const created = await projectRegistry.getOrCreateActiveByRoot({ + rootPath, + kind: "non_git", + displayName: "archived-root", + timestamp: "2026-03-03T00:00:00.000Z", + }); + + expect(created).toMatchObject({ rootPath, archivedAt: null }); + expect(created.projectId).not.toBe(archived.projectId); + expect(await projectRegistry.get(archived.projectId)).toEqual(archived); + }); + + test("refreshes the oldest active legacy duplicate kind without rewriting its identity", async () => { + await projectRegistry.initialize(); + const rootPath = path.join(tmpDir, "legacy-root"); + const oldest = createPersistedProjectRecord({ + projectId: "remote:oldest", + rootPath, + kind: "git", + displayName: "oldest", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + const duplicate = createPersistedProjectRecord({ + projectId: "remote:duplicate", + rootPath, + kind: "git", + displayName: "duplicate", + createdAt: "2026-03-02T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + }); + await projectRegistry.upsert(oldest); + await projectRegistry.upsert(duplicate); + + await expect( + projectRegistry.getOrCreateActiveByRoot({ + rootPath, + kind: "non_git", + displayName: "new-name", + timestamp: "2026-03-03T00:00:00.000Z", + }), + ).resolves.toEqual({ + ...oldest, + kind: "non_git", + updatedAt: "2026-03-03T00:00:00.000Z", + }); + expect(await projectRegistry.list()).toEqual([ + { ...oldest, kind: "non_git", updatedAt: "2026-03-03T00:00:00.000Z" }, + duplicate, + ]); + }); + + test("reuses an active project for Windows lexical-equivalent root spellings", async () => { + await projectRegistry.initialize(); + const first = await projectRegistry.getOrCreateActiveByRoot({ + rootPath: "C:\\Users\\Paseo\\Repo", + kind: "git", + displayName: "Repo", + timestamp: "2026-03-01T00:00:00.000Z", + }); + const second = await projectRegistry.getOrCreateActiveByRoot({ + rootPath: "c:/users/paseo/repo/.", + kind: "git", + displayName: "Repo", + timestamp: "2026-03-02T00:00:00.000Z", + }); + + expect(second).toEqual(first); + expect(await projectRegistry.list()).toEqual([first]); + }); + + test("keeps lexical and symlink root spellings distinct without realpath", async () => { + await projectRegistry.initialize(); + const target = path.join(tmpDir, "target"); + const link = path.join(tmpDir, "link"); + mkdirSync(target); + symlinkSync(target, link, process.platform === "win32" ? "junction" : "dir"); + + const targetProject = await projectRegistry.getOrCreateActiveByRoot({ + rootPath: target, + kind: "non_git", + displayName: "target", + timestamp: "2026-03-01T00:00:00.000Z", + }); + const linkProject = await projectRegistry.getOrCreateActiveByRoot({ + rootPath: link, + kind: "non_git", + displayName: "link", + timestamp: "2026-03-02T00:00:00.000Z", + }); + + expect(linkProject.projectId).not.toBe(targetProject.projectId); + expect(await projectRegistry.list()).toEqual([targetProject, linkProject]); + }); + + test("retries a generated project ID collision", async () => { + const generatedIds = ["prj_collision", "prj_fresh"]; + projectRegistry = new FileBackedProjectRegistry( + path.join(tmpDir, "projects", "projects.json"), + logger, + { projectIdFactory: () => generatedIds.shift() ?? "prj_unexpected" }, + ); + await projectRegistry.initialize(); + await projectRegistry.upsert( + createPersistedProjectRecord({ + projectId: "prj_collision", + rootPath: path.join(tmpDir, "existing"), + kind: "non_git", + displayName: "existing", createdAt: "2026-03-01T00:00:00.000Z", updatedAt: "2026-03-01T00:00:00.000Z", }), ); - await projectRegistry.upsert( - createPersistedProjectRecord({ - projectId: remoteKey, - rootPath: "/home/me/scratch/repo", - kind: "git", - displayName: "acme/repo", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - }), - ); + const created = await projectRegistry.getOrCreateActiveByRoot({ + rootPath: path.join(tmpDir, "new"), + kind: "non_git", + displayName: "new", + timestamp: "2026-03-02T00:00:00.000Z", + }); - const all = await projectRegistry.list(); - expect(all).toHaveLength(1); - expect(all[0]?.displayName).toBe("acme/repo"); - // Second upsert wins — the first rootPath is lost. - expect(all[0]?.rootPath).toBe("/home/me/scratch/repo"); + expect(created.projectId).toBe("prj_fresh"); + expect(await projectRegistry.list()).toHaveLength(2); }); test("project record schema accepts records without customName (legacy on-disk records)", async () => { @@ -212,6 +397,29 @@ describe("workspace registries", () => { expect(await workspaceRegistry.list()).toEqual([]); }); + test("refreshes workspace archive timestamps when an archive is repeated", async () => { + await workspaceRegistry.initialize(); + await workspaceRegistry.upsert( + createPersistedWorkspaceRecord({ + workspaceId: "workspace-one", + projectId: "project-one", + cwd: "/tmp/repo", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }), + ); + + await workspaceRegistry.archive("workspace-one", "2026-03-02T00:00:00.000Z"); + await workspaceRegistry.archive("workspace-one", "2026-03-03T00:00:00.000Z"); + + expect(await workspaceRegistry.get("workspace-one")).toMatchObject({ + archivedAt: "2026-03-03T00:00:00.000Z", + updatedAt: "2026-03-03T00:00:00.000Z", + }); + }); + test("composes concurrent workspace field updates without losing either change", 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 bddf33734..f219d9158 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -4,7 +4,12 @@ import type { Logger } from "pino"; import { z } from "zod"; import { writeJsonFileAtomic } from "./atomic-file.js"; -import type { PersistedProjectKind, PersistedWorkspaceKind } from "./workspace-registry-model.js"; +import { areEquivalentPaths } from "../utils/path.js"; +import { + generateProjectId, + type PersistedProjectKind, + type PersistedWorkspaceKind, +} from "./workspace-registry-model.js"; const PersistedProjectRecordSchema = z.object({ projectId: z.string(), @@ -45,6 +50,11 @@ const PersistedWorkspaceRecordSchema = z.object({ .nullable() .optional() .transform((value) => value ?? null), + // Exact checkout/worktree root backing cwd. This differs from cwd when the + // selected project is a subdirectory inside a repository. Persist it so + // archive and recovery do not need the directory to still exist in order to + // recover placement. + worktreeRoot: z.string().nullable().default(null), // The base branch the worktree was created from (normalized like worktree.json's // baseRefName). Only worktree workspaces carry a base branch; checkout-branch // worktrees and directory/local_checkout workspaces leave it null. @@ -53,6 +63,8 @@ const PersistedWorkspaceRecordSchema = z.object({ .nullable() .optional() .transform((value) => value ?? null), + isPaseoOwnedWorktree: z.boolean().default(false), + mainRepoRoot: z.string().nullable().default(null), createdAt: z.string(), updatedAt: z.string(), archivedAt: z.string().nullable(), @@ -71,9 +83,23 @@ export interface ProjectRegistry { existsOnDisk(): Promise; list(): Promise; get(projectId: string): Promise; + getOrCreateActiveByRoot(input: { + rootPath: string; + kind: PersistedProjectKind; + displayName: string; + timestamp: string; + }): Promise; upsert(record: PersistedProjectRecord): Promise; archive(projectId: string, archivedAt: string): Promise; remove(projectId: string): Promise; + /** Central lifecycle seam for daemon-global project observers. */ + subscribeToMutations?( + listener: (mutation: { + kind: "upsert" | "archive" | "remove"; + projectId: string; + project: PersistedProjectRecord | null; + }) => void | Promise, + ): () => void; } export interface WorkspaceRegistry { @@ -162,24 +188,43 @@ class FileBackedRegistry { async archive(id: string, archivedAt: string): Promise { await this.load(); const existing = this.cache.get(id); - if (!existing) { - return; + if (!existing) return; + await this.persistArchive(existing, 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); + } + + private async persistArchive(existing: TRecord, archivedAt: string): Promise { const next = this.schema.parse({ ...existing, updatedAt: archivedAt, archivedAt, }); - this.cache.set(id, next); + this.cache.set(this.getId(next), next); await this.enqueuePersist(); + return next; } async remove(id: string): Promise { + await this.removeIfPresent(id); + } + + protected async removeIfPresent(id: string): Promise { await this.load(); - if (!this.cache.delete(id)) { - return; + const existing = this.cache.get(id); + if (!existing) { + return null; } + this.cache.delete(id); await this.enqueuePersist(); + return existing; } private async load(): Promise { @@ -219,7 +264,17 @@ export class FileBackedProjectRegistry extends FileBackedRegistry implements ProjectRegistry { - constructor(filePath: string, logger: Logger) { + private allocationQueue: Promise = Promise.resolve(); + private readonly projectIdFactory: () => string; + private readonly mutationListeners = new Set< + (mutation: { + kind: "upsert" | "archive" | "remove"; + projectId: string; + project: PersistedProjectRecord | null; + }) => void | Promise + >(); + + constructor(filePath: string, logger: Logger, options?: { projectIdFactory?: () => string }) { super({ filePath, logger, @@ -227,6 +282,89 @@ export class FileBackedProjectRegistry getId: (record) => record.projectId, component: "projects", }); + this.projectIdFactory = options?.projectIdFactory ?? generateProjectId; + } + + async getOrCreateActiveByRoot(input: { + rootPath: string; + kind: PersistedProjectKind; + displayName: string; + timestamp: string; + }): Promise { + const previous = this.allocationQueue; + let release!: () => void; + this.allocationQueue = new Promise((resolve) => (release = resolve)); + await previous; + try { + const active = (await this.list()) + .filter( + (project) => !project.archivedAt && areEquivalentPaths(project.rootPath, input.rootPath), + ) + .sort( + (left, right) => + Date.parse(left.createdAt) - Date.parse(right.createdAt) || + left.projectId.localeCompare(right.projectId), + )[0]; + if (active) { + if (active.kind === input.kind) return active; + const refreshed = { ...active, kind: input.kind, updatedAt: input.timestamp }; + await this.upsert(refreshed); + return refreshed; + } + + for (;;) { + const projectId = this.projectIdFactory(); + if (await this.get(projectId)) continue; + const record = createPersistedProjectRecord({ + projectId, + rootPath: input.rootPath, + kind: input.kind, + displayName: input.displayName, + createdAt: input.timestamp, + updatedAt: input.timestamp, + }); + await this.upsert(record); + return record; + } + } finally { + release(); + } + } + + subscribeToMutations( + listener: (mutation: { + kind: "upsert" | "archive" | "remove"; + projectId: string; + project: PersistedProjectRecord | null; + }) => void | Promise, + ): () => void { + this.mutationListeners.add(listener); + return () => this.mutationListeners.delete(listener); + } + + override async upsert(record: PersistedProjectRecord): Promise { + await super.upsert(record); + await this.notifyMutation({ kind: "upsert", projectId: record.projectId, project: record }); + } + + override async archive(projectId: string, archivedAt: string): Promise { + const project = await this.archiveIfActive(projectId, archivedAt); + if (!project) return; + await this.notifyMutation({ kind: "archive", projectId, project }); + } + + override async remove(projectId: string): Promise { + const project = await this.removeIfPresent(projectId); + if (!project) return; + await this.notifyMutation({ kind: "remove", projectId, project: null }); + } + + private async notifyMutation(mutation: { + kind: "upsert" | "archive" | "remove"; + projectId: string; + project: PersistedProjectRecord | null; + }): Promise { + await Promise.all([...this.mutationListeners].map((listener) => listener(mutation))); } } @@ -274,7 +412,10 @@ export function createPersistedWorkspaceRecord(input: { displayName: string; title?: string | null; branch?: string | null; + worktreeRoot?: string | null; baseBranch?: string | null; + isPaseoOwnedWorktree?: boolean; + mainRepoRoot?: string | null; createdAt: string; updatedAt: string; archivedAt?: string | null; @@ -284,7 +425,10 @@ export function createPersistedWorkspaceRecord(input: { ...input, title: input.title ?? null, branch: input.branch ?? null, + worktreeRoot: input.worktreeRoot ?? null, baseBranch: input.baseBranch ?? null, + isPaseoOwnedWorktree: input.isPaseoOwnedWorktree ?? false, + mainRepoRoot: input.mainRepoRoot ?? null, archivedAt: input.archivedAt ?? null, pinnedAt: input.pinnedAt ?? null, }); diff --git a/packages/server/src/server/workspace-source.ts b/packages/server/src/server/workspace-source.ts new file mode 100644 index 000000000..21a53e627 --- /dev/null +++ b/packages/server/src/server/workspace-source.ts @@ -0,0 +1,25 @@ +import { expandTilde } from "../utils/path.js"; +import type { ProjectRegistry } from "./workspace-registry.js"; + +export interface WorktreeWorkspaceSource { + cwd?: string; + projectId?: string; +} + +export async function resolveWorktreeSourceCwd( + source: WorktreeWorkspaceSource, + projectRegistry: Pick, +): Promise { + if (source.cwd) { + return expandTilde(source.cwd); + } + if (!source.projectId) { + throw new Error("cwd or projectId is required for a worktree-backed workspace"); + } + + const project = await projectRegistry.get(source.projectId); + if (!project || project.archivedAt) { + throw new Error(`Project not found: ${source.projectId}`); + } + return project.rootPath; +} diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index 8cb880675..895ab2337 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -44,6 +44,7 @@ export interface RunAsyncWorktreeBootstrapOptions { // workspaceId-scoped archive tear these terminals down. workspaceId: string; worktree: WorktreeConfig; + workspaceCwd?: string; shouldBootstrap?: boolean; terminalManager: TerminalManager | null; appendTimelineItem: (item: AgentTimelineItem) => Promise; @@ -503,7 +504,8 @@ async function runWorktreeTerminalBootstrap( options: RunAsyncWorktreeBootstrapOptions, runtimeEnv: WorktreeRuntimeEnv, ): Promise { - const terminalSpecs = getWorktreeTerminalSpecs(options.worktree.worktreePath); + const workspaceCwd = options.workspaceCwd ?? options.worktree.worktreePath; + const terminalSpecs = getWorktreeTerminalSpecs(workspaceCwd); if (terminalSpecs.length === 0) { return; } @@ -540,7 +542,7 @@ async function runWorktreeTerminalBootstrap( terminalSpecs.map(async (spec): Promise => { try { const terminal = await terminalManager.createTerminal({ - cwd: options.worktree.worktreePath, + cwd: workspaceCwd, name: spec.name, env: runtimeEnv, workspaceId: options.workspaceId, @@ -597,6 +599,7 @@ export async function runAsyncWorktreeBootstrap( let runtimeEnv: WorktreeRuntimeEnv | null = null; const emitLiveTimelineItem = options.emitLiveTimelineItem; const progressAccumulator = createWorktreeSetupProgressAccumulator(); + const workspaceCwd = options.workspaceCwd ?? options.worktree.worktreePath; let liveEmitQueue = Promise.resolve(); const queueLiveRunningEmit = () => { @@ -632,12 +635,12 @@ export async function runAsyncWorktreeBootstrap( branchName: options.worktree.branchName, }); options.terminalManager?.registerCwdEnv({ - cwd: options.worktree.worktreePath, + cwd: workspaceCwd, env: runtimeEnv, }); setupResults = await runWorktreeSetupCommands({ - worktreePath: options.worktree.worktreePath, + worktreePath: workspaceCwd, branchName: options.worktree.branchName, cleanupOnFailure: false, runtimeEnv, diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index 6c8eacb02..a5de5baed 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -32,8 +32,15 @@ import { import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalSession } from "../terminal/terminal.js"; import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js"; -import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js"; +import { + createPersistedProjectRecord, + type PersistedProjectRecord, + type PersistedWorkspaceRecord, + type ProjectRegistry, + type WorkspaceRegistry, +} from "./workspace-registry.js"; import type { ForgeService } from "../services/forge-service.js"; +import { areEquivalentPaths } from "../utils/path.js"; import { createPaseoWorktree as createPaseoWorktreeService, type CreatePaseoWorktreeFn, @@ -41,6 +48,7 @@ import { import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; import type { WorkspaceGitService } from "./workspace-git-service.js"; import { isPlatform } from "../test-utils/platform.js"; +import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js"; interface LegacyCreateWorktreeTestOptions { branchName: string; @@ -294,6 +302,70 @@ function createPaseoWorktreeForTest(options: { forgeOverrides: { github: createGitHubServiceStub() }, }, }); + const projectRegistry: ProjectRegistry = { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => Array.from(projects.values()), + get: async (projectId) => projects.get(projectId) ?? null, + getOrCreateActiveByRoot: async (allocation) => { + const existing = Array.from(projects.values()).find( + (project) => + areEquivalentPaths(project.rootPath, allocation.rootPath) && !project.archivedAt, + ); + if (existing) return existing; + const project = createPersistedProjectRecord({ + projectId: `prj_test_${projects.size + 1}`, + rootPath: allocation.rootPath, + kind: allocation.kind, + displayName: allocation.displayName, + createdAt: allocation.timestamp, + updatedAt: allocation.timestamp, + }); + projects.set(project.projectId, project); + return project; + }, + upsert: async (record) => { + options.events?.push(`project:${record.projectId}`); + projects.set(record.projectId, record); + }, + archive: async (projectId, archivedAt) => { + const project = projects.get(projectId); + if (project) projects.set(projectId, { ...project, archivedAt }); + }, + remove: async (projectId) => { + projects.delete(projectId); + }, + }; + const workspaceRegistry: WorkspaceRegistry = { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => Array.from(workspaces.values()), + get: async (workspaceId) => workspaces.get(workspaceId) ?? null, + update: async (workspaceId, updater) => { + const workspace = workspaces.get(workspaceId); + if (!workspace) return null; + const updated = updater(workspace); + workspaces.set(workspaceId, updated); + return updated; + }, + upsert: async (record) => { + options.events?.push(`workspace:${record.workspaceId}`); + workspaces.set(record.workspaceId, record); + }, + archive: async (workspaceId, archivedAt) => { + const workspace = workspaces.get(workspaceId); + if (workspace) workspaces.set(workspaceId, { ...workspace, archivedAt }); + }, + remove: async (workspaceId) => { + workspaces.delete(workspaceId); + }, + }; + const workspaceProvisioning = createWorkspaceProvisioningService({ + projectRegistry, + workspaceRegistry, + workspaceGitService, + logger: createLogger(), + }); return (input, serviceOptions) => { return createPaseoWorktreeService(input, { @@ -301,22 +373,8 @@ function createPaseoWorktreeForTest(options: { ...(serviceOptions?.resolveDefaultBranch ? { resolveDefaultBranch: serviceOptions.resolveDefaultBranch } : {}), - projectRegistry: { - get: async (projectId) => projects.get(projectId) ?? null, - upsert: async (record) => { - options.events?.push(`project:${record.projectId}`); - projects.set(record.projectId, record); - }, - }, - workspaceRegistry: { - get: async (workspaceId) => workspaces.get(workspaceId) ?? null, - list: async () => Array.from(workspaces.values()), - upsert: async (record) => { - options.events?.push(`workspace:${record.workspaceId}`); - workspaces.set(record.workspaceId, record); - }, - }, workspaceGitService, + workspaceProvisioning, }); }; } @@ -548,6 +606,62 @@ describe("runWorktreeSetupInBackground", () => { } }); + test("runs setup from an exact workspace subdirectory", async () => { + const { tempDir, repoDir } = createGitRepo(); + cleanupPaths.push(tempDir); + const sourceWorkspaceCwd = path.join(repoDir, "packages", "app"); + mkdirSync(sourceWorkspaceCwd, { recursive: true }); + writeFileSync( + path.join(sourceWorkspaceCwd, "paseo.json"), + JSON.stringify({ + worktree: { + setup: ["pwd > setup-cwd.txt"], + }, + }), + ); + execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add app setup"], { + cwd: repoDir, + stdio: "pipe", + }); + + const paseoHome = path.join(tempDir, ".paseo"); + const createdWorktree = await createLegacyWorktreeForTest({ + branchName: "feature-subdirectory-setup", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "feature-subdirectory-setup", + runSetup: false, + paseoHome, + }); + const workspaceCwd = path.join(createdWorktree.worktreePath, "packages", "app"); + + await runWorktreeSetupInBackground( + { + paseoHome, + emitWorkspaceUpdateForWorkspaceId: async () => {}, + cacheWorkspaceSetupSnapshot: () => {}, + emit: () => {}, + sessionLogger: createLogger(), + terminalManager: null, + archiveWorkspaceRecord: async () => {}, + }, + { + requestCwd: sourceWorkspaceCwd, + repoRoot: repoDir, + workspaceId: "ws-subdirectory-setup", + worktree: createdWorktree, + shouldBootstrap: true, + slug: "feature-subdirectory-setup", + worktreePath: createdWorktree.worktreePath, + workspaceCwd, + }, + ); + + expect(existsSync(path.join(workspaceCwd, "setup-cwd.txt"))).toBe(true); + expect(existsSync(path.join(createdWorktree.worktreePath, "setup-cwd.txt"))).toBe(false); + }); + test("emits running then completed snapshots for no-setup workspaces without auto-starting scripts", async () => { const { tempDir, repoDir } = createGitRepo({ paseoConfig: { @@ -1325,7 +1439,7 @@ describe("handleCreatePaseoWorktreeRequest", () => { workspace: { workspaceId: "ws-fix-attached-pr-context", projectId: "/tmp/repo", - cwd: "/tmp/worktrees/fix-attached-pr-context", + cwd: "/tmp/worktrees/fix-attached-pr-context/packages/app", kind: "worktree" as const, displayName: "fix-attached-pr-context", createdAt: "2026-04-30T00:00:00.000Z", @@ -1382,7 +1496,7 @@ describe("handleCreatePaseoWorktreeRequest", () => { }), expect.anything(), ); - expect(result.sessionConfig.cwd).toBe("/tmp/worktrees/fix-attached-pr-context"); + expect(result.sessionConfig.cwd).toBe("/tmp/worktrees/fix-attached-pr-context/packages/app"); }); test("buildAgentSessionConfig invalidates GitHub cache after branch setup mutations", async () => { diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index db296c8fe..5573a945f 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -244,7 +244,7 @@ export async function buildAgentSessionConfig( ), }, ); - cwd = createdWorktree.worktree.worktreePath; + cwd = createdWorktree.workspace.cwd; setupContinuation = createdWorktree.setupContinuation; createdWorkspaceId = createdWorktree.workspace.workspaceId; } else if (normalized.createNewBranch) { @@ -627,6 +627,7 @@ export async function createPaseoWorktreeWorkflow( shouldBootstrap: createdWorktree.created, slug, worktreePath: createdWorktree.worktree.worktreePath, + workspaceCwd: workspace.cwd, }); } }, 0); @@ -641,6 +642,7 @@ export async function createPaseoWorktreeWorkflow( agentId, workspaceId: workspace.workspaceId, worktree: createdWorktree.worktree, + workspaceCwd: workspace.cwd, shouldBootstrap: createdWorktree.created, terminalManager: setupContinuation.terminalManager, appendTimelineItem: (item) => setupContinuation.appendTimelineItem({ agentId, item }), @@ -683,6 +685,7 @@ export async function runWorktreeSetupInBackground( shouldBootstrap: boolean; slug: string; worktreePath: string; + workspaceCwd?: string; }, ): Promise { let worktree: WorktreeConfig = options.worktree; @@ -721,7 +724,8 @@ export async function runWorktreeSetupInBackground( if (!options.shouldBootstrap) { emitSetupProgress("completed", null); } else { - const setupCommands = getWorktreeSetupCommands(worktree.worktreePath); + const workspaceCwd = options.workspaceCwd ?? worktree.worktreePath; + const setupCommands = getWorktreeSetupCommands(workspaceCwd); if (setupCommands.length === 0) { setupStarted = true; emitSetupProgress("completed", null); @@ -732,12 +736,12 @@ export async function runWorktreeSetupInBackground( repoRootPath: options.repoRoot, }); dependencies.terminalManager?.registerCwdEnv({ - cwd: worktree.worktreePath, + cwd: workspaceCwd, env: runtimeEnv, }); setupStarted = true; setupResults = await runWorktreeSetupCommands({ - worktreePath: worktree.worktreePath, + worktreePath: workspaceCwd, branchName: worktree.branchName, cleanupOnFailure: false, repoRootPath: options.repoRoot, diff --git a/packages/server/src/server/worktree/commands.ts b/packages/server/src/server/worktree/commands.ts index 3fa7f601a..cedc5013c 100644 --- a/packages/server/src/server/worktree/commands.ts +++ b/packages/server/src/server/worktree/commands.ts @@ -122,15 +122,14 @@ export async function archiveCommand( dependencies: ArchiveCommandDependencies, input: ArchiveCommandInput, ): Promise { - const resolvedTarget = await resolveArchiveTarget(dependencies, input); + const targetPath = await resolveArchiveTarget(dependencies, input); const scope = input.scope ?? "workspace"; + const ownership = await isPaseoOwnedWorktreeCwd(targetPath, { + paseoHome: dependencies.paseoHome, + worktreesRoot: dependencies.paseoWorktreesBaseRoot, + }); if (scope === "worktree") { - const ownership = await isPaseoOwnedWorktreeCwd(resolvedTarget.targetPath, { - paseoHome: dependencies.paseoHome, - worktreesRoot: dependencies.paseoWorktreesBaseRoot, - }); - if (!ownership.allowed) { return { ok: false, @@ -141,10 +140,7 @@ export async function archiveCommand( } const result = await archiveByScope(dependencies, { - scope: { kind: "worktree", targetPath: resolvedTarget.targetPath }, - repoRoot: ownership.repoRoot ?? resolvedTarget.repoRoot ?? null, - repoWorktreesRoot: ownership.worktreeRoot, - paseoWorktreesBaseRoot: dependencies.paseoWorktreesBaseRoot, + scope: { kind: "worktree", targetPath }, requestId: input.requestId, }); @@ -155,11 +151,11 @@ export async function archiveCommand( } const workspaceId = - input.workspaceId ?? (await resolveWorkspaceIdAtPath(dependencies, resolvedTarget.targetPath)); + input.workspaceId ?? (await resolveWorkspaceIdAtPath(dependencies, targetPath)); if (!workspaceId) { dependencies.sessionLogger?.warn( - { targetPath: resolvedTarget.targetPath }, + { targetPath }, "Could not resolve workspace for archive; skipping", ); return { @@ -170,8 +166,6 @@ export async function archiveCommand( const result = await archiveByScope(dependencies, { scope: { kind: "workspace", workspaceId }, - repoRoot: resolvedTarget.repoRoot, - paseoWorktreesBaseRoot: dependencies.paseoWorktreesBaseRoot, requestId: input.requestId, }); @@ -181,28 +175,20 @@ export async function archiveCommand( }; } -interface ResolvedArchiveTarget { - targetPath: string; - repoRoot: string | null; -} - async function resolveArchiveTarget( dependencies: ArchiveCommandDependencies, input: ArchiveCommandInput, -): Promise { +): Promise { const repoRoot = input.repoRoot ?? null; if (input.worktreePath) { - return { targetPath: input.worktreePath, repoRoot }; + return input.worktreePath; } if (input.worktreeSlug) { if (!repoRoot) { throw new Error("repoRoot is required when worktreeSlug is supplied"); } - return { - targetPath: await resolveWorktreeSlugPath(dependencies, repoRoot, input.worktreeSlug), - repoRoot, - }; + return resolveWorktreeSlugPath(dependencies, repoRoot, input.worktreeSlug); } if (repoRoot && input.branchName) { @@ -211,7 +197,7 @@ async function resolveArchiveTarget( if (!match) { throw new Error(`Paseo worktree not found for branch ${input.branchName}`); } - return { targetPath: match.path, repoRoot }; + return match.path; } throw new Error("worktreePath, worktreeSlug, or repoRoot+branchName is required"); diff --git a/packages/server/src/services/forge-registry.test.ts b/packages/server/src/services/forge-registry.test.ts index dd12174ec..8671f51f8 100644 --- a/packages/server/src/services/forge-registry.test.ts +++ b/packages/server/src/services/forge-registry.test.ts @@ -10,10 +10,12 @@ describe("forge registry", () => { const gitlab = createForgeService("gitlab"); const gitea = createForgeService("gitea"); const forgejo = createForgeService("forgejo"); + const codeberg = createForgeService("codeberg"); expect(github?.getCurrentPullRequestStatus).toBeTypeOf("function"); expect(gitlab?.getCurrentPullRequestStatus).toBeTypeOf("function"); expect(gitea?.getCurrentPullRequestStatus).toBeTypeOf("function"); expect(forgejo?.getCurrentPullRequestStatus).toBeTypeOf("function"); + expect(codeberg?.getCurrentPullRequestStatus).toBeTypeOf("function"); }); it("returns null for an unregistered forge", () => { @@ -29,9 +31,10 @@ describe("forge registry", () => { expect(defaultForgeRegistry.has("gitlab")).toBe(true); expect(defaultForgeRegistry.has("gitea")).toBe(true); expect(defaultForgeRegistry.has("forgejo")).toBe(true); + expect(defaultForgeRegistry.has("codeberg")).toBe(true); expect(defaultForgeRegistry.has("bitbucket")).toBe(false); expect(defaultForgeRegistry.ids()).toEqual( - expect.arrayContaining(["github", "gitlab", "gitea", "forgejo"]), + expect.arrayContaining(["github", "gitlab", "gitea", "forgejo", "codeberg"]), ); }); diff --git a/packages/server/src/services/forge-service.ts b/packages/server/src/services/forge-service.ts index b0bf9c2a1..7f6d36465 100644 --- a/packages/server/src/services/forge-service.ts +++ b/packages/server/src/services/forge-service.ts @@ -271,10 +271,10 @@ export type GetCheckDetailsOptions = { checkRunId?: number; workflowRunId?: number; /** - * GitLab-only: the change request iid. GitLab routes the fetch to the change - * request's head pipeline (`glab ci get --merge-request`), so a fork/detached - * MR pipeline living in the source project resolves instead of 404ing against - * the checkout's target project. GitHub ignores it. + * Change request number used when check details must resolve against a + * specific request rather than the current branch. GitLab routes the fetch to + * the MR's head pipeline; Gitea-family adapters resolve the PR head SHA by + * number, including for terminal PRs. GitHub ignores it. */ changeRequestNumber?: number; } & ForgeReadOptions; @@ -456,6 +456,7 @@ export interface ForgeService { options: { cwd: string; headRef: string; + headSha?: string; headRepositoryOwner?: string; } & ForgeReadOptions, ): Promise; @@ -496,6 +497,7 @@ export interface ForgeService { retainCurrentPullRequestStatusPoll?(options: { cwd: string; headRef: string; + headSha?: string; headRepositoryOwner?: string; onStatus?: (status: CurrentPullRequestStatus | null) => void; onError?: (error: unknown) => void; diff --git a/packages/server/src/services/gitea-service.test.ts b/packages/server/src/services/gitea-service.test.ts index 51e97b88f..6b2028e66 100644 --- a/packages/server/src/services/gitea-service.test.ts +++ b/packages/server/src/services/gitea-service.test.ts @@ -74,6 +74,35 @@ const CONFLICTING_PR = { ci: "failure", }; +function currentPullRequestApi(input: { + number: number; + state: "open" | "closed"; + headRef: string; + headSha: string; + merged?: boolean; +}) { + return { + number: input.number, + html_url: `https://gitea.com/example-user/sample-repo/pulls/${input.number}`, + title: "Historical pull request", + body: "", + state: input.state, + merged: input.merged ?? false, + mergeable: true, + updated_at: "2026-06-26T10:00:00Z", + labels: [], + head: { + ref: input.headRef, + sha: input.headSha, + repo: { id: 1, owner: { login: "example-user" } }, + }, + base: { + ref: "main", + repo: { id: 1, owner: { login: "example-user" } }, + }, + }; +} + const STATUS_PR_VIEW = { id: 161482, index: 5, @@ -1048,6 +1077,43 @@ describe("createGiteaService", () => { ]); }); + it("resolves terminal PR check details by explicit change request number", async () => { + const headSha = "8888888888888888888888888888888888888888"; + const terminalView = { + ...STATUS_PR_VIEW, + index: 8, + state: "closed", + head: "feat/recently-closed", + headSha, + hasMerged: true, + mergedAt: "2026-06-28T17:00:00Z", + }; + const { service, calls } = makeService( + (args) => { + if (args[0] === "pr" && args[1] === "8") return ok(JSON.stringify(terminalView)); + if (args[0] === "api" && args[1].endsWith(`/commits/${headSha}/status`)) { + return ok(JSON.stringify(SAMPLE_COMBINED_STATUS)); + } + throw new Error(`unexpected call: ${args.join(" ")}`); + }, + { resolveCurrentBranch: async () => "feat/recently-closed" }, + ); + + const details = await service.getCheckDetails({ + cwd: "/repo", + repoOwner: "example-user", + repoName: "sample-repo", + checkRunId: 2, + changeRequestNumber: 8, + }); + + expect(details).toMatchObject({ checkRunId: 2, name: "ci/lint", status: "pending" }); + expect(calls).toEqual([ + ["pr", "8", "-o", "json"], + ["api", `repos/example-user/sample-repo/commits/${headSha}/status`], + ]); + }); + it("resolves Gitea Actions check details addressed only by workflowRunId", async () => { const { service } = makeService( (args) => { @@ -1172,6 +1238,9 @@ describe("createGiteaService", () => { const { service, calls } = makeService( (args) => { if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([])); + if (args[0] === "api" && args[1].includes("/pulls?state=all")) { + return ok(JSON.stringify([])); + } throw new Error(`unexpected call: ${args.join(" ")}`); }, { @@ -1187,13 +1256,9 @@ describe("createGiteaService", () => { checkRunId: 2, }), ).rejects.toThrow("Gitea pull request for branch feat/nonexistent was not found"); - expect( - calls - .filter((args) => args[0] === "pr" && args[1] === "list") - .map((args) => [argValue(args, "--state"), argValue(args, "--page")]), - ).toEqual([ - ["open", "1"], - ["all", undefined], + expect(calls[1]).toEqual([ + "api", + "repos/example-user/sample-repo/pulls?state=all&sort=recentupdate&page=1&limit=50", ]); }); @@ -1337,7 +1402,7 @@ describe("createGiteaService", () => { expect(status?.checksStatus).toBe("failure"); }); - it("finds the current branch PR on the second open pull-request page", async () => { + it("finds the open current-branch PR even when its remote head SHA differs", async () => { const firstPage = Array.from({ length: 50 }, (_, index) => ({ ...OPEN_PR, index: String(100 + index), @@ -1367,6 +1432,7 @@ describe("createGiteaService", () => { const status = await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feat/sample-change", + headSha: "9999999999999999999999999999999999999999", }); expect(status?.number).toBe(5); @@ -1381,18 +1447,18 @@ describe("createGiteaService", () => { }); it("falls back to the recent all-state PR window for a recently closed current branch PR", async () => { - const closedPr = { - ...OPEN_PR, - index: "8", + const headSha = "8888888888888888888888888888888888888888"; + const closedPr = currentPullRequestApi({ + number: 8, state: "closed", - url: "https://gitea.com/example-user/sample-repo/pulls/8", - head: "feat/recently-closed", - }; + headRef: "feat/recently-closed", + headSha, + }); const { service, calls } = makeService((args) => { if (args[0] === "pr" && args[1] === "list" && argValue(args, "--state") === "open") { return ok(JSON.stringify([])); } - if (args[0] === "pr" && args[1] === "list" && argValue(args, "--state") === "all") { + if (args[0] === "api" && args[1].includes("/pulls?state=all")) { return ok(JSON.stringify([closedPr])); } throw new Error(`unexpected call: ${args.join(" ")}`); @@ -1401,22 +1467,79 @@ describe("createGiteaService", () => { const status = await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feat/recently-closed", + headSha, }); expect(status).toMatchObject({ number: 8, headRefName: "feat/recently-closed" }); - expect( - calls - .filter((args) => args[0] === "pr" && args[1] === "list") - .map((args) => [argValue(args, "--state"), argValue(args, "--page")]), - ).toEqual([ - ["open", "1"], - ["all", undefined], + expect(calls[1]).toEqual([ + "api", + "repos/example-user/sample-repo/pulls?state=all&sort=recentupdate&page=1&limit=50", ]); }); + it("uses tea repository context when origin identity is unavailable", async () => { + const headSha = "8888888888888888888888888888888888888888"; + const closedPr = currentPullRequestApi({ + number: 8, + state: "closed", + headRef: "feat/recently-closed", + headSha, + }); + const { service, calls } = makeService( + (args) => { + if (args[0] === "pr" && args[1] === "list") return ok("[]"); + if (args[0] === "api" && args[1].startsWith("repos/{owner}/{repo}/pulls?")) { + return ok(JSON.stringify([closedPr])); + } + throw new Error(`unexpected call: ${args.join(" ")}`); + }, + { resolveRemoteUrl: async () => null }, + ); + + const status = await service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "feat/recently-closed", + headSha, + }); + + expect(status).toMatchObject({ number: 8, headRefName: "feat/recently-closed" }); + expect(calls[1]).toEqual([ + "api", + "repos/{owner}/{repo}/pulls?state=all&sort=recentupdate&page=1&limit=50", + ]); + }); + + it("does not attach a stale Gitea-family PR after a same-name branch advances", async () => { + const stale = currentPullRequestApi({ + number: 8, + state: "closed", + headRef: "dev", + headSha: "1111111111111111111111111111111111111111", + merged: true, + }); + const { service } = makeService((args) => { + if (args[0] === "pr" && args[1] === "list") return ok("[]"); + if (args[0] === "api" && args[1].includes("/pulls?state=all")) { + return ok(JSON.stringify([stale])); + } + throw new Error(`unexpected call: ${args.join(" ")}`); + }); + + await expect( + service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "dev", + headSha: "2222222222222222222222222222222222222222", + }), + ).resolves.toBeNull(); + }); + it("returns null when no PR matches the current branch", async () => { const { service, calls } = makeService((args) => { if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([])); + if (args[0] === "api" && args[1].includes("/pulls?state=all")) { + return ok(JSON.stringify([])); + } throw new Error(`unexpected call: ${args.join(" ")}`); }); @@ -1426,13 +1549,9 @@ describe("createGiteaService", () => { }); expect(status).toBeNull(); - expect( - calls - .filter((args) => args[0] === "pr" && args[1] === "list") - .map((args) => [argValue(args, "--state"), argValue(args, "--page")]), - ).toEqual([ - ["open", "1"], - ["all", undefined], + expect(calls[1]).toEqual([ + "api", + "repos/example-user/sample-repo/pulls?state=all&sort=recentupdate&page=1&limit=50", ]); }); diff --git a/packages/server/src/services/gitea-service.ts b/packages/server/src/services/gitea-service.ts index 372a62810..2d467a26e 100644 --- a/packages/server/src/services/gitea-service.ts +++ b/packages/server/src/services/gitea-service.ts @@ -234,6 +234,36 @@ const GiteaPullRequestApiSchema = z }) .passthrough(); +const GiteaCurrentPullRequestApiSchema = z + .object({ + number: z.number(), + html_url: z.string(), + title: z.string(), + body: z.string().nullable().optional(), + state: z.string(), + merged: z.boolean().optional(), + mergeable: z.boolean().nullable().optional(), + updated_at: z.string().optional(), + labels: z + .array(z.object({ name: z.string() }).passthrough()) + .optional() + .default([]), + head: z + .object({ + ref: z.string(), + sha: z.string(), + repo: GiteaPullRequestRepoSchema.nullable().optional(), + }) + .passthrough(), + base: z + .object({ + ref: z.string(), + repo: GiteaPullRequestRepoSchema.nullable().optional(), + }) + .passthrough(), + }) + .passthrough(); + const GiteaCommitStatusSchema = z .object({ id: z.number(), @@ -324,6 +354,7 @@ const GiteaReviewCommentSchema = z type GiteaPrListItem = z.infer; type GiteaIssueListItem = z.infer; type GiteaPullRequestView = z.infer; +type GiteaCurrentPullRequestApi = z.infer; type GiteaCommitStatus = z.infer; type GiteaCombinedCommitStatus = z.infer; type GiteaActionsRun = z.infer; @@ -801,6 +832,25 @@ function parseGiteaRepoFromUrl(url: string): { owner?: string; name?: string } { return {}; } +function currentPullRequestApiToListItem(item: GiteaCurrentPullRequestApi): GiteaPrListItem { + const headOwner = item.head.repo?.owner?.login; + const baseOwner = item.base.repo?.owner?.login; + const head = + headOwner && headOwner !== baseOwner ? `${headOwner}:${item.head.ref}` : item.head.ref; + return { + index: String(item.number), + state: item.merged ? "merged" : item.state, + url: item.html_url, + title: item.title, + body: item.body ?? "", + ...(item.mergeable != null ? { mergeable: String(item.mergeable) } : {}), + base: item.base.ref, + head, + updated: item.updated_at, + labels: item.labels.map((label) => label.name).join(","), + }; +} + function toPullRequestSummary(item: GiteaPrListItem): PullRequestSummary { const { owner, name } = parseGiteaRepoFromUrl(item.url); return { @@ -1347,6 +1397,7 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For async function loadCurrentPullRequestStatus(input: { cwd: string; headRef: string; + headSha?: string; }): Promise { const match = await findCurrentPullRequestForHeadRef(input); if (!match) { @@ -1356,7 +1407,9 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For return loadCurrentPullRequestChecks(input.cwd, match, status); } - async function resolveExpectedHeadOwner(cwd: string): Promise { + async function resolveCurrentRepoIdentity( + cwd: string, + ): Promise<{ owner: string; name: string } | null> { const remoteUrl = await resolveRemoteUrl(cwd); if (!remoteUrl) { return null; @@ -1365,34 +1418,60 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For if (!location) { return null; } - return parseGitHubRemoteIdentity(location.path)?.owner ?? null; + const identity = parseGitHubRemoteIdentity(location.path); + return identity ? { owner: identity.owner, name: identity.name } : null; } async function findCurrentPullRequestForHeadRef(input: { cwd: string; headRef: string; + headSha?: string; }): Promise { - const expectedHeadOwner = await resolveExpectedHeadOwner(input.cwd); + const repoIdentity = await resolveCurrentRepoIdentity(input.cwd); + const expectedHeadOwner = repoIdentity?.owner ?? null; const openMatch = await findOpenPullRequestForHeadRef(input, expectedHeadOwner); if (openMatch) { return openMatch; } - const recentItems = await listPullRequestItems({ - cwd: input.cwd, - state: "all", - limit: CURRENT_PR_LOOKUP_PAGE_SIZE, - }); - return ( - recentItems.find((item) => matchesCurrentHeadRef(item, input.headRef, expectedHeadOwner)) ?? - null + // tea discovers repository context from any configured git remote, while + // the default remote resolver reads origin only. Preserve that broader + // discovery for repositories whose primary remote has another name. + const repoPath = repoIdentity + ? `repos/${encodeURIComponent(repoIdentity.owner)}/${encodeURIComponent(repoIdentity.name)}` + : "repos/{owner}/{repo}"; + const recentItems = await runJsonArray( + [ + "api", + `${repoPath}/pulls?state=all&sort=recentupdate&page=1&limit=${CURRENT_PR_LOOKUP_PAGE_SIZE}`, + ], + { cwd: input.cwd }, + GiteaCurrentPullRequestApiSchema, ); + const candidates = recentItems.filter((item) => + matchesCurrentHeadRef( + currentPullRequestApiToListItem(item), + input.headRef, + expectedHeadOwner, + ), + ); + const match = + candidates.find((item) => mapGiteaState(item.state) === "open") ?? + candidates.find( + (item) => + mapGiteaState(item.state) !== "open" && + input.headSha !== undefined && + item.head.sha === input.headSha, + ) ?? + null; + return match ? currentPullRequestApiToListItem(match) : null; } async function findOpenPullRequestForHeadRef( input: { cwd: string; headRef: string; + headSha?: string; }, expectedHeadOwner: string | null, ): Promise { @@ -1668,7 +1747,11 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For }, getCurrentPullRequestStatus(input): Promise { - return loadCurrentPullRequestStatus({ cwd: input.cwd, headRef: input.headRef }); + return loadCurrentPullRequestStatus({ + cwd: input.cwd, + headRef: input.headRef, + headSha: input.headSha, + }); }, async getPullRequest(input: GetPullRequestOptions): Promise { diff --git a/packages/server/src/services/github-service.test.ts b/packages/server/src/services/github-service.test.ts index 0b3fa2787..d82defec0 100644 --- a/packages/server/src/services/github-service.test.ts +++ b/packages/server/src/services/github-service.test.ts @@ -21,7 +21,7 @@ const EXPECTED_GITHUB_FAST_POLL_MS = 20_000; const EXPECTED_GITHUB_SLOW_POLL_MS = 120_000; const EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS = 300_000; const CURRENT_PR_STATUS_BASE_FIELDS = - "number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,reviewDecision,mergeable,headRepositoryOwner"; + "number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,mergedAt,reviewDecision,mergeable,headRepositoryOwner"; const CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`; interface RunnerCall { @@ -207,6 +207,7 @@ function currentPullRequestJson(overrides: Record = {}): string isDraft: false, baseRefName: "main", headRefName: "feature/fork", + headRefOid: "1111111111111111111111111111111111111111", mergedAt: null, statusCheckRollup: [], reviewDecision: "REVIEW_REQUIRED", @@ -2246,6 +2247,26 @@ describe("ForgeService", () => { expect(status?.mergeable).toBe("UNKNOWN"); }); + it("keeps an open PR when its remote head SHA differs from the checkout HEAD", async () => { + const runner = createRunner([ + currentPullRequestJson({ + headRefOid: "1111111111111111111111111111111111111111", + }), + ]); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + }); + + const status = await service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "feature/fork", + headSha: "2222222222222222222222222222222222222222", + }); + + expect(status).toMatchObject({ number: 42, state: "open" }); + }); + it("loads GitHub merge, auto-merge, permission, policy, and queue facts for PR 993 shape", async () => { const runner = createScriptedRunner([ currentPullRequestJson({ @@ -2313,6 +2334,100 @@ describe("ForgeService", () => { }); }); + it("keeps a merged PR only when headRefOid matches the checkout HEAD", async () => { + const headSha = "2222222222222222222222222222222222222222"; + const runner = createRunner([ + currentPullRequestJson({ + state: "MERGED", + mergedAt: "2026-07-17T12:00:00Z", + headRefOid: headSha, + }), + ]); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + }); + + const status = await service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "feature/fork", + headSha, + }); + + expect(status?.number).toBe(42); + expect(status?.state).toBe("merged"); + }); + + it("does not attach a stale merged PR after a same-name branch advances", async () => { + const stale = currentPullRequestJson({ + state: "MERGED", + mergedAt: "2026-07-17T12:00:00Z", + headRefOid: "1111111111111111111111111111111111111111", + }); + const runner = createScriptedRunner([ + stale, + JSON.stringify({ owner: { login: "parentOwner" }, name: "parentRepo", parent: null }), + JSON.stringify([JSON.parse(stale)]), + ]); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + }); + + await expect( + service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "feature/fork", + headSha: "2222222222222222222222222222222222222222", + }), + ).resolves.toBeNull(); + expect(runner.calls.some((call) => call.args[0] === "pr" && call.args[1] === "list")).toBe( + true, + ); + }); + + it("prefers an open PR over an exact-SHA merged PR for the same head", async () => { + const checkoutSha = "2222222222222222222222222222222222222222"; + const owner = { login: "forkOwner" }; + const staleView = currentPullRequestJson({ + state: "MERGED", + mergedAt: "2026-07-15T12:00:00Z", + headRefOid: "0000000000000000000000000000000000000000", + headRepositoryOwner: owner, + }); + const open = JSON.parse( + currentPullRequestJson({ + number: 43, + state: "OPEN", + headRefOid: "3333333333333333333333333333333333333333", + headRepositoryOwner: owner, + }), + ); + const exactMerged = JSON.parse( + currentPullRequestJson({ + number: 42, + state: "MERGED", + mergedAt: "2026-07-16T12:00:00Z", + headRefOid: checkoutSha, + headRepositoryOwner: owner, + }), + ); + const runner = createScriptedRunner([staleView, JSON.stringify([exactMerged, open])]); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + }); + + const status = await service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "feature/fork", + headSha: checkoutSha, + headRepositoryOwner: "forkOwner", + }); + + expect(status).toMatchObject({ number: 43, state: "open" }); + }); + it("resolves fork PR heads to the parent repository when gh pr view returns a stale branch match", async () => { const runner = createScriptedRunner([ currentPullRequestJson({ @@ -2541,6 +2656,18 @@ describe("ForgeService", () => { expect(calls.map((call) => call.args)).toEqual([ ["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS], ["repo", "view", "--json", "owner,name,parent"], + [ + "pr", + "list", + "--state", + "all", + "--head", + "main", + "--limit", + "10", + "--json", + CURRENT_PR_STATUS_FIELDS, + ], ]); }); diff --git a/packages/server/src/services/github-service.ts b/packages/server/src/services/github-service.ts index 163fb6d30..e2d82db88 100644 --- a/packages/server/src/services/github-service.ts +++ b/packages/server/src/services/github-service.ts @@ -325,6 +325,7 @@ const CurrentPullRequestStatusSchema = z.object({ isDraft: z.boolean().optional().catch(false), baseRefName: z.string().catch(""), headRefName: z.string().catch(""), + headRefOid: z.string().optional(), mergedAt: z.string().nullable().optional(), statusCheckRollup: z.unknown().optional(), reviewDecision: z.unknown().optional(), @@ -493,7 +494,7 @@ query PullRequestCheckoutTarget($owner: String!, $name: String!, $number: Int!) }`; const CURRENT_PR_STATUS_BASE_FIELDS = - "number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,reviewDecision,mergeable,headRepositoryOwner"; + "number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,mergedAt,reviewDecision,mergeable,headRepositoryOwner"; const CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`; const PULL_REQUEST_STATUS_FACTS_QUERY = ` @@ -716,6 +717,7 @@ interface InFlightCacheEntry { interface GitHubPollTarget { cwd: string; headRef: string; + headSha?: string; headRepositoryOwner?: string; retainCount: number; timer: NodeJS.Timeout | null; @@ -727,6 +729,7 @@ interface GitHubPollTarget { interface ResolvedPullRequestCandidate { status: CurrentPullRequestStatus; + headSha?: string; headRepositoryOwner?: string; } @@ -868,6 +871,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G function getPollTargetKey(target: { cwd: string; headRef: string; + headSha?: string; headRepositoryOwner?: string; }): string { return buildCacheKey({ @@ -875,6 +879,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G method: "getCurrentPullRequestStatus", args: { headRef: target.headRef, + headSha: target.headSha, headRepositoryOwner: target.headRepositoryOwner, }, }); @@ -883,6 +888,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G function updatePollTargetAfterSuccess(update: { cwd: string; headRef: string; + headSha?: string; headRepositoryOwner?: string; status: CurrentPullRequestStatus | null; notify: boolean; @@ -932,6 +938,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G await api.getCurrentPullRequestStatus({ cwd: target.cwd, headRef: target.headRef, + headSha: target.headSha, headRepositoryOwner: target.headRepositoryOwner, reason: "self-heal-github", }); @@ -1095,6 +1102,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G method: "getCurrentPullRequestStatus", args: { headRef: input.headRef, + headSha: input.headSha, headRepositoryOwner: input.headRepositoryOwner, }, readOptions: input, @@ -1102,6 +1110,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G const status = await resolveCurrentPullRequestView({ cwd: input.cwd, headRef: input.headRef, + headSha: input.headSha, headRepositoryOwner: input.headRepositoryOwner, run, }); @@ -1111,6 +1120,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G updatePollTargetAfterSuccess({ cwd: input.cwd, headRef: input.headRef, + headSha: input.headSha, headRepositoryOwner: input.headRepositoryOwner, status, notify: input.reason === "self-heal-github", @@ -1502,6 +1512,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G target = { cwd: input.cwd, headRef: input.headRef, + headSha: input.headSha, headRepositoryOwner: input.headRepositoryOwner, retainCount: 0, timer: null, @@ -1869,6 +1880,7 @@ function isStatusCheckRollupPermissionError(error: unknown): boolean { async function resolveCurrentPullRequestView(options: { cwd: string; headRef: string; + headSha?: string; headRepositoryOwner?: string; run: (args: string[], options: GitHubCommandRunnerOptions) => Promise; }): Promise { @@ -1877,6 +1889,7 @@ async function resolveCurrentPullRequestView(options: { ? pickPullRequestCandidate({ candidates: [viewCandidate], headRef: options.headRef, + headSha: options.headSha, headRepositoryOwner: options.headRepositoryOwner, }) : null; @@ -1893,12 +1906,13 @@ async function resolveCurrentPullRequestView(options: { const forkOwner = repo?.owner?.login; const parentOwner = repo?.parent?.owner?.login; const parentName = repo?.parent?.name; - if (!forkOwner || !parentOwner || !parentName) { + if (!forkOwner) { return null; } - - listHeadRef = `${forkOwner}:${options.headRef}`; - listRepo = `${parentOwner}/${parentName}`; + if (parentOwner && parentName) { + listHeadRef = `${forkOwner}:${options.headRef}`; + listRepo = `${parentOwner}/${parentName}`; + } headRepositoryOwner = forkOwner; } @@ -1911,6 +1925,7 @@ async function resolveCurrentPullRequestView(options: { const match = pickPullRequestCandidate({ candidates, headRef: options.headRef, + headSha: options.headSha, headRepositoryOwner, }); return match?.status ?? null; @@ -2173,8 +2188,10 @@ function toCurrentPullRequestCandidate( return null; } const headRepositoryOwner = item.headRepositoryOwner?.login; + const headSha = item.headRefOid; return { status, + ...(headSha ? { headSha } : {}), ...(headRepositoryOwner ? { headRepositoryOwner } : {}), }; } @@ -2190,26 +2207,45 @@ function hasResolvedRepoIdentity(status: CurrentPullRequestStatus): boolean { function pickPullRequestCandidate(options: { candidates: ResolvedPullRequestCandidate[]; headRef: string; + headSha?: string; headRepositoryOwner?: string; }): ResolvedPullRequestCandidate | null { const matching = options.candidates.filter((candidate) => { if (!isCandidateForHeadRef(candidate, options.headRef)) { return false; } + if ( + candidate.status.state !== "open" && + (!options.headSha || candidate.headSha !== options.headSha) + ) { + return false; + } if (!options.headRepositoryOwner) { return true; } return candidate.headRepositoryOwner === options.headRepositoryOwner; }); - matching.sort(comparePullRequestCandidatePreference); + matching.sort((left, right) => + comparePullRequestCandidatePreference(left, right, options.headSha), + ); return matching[0] ?? null; } function comparePullRequestCandidatePreference( left: ResolvedPullRequestCandidate, right: ResolvedPullRequestCandidate, + headSha?: string, ): number { - return getPullRequestStateRank(left.status) - getPullRequestStateRank(right.status); + const stateRank = getPullRequestStateRank(left.status) - getPullRequestStateRank(right.status); + if (stateRank !== 0) { + return stateRank; + } + const leftExact = headSha !== undefined && left.headSha === headSha; + const rightExact = headSha !== undefined && right.headSha === headSha; + if (leftExact !== rightExact) { + return leftExact ? -1 : 1; + } + return 0; } function getPullRequestStateRank(status: CurrentPullRequestStatus): number { diff --git a/packages/server/src/services/gitlab-service.test.ts b/packages/server/src/services/gitlab-service.test.ts index dc9cc5294..3f930a720 100644 --- a/packages/server/src/services/gitlab-service.test.ts +++ b/packages/server/src/services/gitlab-service.test.ts @@ -33,6 +33,24 @@ function makeService(responder: Responder, overrides: Partial = {}, ): PullRequestCommandStatus { @@ -60,6 +78,7 @@ const OPEN_MR = { state: "opened", source_branch: "release/v0.4.0", target_branch: "main", + sha: "1111111111111111111111111111111111111111", source_project_id: 101, target_project_id: 101, draft: false, @@ -231,7 +250,9 @@ const DISCUSSIONS = [ describe("createGitLabService", () => { it("maps a glab merge request view to the neutral current PR status", async () => { - const { service, calls } = makeService(() => ok(JSON.stringify(OPEN_MR))); + const { service, calls } = makeService((args) => + ok(JSON.stringify(args[1] === "list" ? [OPEN_MR] : OPEN_MR)), + ); const status = await service.getCurrentPullRequestStatus({ cwd: "/repo", @@ -261,23 +282,26 @@ describe("createGitLabService", () => { pipelineStatus: "success", mergeWhenPipelineSucceeds: false, }); - expect(calls[0]).toEqual(["mr", "view", "release/v0.4.0", "-F", "json"]); + expect(calls[0]).toEqual(currentMrListArgs("release/v0.4.0")); + expect(calls[1]).toEqual(["mr", "view", "14", "-F", "json"]); }); it("reports a conflicting merge request as CONFLICTING", async () => { - const { service } = makeService(() => - ok( - JSON.stringify({ ...OPEN_MR, has_conflicts: true, detailed_merge_status: "broken_status" }), - ), + const conflicting = { + ...OPEN_MR, + source_branch: "x", + has_conflicts: true, + detailed_merge_status: "broken_status", + }; + const { service } = makeService((args) => + ok(JSON.stringify(args[1] === "list" ? [conflicting] : conflicting)), ); const status = await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "x" }); expect(status?.mergeable).toBe("CONFLICTING"); }); it("returns null when no merge request exists for the branch", async () => { - const { service } = makeService(() => { - throw { code: 1, stderr: "no open merge request available for 'feature/x'" }; - }); + const { service } = makeService(() => ok("[]")); const status = await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/x", @@ -285,6 +309,61 @@ describe("createGitLabService", () => { expect(status).toBeNull(); }); + it("selects a terminal merge request only when its head SHA matches the checkout", async () => { + const branch = "dev"; + const checkoutSha = "2222222222222222222222222222222222222222"; + const newestStale = { + ...OPEN_MR, + iid: 271, + state: "merged", + source_branch: branch, + sha: "1111111111111111111111111111111111111111", + updated_at: "2026-07-17T12:00:00.000Z", + }; + const exactOlder = { + ...OPEN_MR, + iid: 270, + state: "merged", + source_branch: branch, + sha: checkoutSha, + updated_at: "2026-07-16T12:00:00.000Z", + }; + const { service, calls } = makeService((args) => { + if (args[1] === "list") return ok(JSON.stringify([newestStale, exactOlder])); + if (args[1] === "view" && args[2] === "270") return ok(JSON.stringify(exactOlder)); + if (args[0] === "api") return ok("{}"); + throw new Error(`unexpected call: ${args.join(" ")}`); + }); + + const status = await service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: branch, + headSha: checkoutSha, + }); + + expect(status?.number).toBe(270); + expect(calls[1]).toEqual(["mr", "view", "270", "-F", "json"]); + }); + + it("does not attach the latest historical merge request after a reused branch advances", async () => { + const stale = { + ...OPEN_MR, + state: "merged", + source_branch: "dev", + sha: "1111111111111111111111111111111111111111", + }; + const { service, calls } = makeService(() => ok(JSON.stringify([stale]))); + + await expect( + service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "dev", + headSha: "2222222222222222222222222222222222222222", + }), + ).resolves.toBeNull(); + expect(calls).toEqual([currentMrListArgs("dev")]); + }); + it("looks up a numeric current branch through the source-branch list filter", async () => { const numericBranchMr = { ...OPEN_MR, @@ -313,6 +392,7 @@ describe("createGitLabService", () => { const status = await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "1234", + headSha: "2222222222222222222222222222222222222222", }); expect(status).toMatchObject({ @@ -320,7 +400,7 @@ describe("createGitLabService", () => { title: "Fix numeric branch", headRefName: "1234", }); - expect(calls[0]).toEqual(["mr", "list", "--source-branch", "1234", "-F", "json"]); + expect(calls[0]).toEqual(currentMrListArgs("1234")); expect(calls[1]).toEqual(["mr", "view", "21", "-F", "json"]); expect(calls).not.toContainEqual(["mr", "view", "1234", "-F", "json"]); }); @@ -342,7 +422,7 @@ describe("createGitLabService", () => { }); expect(status).toBeNull(); - expect(calls).toEqual([["mr", "list", "--source-branch", "1234", "-F", "json"]]); + expect(calls).toEqual([currentMrListArgs("1234")]); }); it("lists merge requests as neutral PR summaries", async () => { @@ -549,17 +629,16 @@ describe("createGitLabService", () => { }); it("surfaces the head pipeline id and url on the gitlab status facts", async () => { - const { service } = makeService(() => - ok( - JSON.stringify({ - ...OPEN_MR, - head_pipeline: { - id: 306, - status: "running", - web_url: "https://gitlab.example.com/example-group/example-project/-/pipelines/306", - }, - }), - ), + const pipelineMr = { + ...OPEN_MR, + head_pipeline: { + id: 306, + status: "running", + web_url: "https://gitlab.example.com/example-group/example-project/-/pipelines/306", + }, + }; + const { service } = makeService((args) => + ok(JSON.stringify(args[1] === "list" ? [pipelineMr] : pipelineMr)), ); const status = await service.getCurrentPullRequestStatus({ @@ -682,6 +761,7 @@ describe("createGitLabService", () => { it("populates approval counts from the approvals endpoint", async () => { const { service, calls } = makeService((args) => { + if (args[0] === "mr" && args[1] === "list") return ok(JSON.stringify([OPEN_MR])); if (args[0] === "mr" && args[1] === "view") return ok(JSON.stringify(OPEN_MR)); if (args[0] === "api" && args[1].endsWith("/approvals")) return ok(JSON.stringify(APPROVALS)); throw new Error(`unexpected call: ${args.join(" ")}`); @@ -697,7 +777,7 @@ describe("createGitLabService", () => { approvalsRequired: 2, approvalsGiven: 1, }); - expect(calls[1]).toEqual([ + expect(calls[2]).toEqual([ "api", "projects/example-group%2Fexample-project/merge_requests/14/approvals", ]); @@ -705,6 +785,7 @@ describe("createGitLabService", () => { it("falls back to zero approvals when the approvals endpoint returns an error", async () => { const { service } = makeService((args) => { + if (args[0] === "mr" && args[1] === "list") return ok(JSON.stringify([OPEN_MR])); if (args[0] === "mr" && args[1] === "view") return ok(JSON.stringify(OPEN_MR)); throw { code: 1, stderr: "500 Internal Server Error" }; }); diff --git a/packages/server/src/services/gitlab-service.ts b/packages/server/src/services/gitlab-service.ts index 204c99dab..dae4cb2b4 100644 --- a/packages/server/src/services/gitlab-service.ts +++ b/packages/server/src/services/gitlab-service.ts @@ -149,6 +149,7 @@ const GitLabMergeRequestSchema = z state: z.string(), source_branch: z.string(), target_branch: z.string(), + sha: z.string().optional(), source_project_id: z.number().nullable().optional(), target_project_id: z.number().nullable().optional(), draft: z.boolean().optional(), @@ -861,16 +862,26 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F return runJson(["mr", "view", ref, "-F", "json"], { cwd }, GitLabMergeRequestSchema); } - function isNumericBranchRef(ref: string): boolean { - return /^[0-9]+$/.test(ref); - } - - async function listOpenMergeRequestsBySourceBranch( + async function listMergeRequestsBySourceBranch( cwd: string, sourceBranch: string, ): Promise { return runJson( - ["mr", "list", "--source-branch", sourceBranch, "-F", "json"], + [ + "mr", + "list", + "--all", + "--source-branch", + sourceBranch, + "--order", + "updated_at", + "--sort", + "desc", + "--per-page", + "100", + "-F", + "json", + ], { cwd }, z.array(GitLabMergeRequestSchema), ); @@ -879,16 +890,17 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F async function resolveCurrentMergeRequest( cwd: string, headRef: string, + headSha?: string, ): Promise { - if (!isNumericBranchRef(headRef)) { - return viewMergeRequest(cwd, headRef); - } - - const mergeRequests = await listOpenMergeRequestsBySourceBranch(cwd, headRef); + const mergeRequests = await listMergeRequestsBySourceBranch(cwd, headRef); + const candidates = mergeRequests.filter((mr) => mr.source_branch === headRef); const match = - mergeRequests.find( - (mr) => mr.source_branch === headRef && mapMergeRequestState(mr.state) === "open", - ) ?? null; + candidates.find((mr) => mapMergeRequestState(mr.state) === "open") ?? + candidates.find( + (mr) => + mapMergeRequestState(mr.state) !== "open" && headSha !== undefined && mr.sha === headSha, + ) ?? + null; return match ? viewMergeRequest(cwd, String(match.iid)) : null; } @@ -1004,7 +1016,7 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F async getCurrentPullRequestStatus(input): Promise { try { - const mr = await resolveCurrentMergeRequest(input.cwd, input.headRef); + const mr = await resolveCurrentMergeRequest(input.cwd, input.headRef, input.headSha); if (!mr) { return null; } diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 6d1b2f238..c28586d8a 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -8,10 +8,12 @@ import { readFileSync, realpathSync, mkdirSync, + statSync, } from "fs"; import { join } from "path"; import { win32 } from "node:path"; import { tmpdir } from "os"; +import pino from "pino"; import { __resetCheckoutShortstatCacheForTests, __resetPullRequestStatusCacheForTests, @@ -46,7 +48,11 @@ import { startGitCommandMetrics, stopGitCommandMetrics } from "./run-git-command import { createForgeResolver } from "../services/forge-resolver.js"; import { GitHubCommandError, GitHubCliMissingError } from "../services/github-service.js"; import type { CurrentPullRequestStatus, ForgeService } from "../services/forge-service.js"; -import { TeaAuthenticationError, TeaCliMissingError } from "../services/gitea-service.js"; +import { + TeaAuthenticationError, + TeaCliMissingError, + TeaCommandError, +} from "../services/gitea-service.js"; import { createWorktree as createWorktreePrimitive, type CreateWorktreeOptions, @@ -159,6 +165,7 @@ function createPullRequestStatus(overrides?: Partial) interface RequestedPullRequestTarget { headRef: string; + headSha?: string; headRepositoryOwner?: string; } @@ -174,6 +181,7 @@ function createGitHubServiceRecordingPullRequestTargets( github.getCurrentPullRequestStatus = async (request) => { options.requestedTargets.push({ headRef: request.headRef, + ...(request.headSha ? { headSha: request.headSha } : {}), ...(request.headRepositoryOwner ? { headRepositoryOwner: request.headRepositoryOwner } : {}), }); return createPullRequestStatus({ @@ -307,6 +315,45 @@ describe("checkout git utilities", () => { }); }); + it("reports a real non-git directory as non-git", async () => { + const nonGitDir = join(tempDir, "not-git-status"); + mkdirSync(nonGitDir, { recursive: true }); + + await expect(getCheckoutStatus(nonGitDir)).resolves.toEqual({ isGit: false }); + }); + + it.runIf( + process.platform !== "win32" && + existsSync("/dev/shm") && + statSync("/dev").dev !== statSync("/dev/shm").dev, + )("warns and reports a non-git directory at a filesystem boundary as non-git", async () => { + const nonGitDir = realpathSync.native(mkdtempSync("/dev/shm/checkout-git-boundary-test-")); + const records: unknown[] = []; + const logger = pino( + { level: "warn" }, + { + write(line: string) { + records.push(JSON.parse(line)); + }, + }, + ); + try { + await expect(getCheckoutStatus(nonGitDir, { logger })).resolves.toEqual({ isGit: false }); + expect(records).toEqual([ + expect.objectContaining({ + level: 40, + cwd: nonGitDir, + msg: "Git worktree discovery failed; treating directory as non-Git", + err: expect.objectContaining({ + message: expect.stringContaining("Stopping at filesystem boundary"), + }), + }), + ]); + } finally { + rmSync(nonGitDir, { recursive: true, force: true }); + } + }); + it("returns null for getCurrentBranch in a repo with no commits", async () => { const emptyRepo = join(tempDir, "empty-repo"); mkdirSync(emptyRepo, { recursive: true }); @@ -2300,7 +2347,8 @@ const x = 1; const lookupTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome); - expect(lookupTarget).toEqual({ headRef: "refactor/workspace-scripts" }); + expect(lookupTarget).toMatchObject({ headRef: "refactor/workspace-scripts" }); + expect(lookupTarget?.headSha).toMatch(/^[0-9a-f]{40}$/); }); it("keeps the local branch lookup when origin tracking uses the same head name", async () => { @@ -2315,7 +2363,8 @@ const x = 1; const lookupTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome); - expect(lookupTarget).toEqual({ headRef: "feature" }); + expect(lookupTarget).toMatchObject({ headRef: "feature" }); + expect(lookupTarget?.headSha).toMatch(/^[0-9a-f]{40}$/); }); it("does not attach an owner when the tracked remote is the same GitHub repository", async () => { @@ -2337,7 +2386,8 @@ const x = 1; const lookupTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome); - expect(lookupTarget).toEqual({ headRef: "refactor/workspace-scripts" }); + expect(lookupTarget).toMatchObject({ headRef: "refactor/workspace-scripts" }); + expect(lookupTarget?.headSha).toMatch(/^[0-9a-f]{40}$/); }); it("keeps the fork owner when same-repo comparison is indeterminate", async () => { @@ -2355,7 +2405,8 @@ const x = 1; const lookupTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome); - expect(lookupTarget).toEqual({ headRef: "main", headRepositoryOwner: "chethanuk" }); + expect(lookupTarget).toMatchObject({ headRef: "main", headRepositoryOwner: "chethanuk" }); + expect(lookupTarget?.headSha).toMatch(/^[0-9a-f]{40}$/); }); it("uses the configured push remote for fork PR lookup when upstream is absent", async () => { @@ -2384,8 +2435,11 @@ const x = 1; ); expect(getBranchUpstream(repoDir)).toBeNull(); - expect(factsTarget).toEqual({ headRef: "main", headRepositoryOwner: "chethanuk" }); - expect(requestedTargets).toEqual([{ headRef: "main", headRepositoryOwner: "chethanuk" }]); + expect(factsTarget).toMatchObject({ headRef: "main", headRepositoryOwner: "chethanuk" }); + expect(factsTarget?.headSha).toMatch(/^[0-9a-f]{40}$/); + expect(requestedTargets).toEqual([ + expect.objectContaining({ headRef: "main", headRepositoryOwner: "chethanuk" }), + ]); }); it("keeps the local branch lookup when same-repo tracking points at the base branch", async () => { @@ -2400,7 +2454,8 @@ const x = 1; const lookupTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome); - expect(lookupTarget).toEqual({ headRef: "tender-parrot" }); + expect(lookupTarget).toMatchObject({ headRef: "tender-parrot" }); + expect(lookupTarget?.headSha).toMatch(/^[0-9a-f]{40}$/); }); it("derives the same origin tracked head for on-demand PR status reads", async () => { @@ -2454,7 +2509,9 @@ const x = 1; const status = await getPullRequestStatus(repoDir, github); - expect(requestedTargets).toEqual([{ headRef: "main", headRepositoryOwner: "chethanuk" }]); + expect(requestedTargets).toEqual([ + expect.objectContaining({ headRef: "main", headRepositoryOwner: "chethanuk" }), + ]); expect(status.status?.number).toBe(345); expect(status.status?.headRefName).toBe("main"); }); @@ -2503,6 +2560,33 @@ const x = 1; expect(callCount).toBe(1); }); + it("does not reuse a PR status cache entry after HEAD changes on the same branch", async () => { + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); + execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { + cwd: repoDir, + }); + + const requestedShas: string[] = []; + const github = createGitHubServiceForStatus(null); + github.getCurrentPullRequestStatus = async (options) => { + if (options.headSha) requestedShas.push(options.headSha); + return createPullRequestStatus({ + url: `https://github.com/getpaseo/paseo/pull/${requestedShas.length}`, + }); + }; + + const first = await getPullRequestStatus(repoDir, github); + writeFileSync(join(repoDir, "next.txt"), "next\n"); + execFileSync("git", ["add", "next.txt"], { cwd: repoDir }); + execFileSync("git", ["commit", "-m", "next commit"], { cwd: repoDir }); + const second = await getPullRequestStatus(repoDir, github); + + expect(first.status?.url).toContain("/pull/1"); + expect(second.status?.url).toContain("/pull/2"); + expect(requestedShas).toHaveLength(2); + expect(requestedShas[0]).not.toBe(requestedShas[1]); + }); + it("passes forced PR status reads through to the GitHub service", async () => { execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { @@ -2596,6 +2680,41 @@ const x = 1; } }); + it("keeps stale PR status when a Gitea-family refresh hits a transient command error", async () => { + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); + execFileSync("git", ["remote", "add", "origin", "https://gitea.example.com/acme/repo.git"], { + cwd: repoDir, + }); + + __setPullRequestStatusCacheTtlForTests(50); + try { + let callCount = 0; + const service = createGitHubServiceForStatus(null); + service.getCurrentPullRequestStatus = async () => { + callCount += 1; + if (callCount === 1) { + return createPullRequestStatus({ url: "https://gitea.example.com/acme/repo/pulls/7" }); + } + throw new TeaCommandError({ + args: ["pr", "list"], + cwd: repoDir, + exitCode: 1, + stderr: "request timed out", + }); + }; + + const fresh = await getPullRequestStatus(repoDir, service); + await sleep(80); + const stale = await getPullRequestStatus(repoDir, service); + + expect(stale).toEqual(fresh); + expect(stale.status?.url).toContain("/pulls/7"); + expect(callCount).toBe(2); + } finally { + __resetPullRequestStatusCacheForTests(); + } + }); + it("does not use stale PR status fallback for forced GitHub errors", async () => { execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index c680efcc5..1b0752ede 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -7,7 +7,7 @@ import type { Logger } from "pino"; import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js"; import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js"; import { parseGitHubRepoFromRemote } from "../server/workspace-git-metadata.js"; -import { GitHubCommandError, createGitHubService } from "../services/github-service.js"; +import { createGitHubService } from "../services/github-service.js"; import type { CurrentPullRequestStatus, ForgeAuthState, @@ -15,13 +15,18 @@ import type { ForgeSpecificStatusFacts, PullRequestMergeable, } from "../services/forge-service.js"; -import { ForgeAuthenticationError, ForgeCliMissingError } from "../services/forge-cli-command.js"; +import { + ForgeAuthenticationError, + ForgeCliMissingError, + ForgeCommandError, +} from "../services/forge-cli-command.js"; import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; import { runGitCommand } from "./run-git-command.js"; import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js"; import { type PaseoWorktreeMetadata, readPaseoWorktreeMetadata } from "./worktree-metadata.js"; const READ_ONLY_GIT_ENV = { GIT_OPTIONAL_LOCKS: "0", + LC_ALL: "C", } as const; /** @@ -66,6 +71,7 @@ interface CheckoutReadCacheOptions { interface PullRequestStatusLookupTarget { headRef: string; + headSha?: string; headRepositoryOwner?: string; } @@ -115,8 +121,8 @@ function createShortstatCache(ttlMs: number) { }); } -function getPullRequestStatusCacheKey(cwd: string): string { - return resolve(cwd); +function getPullRequestStatusCacheKey(cwd: string, headSha: string | null): string { + return `${resolve(cwd)}\u0000${headSha ?? ""}`; } function rememberPullRequestStatus(cacheKey: string, status: PullRequestStatusResult): void { @@ -779,7 +785,7 @@ export interface MergeFromBaseOptions { export interface CheckoutContext { paseoHome?: string; worktreesRoot?: string; - logger?: Pick; + logger?: Pick; facts?: CheckoutSnapshotFacts | null; } @@ -804,13 +810,6 @@ export type CheckoutSnapshotFacts = pullRequestLookupTarget: PullRequestStatusLookupTarget | null; }; -function isGitError(error: unknown): boolean { - if (!(error instanceof Error)) { - return false; - } - return /not a git repository/i.test(error.message) || /git repository/i.test(error.message); -} - async function requireGitRepo(cwd: string): Promise { try { await runGitCommand(["rev-parse", "--git-dir"], { cwd, envOverlay: READ_ONLY_GIT_ENV }); @@ -835,6 +834,38 @@ export async function getCurrentBranch(cwd: string): Promise { } } +async function getCurrentHeadSha(cwd: string, context?: CheckoutContext): Promise { + const knownSha = context?.facts?.isGit + ? context.facts.pullRequestLookupTarget?.headSha + : undefined; + if (knownSha) { + return knownSha; + } + try { + const { stdout } = await runGitCommand(["rev-parse", "HEAD"], { + cwd, + envOverlay: READ_ONLY_GIT_ENV, + logger: context?.logger, + }); + const sha = stdout.trim(); + return sha.length > 0 ? sha : null; + } catch { + return null; + } +} + +async function addHeadShaToPullRequestLookupTarget( + cwd: string, + target: PullRequestStatusLookupTarget | null, + context?: CheckoutContext, +): Promise { + if (!target) { + return null; + } + const headSha = await getCurrentHeadSha(cwd, context); + return headSha ? { ...target, headSha } : target; +} + async function getRebaseHeadBranch(cwd: string): Promise { const paths = ["rebase-merge/head-name", "rebase-apply/head-name"]; const results = await Promise.all( @@ -865,7 +896,12 @@ async function getWorktreeRoot(cwd: string, context?: CheckoutContext): Promise< logger: context?.logger, }); return parseGitRevParsePath(stdout); - } catch { + } catch (error) { + // Git discovery is fail-open: keep the directory usable as non-Git and retain the diagnostic. + context?.logger?.warn( + { err: error, cwd }, + "Git worktree discovery failed; treating directory as non-Git", + ); return null; } } @@ -1031,7 +1067,7 @@ async function getPaseoWorktreeForCwd( return { isPaseoOwnedWorktree: true, - worktreeRoot: knownWorktreeRoot ?? (await getWorktreeRoot(cwd)) ?? cwd, + worktreeRoot: knownWorktreeRoot ?? (await getWorktreeRoot(cwd, context)) ?? cwd, }; } @@ -1533,35 +1569,29 @@ async function inspectCheckoutContext( cwd: string, context?: CheckoutContext, ): Promise { - try { - const root = await getWorktreeRoot(cwd, context); - if (!root) { - return null; - } - - const [currentBranch, remoteUrl, absoluteGitDir, gitCommonDir, paseoWorktree] = - await Promise.all([ - getCurrentBranch(cwd), - getOriginRemoteUrl(cwd), - resolveAbsoluteGitDir(cwd), - resolveGitCommonDir(cwd), - getPaseoWorktreeForCwd(cwd, context, root), - ]); - - return { - worktreeRoot: root, - currentBranch, - remoteUrl, - absoluteGitDir, - gitCommonDir, - paseoWorktree, - }; - } catch (error) { - if (isGitError(error)) { - return null; - } - throw error; + const root = await getWorktreeRoot(cwd, context); + if (!root) { + return null; } + + const [currentBranch, remoteUrl, absoluteGitDir, gitCommonDir, paseoWorktree] = await Promise.all( + [ + getCurrentBranch(cwd), + getOriginRemoteUrl(cwd), + resolveAbsoluteGitDir(cwd), + resolveGitCommonDir(cwd), + getPaseoWorktreeForCwd(cwd, context, root), + ], + ); + + return { + worktreeRoot: root, + currentBranch, + remoteUrl, + absoluteGitDir, + gitCommonDir, + paseoWorktree, + }; } function buildPullRequestLookupTargetFromBranchConfig( @@ -1768,6 +1798,11 @@ export async function getCheckoutSnapshotFacts( context, )) ?? pullRequestLookupTarget; } + pullRequestLookupTarget = await addHeadShaToPullRequestLookupTarget( + cwd, + pullRequestLookupTarget, + context, + ); return { isGit: true, @@ -2974,7 +3009,7 @@ export async function mergeToBase( } let normalizedBaseRef = baseRef; normalizedBaseRef = normalizeLocalBranchRefName(normalizedBaseRef); - const currentWorktreeRoot = (await getWorktreeRoot(cwd)) ?? cwd; + const currentWorktreeRoot = (await getWorktreeRoot(cwd, context)) ?? cwd; if (normalizedBaseRef === currentBranch) { return currentWorktreeRoot; } @@ -3409,7 +3444,8 @@ export async function getPullRequestStatus( options?: CheckoutReadCacheOptions, context?: CheckoutContext, ): Promise { - const cacheKey = getPullRequestStatusCacheKey(cwd); + const headSha = await getCurrentHeadSha(cwd, context); + const cacheKey = getPullRequestStatusCacheKey(cwd, headSha); if (!options?.force) { const cached = pullRequestStatusCache.get(cacheKey); if (cached) { @@ -3422,14 +3458,14 @@ export async function getPullRequestStatus( } } - const lookup = getPullRequestStatusUncached(cwd, forgeService, options, context) + const lookup = getPullRequestStatusUncached(cwd, forgeService, options, context, headSha) .then((status) => { pullRequestStatusCache.set(cacheKey, status); rememberPullRequestStatus(cacheKey, status); return status; }) .catch((error) => { - if (!options?.force && error instanceof GitHubCommandError) { + if (!options?.force && error instanceof ForgeCommandError) { const stale = lastSuccessfulPullRequestStatus.get(cacheKey); if (stale) { return stale; @@ -3450,6 +3486,7 @@ async function getPullRequestStatusUncached( forgeService: ForgeService, options?: CheckoutReadCacheOptions, context?: CheckoutContext, + headSha?: string | null, ): Promise { if (context?.facts?.isGit === false) { return buildPullRequestStatusResult(null, "no_remote"); @@ -3462,7 +3499,11 @@ async function getPullRequestStatusUncached( return buildPullRequestStatusResult(null, "no_remote"); } try { - const lookupTarget = await resolvePullRequestStatusLookupTarget(cwd, head, context); + const resolvedLookupTarget = await resolvePullRequestStatusLookupTarget(cwd, head, context); + const lookupTarget = + headSha && !resolvedLookupTarget.headSha + ? { ...resolvedLookupTarget, headSha } + : resolvedLookupTarget; let status: CurrentPullRequestStatus | null; if (options?.force) { const reason = options.reason; diff --git a/packages/server/src/utils/path.test.ts b/packages/server/src/utils/path.test.ts index d84a1bf6a..fbdf509f5 100644 --- a/packages/server/src/utils/path.test.ts +++ b/packages/server/src/utils/path.test.ts @@ -1,6 +1,14 @@ +import { mkdtempSync, mkdirSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, test } from "vitest"; -import { areEquivalentPaths, createPathEquivalenceMatcher, isPathInsideRoot } from "./path.js"; +import { + areEquivalentPaths, + createPathEquivalenceMatcher, + getRealpathAwareRelativePath, + isPathInsideRoot, +} from "./path.js"; describe("path equivalence", () => { test.each([ @@ -33,4 +41,23 @@ describe("path equivalence", () => { false, ); }); + + test.skipIf(process.platform === "win32")( + "derives the contained suffix from a realpath-equivalent root", + () => { + const tempDir = mkdtempSync(join(tmpdir(), "paseo-path-")); + try { + const realRoot = join(tempDir, "real-root"); + const nestedPath = join(realRoot, "packages", "app"); + const aliasRoot = join(tempDir, "root-alias"); + mkdirSync(nestedPath, { recursive: true }); + symlinkSync(realRoot, aliasRoot, "dir"); + + expect(getRealpathAwareRelativePath(aliasRoot, nestedPath)).toBe(join("packages", "app")); + expect(getRealpathAwareRelativePath(aliasRoot, tempDir)).toBeNull(); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }, + ); }); diff --git a/packages/server/src/utils/path.ts b/packages/server/src/utils/path.ts index f4d87a92a..150bb184d 100644 --- a/packages/server/src/utils/path.ts +++ b/packages/server/src/utils/path.ts @@ -59,22 +59,44 @@ export function createRealpathAwarePathMatcher(target: string): (candidate: stri } export function isPathInsideRoot(root: string, candidate: string): boolean { + return getRelativePathInsideRoot(root, candidate) !== null; +} + +/** + * Returns the candidate's relative suffix when it is inside root. + * + * The suffix is derived from the same lexical path pair used to prove + * containment. Callers that map an existing filesystem path into a new root + * must keep those two operations coupled. + */ +export function getRealpathAwareRelativePath(root: string, candidate: string): string | null { + const rootVariants = collectPathVariants(root); + const candidateVariants = collectPathVariants(candidate); + + for (const rootVariant of rootVariants) { + for (const candidateVariant of candidateVariants) { + const relativePath = getRelativePathInsideRoot(rootVariant, candidateVariant); + if (relativePath !== null) return relativePath; + } + } + + return null; +} + +export function isRealpathInsideRoot(root: string, candidate: string): boolean { + return getRealpathAwareRelativePath(root, candidate) !== null; +} + +function getRelativePathInsideRoot(root: string, candidate: string): string | null { const compareAsWindows = shouldCompareAsWindows(root, candidate); const platformPath = compareAsWindows ? nodePath.win32 : nodePath.posix; const normalizedRoot = normalizePathForComparison(root, compareAsWindows); const normalizedCandidate = normalizePathForComparison(candidate, compareAsWindows); const relative = platformPath.relative(normalizedRoot, normalizedCandidate); - return relative === "" || (!relative.startsWith("..") && !platformPath.isAbsolute(relative)); -} - -export function isRealpathInsideRoot(root: string, candidate: string): boolean { - const rootVariants = collectPathVariants(root); - const candidateVariants = collectPathVariants(candidate); - - return rootVariants.some((rootVariant) => - candidateVariants.some((candidateVariant) => isPathInsideRoot(rootVariant, candidateVariant)), - ); + return relative === "" || (!relative.startsWith("..") && !platformPath.isAbsolute(relative)) + ? relative + : null; } function collectPathVariants(value: string): string[] { diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 0200c5a1c..4769bed7d 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -4,14 +4,24 @@ import { deriveWorktreeProjectHash, deletePaseoWorktree, isPaseoOwnedWorktreeCwd, + mapWorkspaceCwdToWorktree, slugify, type CreateWorktreeOptions, type WorktreeConfig, } from "./worktree"; import { execFileSync } from "child_process"; -import { mkdtempSync, mkdirSync, rmSync, existsSync, realpathSync, writeFileSync } from "fs"; +import { + mkdtempSync, + mkdirSync, + rmSync, + existsSync, + realpathSync, + symlinkSync, + writeFileSync, +} from "fs"; import { join } from "path"; import { tmpdir } from "os"; +import { createRealpathAwarePathMatcher } from "./path"; interface LegacyCreateWorktreeTestOptions { branchName: string; @@ -86,6 +96,12 @@ describe("paseo worktree manager", () => { const ownership = await isPaseoOwnedWorktreeCwd(created.worktreePath, { paseoHome }); expect(ownership.allowed).toBe(true); + await expect( + isPaseoOwnedWorktreeCwd(join(created.worktreePath, "packages", "app"), { paseoHome }), + ).resolves.toMatchObject({ + allowed: true, + worktreePath: created.worktreePath, + }); }); it("rejects paths that are not under the paseo worktrees root", async () => { @@ -97,6 +113,72 @@ describe("paseo worktree manager", () => { expect(ownership.allowed).toBe(false); }); + it("reports the source checkout root separately from Git's common directory", async () => { + const created = await createLegacyWorktreeForTest({ + branchName: "placement-root-branch", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "placement-root", + paseoHome, + }); + + const ownership = await isPaseoOwnedWorktreeCwd(created.worktreePath, { paseoHome }); + + expect(ownership.allowed).toBe(true); + expect(createRealpathAwarePathMatcher(repoDir)(ownership.repoRoot ?? "")).toBe(true); + expect(createRealpathAwarePathMatcher(created.worktreePath)(ownership.worktreePath ?? "")).toBe( + true, + ); + }); + + it("maps only root-contained workspace paths into a replacement worktree", () => { + const sourceWorktreePath = join(tempDir, "source-worktree"); + const targetWorktreePath = join(tempDir, "target-worktree"); + const nestedWorkspaceCwd = join(sourceWorktreePath, "packages", "app"); + + expect( + mapWorkspaceCwdToWorktree({ + sourceWorktreePath, + workspaceCwd: sourceWorktreePath, + targetWorktreePath, + }), + ).toBe(targetWorktreePath); + expect( + mapWorkspaceCwdToWorktree({ + sourceWorktreePath, + workspaceCwd: nestedWorkspaceCwd, + targetWorktreePath, + }), + ).toBe(join(targetWorktreePath, "packages", "app")); + expect(() => + mapWorkspaceCwdToWorktree({ + sourceWorktreePath, + workspaceCwd: join(tempDir, "outside-worktree"), + targetWorktreePath, + }), + ).toThrow("outside its source worktree"); + }); + + it.skipIf(process.platform === "win32")( + "maps a realpath-equivalent source workspace into the matching target subdirectory", + () => { + const sourceWorktreePath = join(tempDir, "source-worktree"); + const workspaceCwd = join(sourceWorktreePath, "packages", "app"); + const sourceAlias = join(tempDir, "source-alias"); + const targetWorktreePath = join(tempDir, "target-worktree"); + mkdirSync(workspaceCwd, { recursive: true }); + symlinkSync(sourceWorktreePath, sourceAlias, "dir"); + + expect( + mapWorkspaceCwdToWorktree({ + sourceWorktreePath: sourceAlias, + workspaceCwd, + targetWorktreePath, + }), + ).toBe(join(targetWorktreePath, "packages", "app")); + }, + ); + it("rejects the worktrees root itself and the per-repo hash dir", async () => { const projectHash = await deriveWorktreeProjectHash(repoDir); const worktreesRoot = join(paseoHome, "worktrees"); diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index dee16e0b1..993a056a1 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -35,7 +35,7 @@ import { resolvePaseoHome } from "../server/paseo-home.js"; 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 } from "./path.js"; +import { expandTilde, getRealpathAwareRelativePath, isPathInsideRoot } from "./path.js"; export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug"; @@ -724,11 +724,15 @@ export async function resolveWorktreeRuntimeEnv(options: { export async function runWorktreeTeardownCommands(options: { worktreePath: string; + teardownCwd?: string; branchName?: string; repoRootPath?: string; }): Promise { - // Read paseo.json from the worktree (it will have the same content as the source repo) - const teardownCommands = getWorktreeTeardownCommands(options.worktreePath); + const teardownCwd = options.teardownCwd ?? options.worktreePath; + if (getRealpathAwareRelativePath(options.worktreePath, teardownCwd) === null) { + throw new Error(`Worktree teardown cwd is outside the worktree: ${teardownCwd}`); + } + const teardownCommands = getWorktreeTeardownCommands(teardownCwd); if (teardownCommands.length === 0) { return []; } @@ -756,7 +760,7 @@ export async function runWorktreeTeardownCommands(options: { const results: WorktreeTeardownCommandResult[] = []; for (const cmd of teardownCommands) { const result = await execSetupCommand(cmd, { - cwd: options.worktreePath, + cwd: teardownCwd, env: teardownEnv, }); results.push(result); @@ -772,6 +776,23 @@ export async function runWorktreeTeardownCommands(options: { return results; } +export async function seedPaseoConfigFile(options: { + sourceCwd: string; + targetCwd: string; +}): Promise { + const sourceConfigPath = join(options.sourceCwd, "paseo.json"); + const targetConfigPath = join(options.targetCwd, "paseo.json"); + try { + await stat(targetConfigPath); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + await copyFile(sourceConfigPath, targetConfigPath).catch((error) => { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + }); +} + /** * Get the git common directory (shared across worktrees) for a given cwd. * This is where refs, objects, etc. are stored. @@ -845,6 +866,36 @@ export async function computeWorktreePath( return join(projectWorktreesRoot, slug); } +export function mapWorkspaceCwdToWorktree(input: { + sourceWorktreePath: string; + workspaceCwd: string; + targetWorktreePath: string; +}): string { + const relativeWorkspaceCwd = getRealpathAwareRelativePath( + input.sourceWorktreePath, + input.workspaceCwd, + ); + if (relativeWorkspaceCwd === null) { + throw new Error(`Workspace cwd is outside its source worktree: ${input.workspaceCwd}`); + } + + return mapWorkspaceRelativeCwdToWorktree({ + relativeWorkspaceCwd, + targetWorktreePath: input.targetWorktreePath, + }); +} + +export function mapWorkspaceRelativeCwdToWorktree(input: { + relativeWorkspaceCwd: string; + targetWorktreePath: string; +}): string { + const mappedCwd = resolve(input.targetWorktreePath, input.relativeWorkspaceCwd); + if (!isPathInsideRoot(input.targetWorktreePath, mappedCwd)) { + throw new Error(`Workspace cwd escapes its target worktree: ${input.relativeWorkspaceCwd}`); + } + return mappedCwd; +} + function normalizePathForOwnership(input: string): string { try { return realpathSync(input); @@ -878,13 +929,13 @@ export async function isPaseoOwnedWorktreeCwd( } const worktreesBaseRoot = resolvePaseoWorktreesBaseRoot(options); - const paseoWorktreesPrefix = normalizePathForOwnership(worktreesBaseRoot) + sep; + const relativePath = getRealpathAwareRelativePath(worktreesBaseRoot, resolvedCwd); // Ownership is defined by the path living under //[/...]. // The / prefix is Paseo-private — nothing else writes there — so the // path shape alone is sufficient proof of ownership, even when git has already // forgotten about the worktree. - if (!resolvedCwd.startsWith(paseoWorktreesPrefix)) { + if (relativePath === null) { return { allowed: false, ...(repoRoot !== undefined ? { repoRoot } : {}), @@ -892,8 +943,7 @@ export async function isPaseoOwnedWorktreeCwd( }; } - const relative = resolvedCwd.slice(paseoWorktreesPrefix.length); - const parts = relative.split(sep).filter((part) => part.length > 0); + const parts = relativePath.split(sep).filter((part) => part.length > 0); if (parts.length < 2) { return { allowed: false, @@ -907,7 +957,7 @@ export async function isPaseoOwnedWorktreeCwd( allowed: true, ...(repoRoot !== undefined ? { repoRoot } : {}), worktreeRoot: worktreesRoot, - worktreePath: resolvedCwd, + worktreePath: join(worktreesRoot, parts[1]), }; } @@ -977,10 +1027,9 @@ export async function listPaseoWorktrees({ envOverlay: READ_ONLY_GIT_ENV, }); - const rootPrefix = normalizePathForOwnership(projectWorktreesRoot) + sep; return parseWorktreeList(stdout) .map((entry) => Object.assign({}, entry, { path: normalizePathForOwnership(entry.path) })) - .filter((entry) => entry.path.startsWith(rootPrefix)) + .filter((entry) => getRealpathAwareRelativePath(projectWorktreesRoot, entry.path) !== null) .map((entry) => Object.assign({}, entry, { createdAt: resolveWorktreeCreatedAtIso(entry.path) }), ); @@ -1018,76 +1067,25 @@ export async function resolveExistingWorktreeForSlug({ }; } -export async function resolvePaseoWorktreeRootForCwd( - cwd: string, - options?: WorktreeRootOptions, -): Promise<{ repoRoot: string; worktreeRoot: string; worktreePath: string } | null> { - let gitCommonDir: string; - try { - gitCommonDir = await getGitCommonDir(cwd); - } catch { - return null; - } - - const worktreesRoot = await getPaseoWorktreesRoot( - cwd, - options?.paseoHome, - options?.worktreesRoot, - ); - const resolvedRoot = normalizePathForOwnership(worktreesRoot) + sep; - - let worktreeRoot: string | null = null; - try { - const { stdout } = await runGitCommand(["rev-parse", "--show-toplevel"], { - cwd, - envOverlay: READ_ONLY_GIT_ENV, - }); - worktreeRoot = parseGitRevParsePath(stdout); - } catch { - worktreeRoot = null; - } - - if (!worktreeRoot) { - return null; - } - - const resolvedWorktreeRoot = normalizePathForOwnership(worktreeRoot); - if (!resolvedWorktreeRoot.startsWith(resolvedRoot)) { - return null; - } - - const knownWorktrees = await listPaseoWorktrees({ - cwd, - paseoHome: options?.paseoHome, - worktreesRoot: options?.worktreesRoot, - }); - const match = knownWorktrees.find((entry) => entry.path === resolvedWorktreeRoot); - if (!match) { - return null; - } - - return { - repoRoot: gitCommonDir, - worktreeRoot: worktreesRoot, - worktreePath: match.path, - }; +export interface DeletePaseoWorktreeOptions { + cwd: string | null; + worktreePath?: string; + teardownCwds?: string[]; + worktreeSlug?: string; + worktreesRoot?: string; + paseoHome?: string; + worktreesBaseRoot?: string; } export async function deletePaseoWorktree({ cwd, worktreePath, + teardownCwds, worktreeSlug, worktreesRoot, paseoHome, worktreesBaseRoot, -}: { - cwd: string | null; - worktreePath?: string; - worktreeSlug?: string; - worktreesRoot?: string; - paseoHome?: string; - worktreesBaseRoot?: string; -}): Promise { +}: DeletePaseoWorktreeOptions): Promise { if (!worktreePath && !worktreeSlug) { throw new Error("worktreePath or worktreeSlug is required"); } @@ -1104,25 +1102,30 @@ export async function deletePaseoWorktree({ throw new Error("cwd or worktreesRoot is required to delete a Paseo worktree"); } - const resolvedRoot = normalizePathForOwnership(resolvedWorktreesRoot) + sep; const requestedPath = worktreePath ?? join(resolvedWorktreesRoot, worktreeSlug!); const resolvedRequested = normalizePathForOwnership(requestedPath); + const ownership = await isPaseoOwnedWorktreeCwd(requestedPath, { + paseoHome, + worktreesRoot: worktreesBaseRoot, + }); const resolvedWorktree = - ( - await resolvePaseoWorktreeRootForCwd(requestedPath, { - paseoHome, - worktreesRoot: worktreesBaseRoot, - }) - )?.worktreePath ?? resolvedRequested; + ownership.allowed && ownership.worktreePath ? ownership.worktreePath : resolvedRequested; - if (!resolvedWorktree.startsWith(resolvedRoot)) { + const relativeWorktreePath = getRealpathAwareRelativePath( + resolvedWorktreesRoot, + resolvedWorktree, + ); + if (relativeWorktreePath === null || relativeWorktreePath === "") { throw new Error("Refusing to delete non-Paseo worktree"); } if (await pathExists(resolvedWorktree)) { - await runWorktreeTeardownCommands({ - worktreePath: resolvedWorktree, - }); + for (const teardownCwd of teardownCwds ?? [resolvedWorktree]) { + await runWorktreeTeardownCommands({ + worktreePath: resolvedWorktree, + teardownCwd, + }); + } } if (cwd) { @@ -1150,6 +1153,27 @@ export async function deletePaseoWorktree({ } } +export async function rollbackCreatedPaseoWorktree( + options: DeletePaseoWorktreeOptions, + cause: unknown, +): Promise { + let cleanupError: unknown; + try { + await deletePaseoWorktree(options); + } catch (error) { + cleanupError = error; + } + if (cleanupError) { + const failure = new Error( + `${cause instanceof Error ? cause.message : "Worktree workflow failed"}; rollback also failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`, + { cause }, + ); + Object.assign(failure, { cleanupError }); + throw failure; + } + throw cause; +} + async function pathExists(path: string): Promise { try { await stat(path); @@ -1244,18 +1268,7 @@ export const createWorktree = async ({ : {}), }); - // If paseo.json exists in the main repo but wasn't checked into the worktree - // (e.g. uncommitted on first-time setup), seed the worktree with it so setup - // commands and scripts pick up the user's intended config. - const mainConfigPath = join(cwd, "paseo.json"); - const worktreeConfigPath = join(worktreePath, "paseo.json"); - try { - await stat(worktreeConfigPath); - } catch { - await copyFile(mainConfigPath, worktreeConfigPath).catch((err) => { - if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; - }); - } + await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath }); if (runSetup) { await runWorktreeSetupCommands({ diff --git a/packages/website/package.json b/packages/website/package.json index adfbf8fce..efbdd7abc 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.1.110", + "version": "0.2.0-beta.1", "private": true, "type": "module", "scripts": { diff --git a/packages/website/public/homepage-hero.png b/packages/website/public/homepage-hero.png new file mode 100644 index 000000000..6ab4ada9f Binary files /dev/null and b/packages/website/public/homepage-hero.png differ diff --git a/packages/website/src/components/hero-mockup.tsx b/packages/website/src/components/hero-mockup.tsx index 9545b3d57..f1a0733d3 100644 --- a/packages/website/src/components/hero-mockup.tsx +++ b/packages/website/src/components/hero-mockup.tsx @@ -1,1005 +1,15 @@ -import * as React from "react"; -import { motion } from "framer-motion"; -import { - FolderGit2, - Paperclip, - ArrowUp, - X, - MessagesSquare, - Plus, - Settings, - Monitor, - GitCommitHorizontal, - ChevronDown, - ChevronRight, - ListChevronsUpDown, - Bot, - Check, - Loader, - SquareTerminal, - PanelLeft, - Ellipsis, - Mic, - GitPullRequest, - SquarePen, - Columns2, - Rows2, -} from "lucide-react"; -import { ClaudeIcon, CodexIcon, SourceControlIcon } from "~/components/mockup"; - -const USER_BUBBLE_STYLE = { borderRadius: "16px 2px 16px 16px" }; -const NEW_BADGE_STYLE = { backgroundColor: "rgba(46, 160, 67, 0.2)" }; - -// ── Stagger timing ────────────────────────────────────── - -const D = { - shell: 0, - sidebar: 0.1, - titleBar: 0.15, - tabs: 0.25, - chat: 0.35, - diffPanel: 0.55, -}; - -function fade(delay: number) { - return { - initial: { opacity: 0, y: 8 } as const, - animate: { opacity: 1, y: 0 } as const, - transition: { duration: 0.5, delay, ease: "easeOut" as const }, - }; -} - -// ── Data ──────────────────────────────────────────────── - -type ChatItem = - | { type: "user"; text: string } - | { type: "text"; text: string; bold?: boolean } - | { type: "tool"; label: string; summary?: string; status: "running" | "done" }; - -const CHAT: ChatItem[] = [ - { type: "user", text: "Build me an internal dashboard for tracking customer returns" }, - { type: "text", text: "I'll break this down into planning and implementation.", bold: true }, - { type: "tool", label: "Run plan-technical", summary: "codex · plan-technical", status: "done" }, - { type: "tool", label: "Run plan-design", summary: "claude · plan-design", status: "done" }, - { - type: "tool", - label: "Wait for agents", - summary: "plan-technical plan-design", - status: "done", - }, - { type: "text", text: "Got the plans. Spinning up Codex for implementation.", bold: true }, - { type: "tool", label: "Run implement", summary: "codex · 12 files changed", status: "done" }, - { type: "text", text: "Implementation done. Requesting review from Claude." }, - { type: "tool", label: "Run review", summary: "claude · no issues found", status: "running" }, - { type: "text", text: "All tasks complete. Dashboard is ready.", bold: true }, -]; - -// ── Sidebar data ──────────────────────────────────────── - -interface SidebarWorkspace { - name: string; - kind: "checkout" | "worktree"; - status: "running" | "done" | "idle" | "syncing"; - selected?: boolean; - diffStat?: { additions: number; deletions: number }; - pr?: { number: number; state: "open" | "merged" | "closed" }; -} - -interface SidebarProject { - initial: string; - name: string; - workspaces: SidebarWorkspace[]; -} - -const SIDEBAR_PROJECTS: SidebarProject[] = [ - { - initial: "A", - name: "acme/returns-app", - workspaces: [ - { - name: "main", - kind: "checkout", - status: "syncing", - selected: true, - diffStat: { additions: 247, deletions: 15 }, - }, - { - name: "feat/dashboard", - kind: "worktree", - status: "done", - pr: { number: 142, state: "open" }, - }, - ], - }, - { - initial: "P", - name: "acme/payments", - workspaces: [ - { name: "main", kind: "checkout", status: "idle" }, - { - name: "fix/stripe-webhook", - kind: "worktree", - status: "done", - diffStat: { additions: 38, deletions: 4 }, - pr: { number: 89, state: "merged" }, - }, - ], - }, - { - initial: "I", - name: "acme/infra", - workspaces: [ - { name: "main", kind: "checkout", status: "idle" }, - { - name: "feat/k8s-autoscale", - kind: "worktree", - status: "syncing", - diffStat: { additions: 91, deletions: 3 }, - }, - ], - }, - { - initial: "D", - name: "acme/design-system", - workspaces: [{ name: "main", kind: "checkout", status: "idle" }], - }, -]; - -// ── Tab data ───────────────────────────────────────────── - -interface TabDef { - name: string; - provider: "claude" | "codex" | "terminal"; - done: boolean; - active?: boolean; -} - -const TABS: TabDef[] = [ - { name: "Orchestrator", provider: "claude", done: false, active: true }, - { name: "Implement", provider: "codex", done: true }, -]; - -// ── Diff data ────────────────────────────────────────── - -type DiffLineType = "add" | "remove" | "context" | "header"; -interface DiffLine { - type: DiffLineType; - ln: string | null; - content?: string; - tokens?: Array<{ text: string; cls: string }>; -} - -const DIFF_LINES: DiffLine[] = [ - { - type: "add", - ln: "1", - tokens: [ - { text: "import", cls: "text-syn-keyword" }, - { text: " { ", cls: "text-syn-punctuation" }, - { text: "useState", cls: "text-syn-variable" }, - { text: " } ", cls: "text-syn-punctuation" }, - { text: "from", cls: "text-syn-keyword" }, - { text: ' "react"', cls: "text-syn-string" }, - ], - }, - { - type: "add", - ln: "2", - tokens: [ - { text: "import", cls: "text-syn-keyword" }, - { text: " { ", cls: "text-syn-punctuation" }, - { text: "ReturnTable", cls: "text-syn-variable" }, - { text: " } ", cls: "text-syn-punctuation" }, - { text: "from", cls: "text-syn-keyword" }, - { text: ' "./components"', cls: "text-syn-string" }, - ], - }, - { type: "add", ln: "3", tokens: [] }, - { - type: "add", - ln: "4", - tokens: [ - { text: "export", cls: "text-syn-keyword" }, - { text: " function", cls: "text-syn-keyword" }, - { text: " Dashboard", cls: "text-syn-function" }, - { text: "() {", cls: "text-syn-punctuation" }, - ], - }, - { - type: "add", - ln: "5", - tokens: [ - { text: " const", cls: "text-syn-keyword" }, - { text: " [returns, setReturns]", cls: "text-syn-variable" }, - { text: " = ", cls: "text-syn-operator" }, - { text: "useState", cls: "text-syn-function" }, - { text: "([])", cls: "text-syn-punctuation" }, - ], - }, - { - type: "add", - ln: "6", - tokens: [ - { text: " const", cls: "text-syn-keyword" }, - { text: " [filter, setFilter]", cls: "text-syn-variable" }, - { text: " = ", cls: "text-syn-operator" }, - { text: "useState", cls: "text-syn-function" }, - { text: '("all")', cls: "text-syn-string" }, - ], - }, - { type: "add", ln: "7", tokens: [] }, - { - type: "add", - ln: "8", - tokens: [ - { text: " ", cls: "text-syn-punctuation" }, - { text: "return", cls: "text-syn-keyword" }, - { text: " (", cls: "text-syn-punctuation" }, - ], - }, - { - type: "add", - ln: "9", - tokens: [ - { text: " <", cls: "text-syn-punctuation" }, - { text: "main", cls: "text-syn-tag" }, - { text: " className", cls: "text-syn-property" }, - { text: '="', cls: "text-syn-punctuation" }, - { text: "min-h-screen p-8", cls: "text-syn-string" }, - { text: '">', cls: "text-syn-punctuation" }, - ], - }, - { - type: "add", - ln: "10", - tokens: [ - { text: " <", cls: "text-syn-punctuation" }, - { text: "h1", cls: "text-syn-tag" }, - { text: ">Customer Returns", cls: "text-syn-punctuation" }, - ], - }, - { - type: "add", - ln: "11", - tokens: [ - { text: " <", cls: "text-syn-punctuation" }, - { text: "FilterBar", cls: "text-syn-tag" }, - { text: " value", cls: "text-syn-property" }, - { text: "={", cls: "text-syn-punctuation" }, - { text: "filter", cls: "text-syn-variable" }, - { text: "}", cls: "text-syn-punctuation" }, - { text: " onChange", cls: "text-syn-property" }, - { text: "={", cls: "text-syn-punctuation" }, - { text: "setFilter", cls: "text-syn-variable" }, - { text: "} />", cls: "text-syn-punctuation" }, - ], - }, - { - type: "add", - ln: "12", - tokens: [ - { text: " <", cls: "text-syn-punctuation" }, - { text: "ReturnTable", cls: "text-syn-tag" }, - { text: " data", cls: "text-syn-property" }, - { text: "={", cls: "text-syn-punctuation" }, - { text: "returns", cls: "text-syn-variable" }, - { text: "} />", cls: "text-syn-punctuation" }, - ], - }, - { - type: "add", - ln: "13", - tokens: [ - { text: " <", cls: "text-syn-punctuation" }, - { text: "StatusChart", cls: "text-syn-tag" }, - { text: " data", cls: "text-syn-property" }, - { text: "={", cls: "text-syn-punctuation" }, - { text: "returns", cls: "text-syn-variable" }, - { text: "} />", cls: "text-syn-punctuation" }, - ], - }, - { - type: "add", - ln: "14", - tokens: [ - { text: " ", cls: "text-syn-punctuation" }, - ], - }, - { type: "add", ln: "15", tokens: [{ text: " )", cls: "text-syn-punctuation" }] }, - { type: "add", ln: "16", tokens: [{ text: "}", cls: "text-syn-punctuation" }] }, -]; - -// ── Implementation agent chat data ────────────────────── - -// ── Terminal log lines ───────────────────────────────── - -const TERMINAL_LINES = [ - { id: "cmd", text: "$ npm run dev", cls: "text-mock-fg" }, - { id: "blank1", text: "", cls: "" }, - { id: "pkg", text: "> acme-returns@0.1.0 dev", cls: "text-mock-fg-muted" }, - { id: "pkg-cmd", text: "> next dev --turbopack", cls: "text-mock-fg-muted" }, - { id: "blank2", text: "", cls: "" }, - { id: "next-ver", text: " ▲ Next.js 15.3.1 (Turbopack)", cls: "text-mock-fg" }, - { id: "next-local", text: " - Local: http://localhost:3000", cls: "text-mock-fg-muted" }, - { id: "blank3", text: "", cls: "" }, - { id: "starting", text: " ✓ Starting...", cls: "text-mock-green" }, - { id: "ready", text: " ✓ Ready in 1.2s", cls: "text-mock-green" }, - { id: "compiling-dashboard", text: " ○ Compiling /dashboard ...", cls: "text-mock-fg-muted" }, - { id: "compiled-dashboard", text: " ✓ Compiled /dashboard in 340ms", cls: "text-mock-green" }, - { id: "compiling-api", text: " ○ Compiling /api/returns ...", cls: "text-mock-fg-muted" }, - { id: "compiled-api", text: " ✓ Compiled /api/returns in 120ms", cls: "text-mock-green" }, -]; - -// ── Sub-components ────────────────────────────────────── - -function TrafficLights() { - return ( -
-
-
-
-
- ); -} - -function ProviderIcon({ - provider, - muted = false, -}: { - provider: "claude" | "codex" | "terminal"; - muted?: boolean; -}) { - const cls = muted ? "text-mock-fg-muted" : "text-mock-fg"; - if (provider === "terminal") return ; - return provider === "claude" ? ( - - ) : ( - - ); -} - -function TabBarAction({ - icon: Icon, -}: { - icon: React.ComponentType<{ size?: number; className?: string; strokeWidth?: number }>; -}) { - return ( -
- -
- ); -} - -function PaneTabBar({ tabs, focused = false }: { tabs: TabDef[]; focused?: boolean }) { - return ( -
- {tabs.map((tab) => ( -
- {tab.active && ( -
- )} - -
- -
- - - {tab.name} - - - -
- ))} -
- - - - -
-
- ); -} - -function Composer({ - provider, - model, - focused = false, -}: { - provider: "claude" | "codex"; - model: string; - focused?: boolean; -}) { - const Icon = provider === "claude" ? ClaudeIcon : CodexIcon; - return ( -
-
-
- {focused && ( - - )} - - Message the agent, tag @files, or use /commands and /skills - -
-
-
-
- -
-
- - {model} - -
-
-
-
- -
-
- -
-
-
-
-
- ); -} - -function prStateLabel(state: "open" | "merged" | "closed"): string { - if (state === "open") return "Open"; - if (state === "merged") return "Merged"; - return "Closed"; -} - -function WorkspaceIcon({ workspace }: { workspace: SidebarWorkspace }) { - if (workspace.status === "syncing") return ; - const icon = - workspace.kind === "worktree" ? ( - - ) : ( - - ); - return ( - <> - {icon} - {workspace.status !== "idle" && ( -
- -
- )} - - ); -} - -function SidebarWorkspaceRow({ workspace }: { workspace: SidebarWorkspace }) { - return ( -
-
-
- -
- - {workspace.name} - - {workspace.diffStat && ( -
- - +{workspace.diffStat.additions} - - - -{workspace.diffStat.deletions} - -
- )} -
- {workspace.pr && ( -
- - - #{workspace.pr.number} · {prStateLabel(workspace.pr.state)} - -
- )} -
- ); -} - -function diffLineBg(type: DiffLineType): string { - if (type === "add") return "bg-mock-diff-add"; - if (type === "remove") return "bg-mock-diff-remove"; - return "bg-mock-surface1"; -} - -function diffLineNumCls(type: DiffLineType): string { - if (type === "add") return "text-mock-green-400"; - if (type === "remove") return "text-mock-red"; - return "text-mock-fg-muted"; -} - -function DiffLineRow({ line }: { line: DiffLine }) { - const lineBg = diffLineBg(line.type); - const lineNumCls = diffLineNumCls(line.type); - return ( -
-
- - {line.ln ?? ""} - -
- - {line.tokens?.map((tok) => ( - - {tok.text} - - ))} - -
- ); -} - -function chatItemKey(item: ChatItem): string { - if (item.type === "user") return `user-${item.text}`; - if (item.type === "tool") return `tool-${item.label}-${item.summary ?? ""}`; - return `text-${item.text}`; -} - -function ChatMessages({ items }: { items: ChatItem[] }) { - return ( -
- {items.map((item) => { - const key = chatItemKey(item); - if (item.type === "user") { - return ( -
-
- {item.text} -
-
- ); - } - if (item.type === "tool") { - const isDone = item.status === "done"; - return ( -
-
- -
- - {item.label} - - {item.summary && ( - - {item.summary} - - )} -
- {isDone ? ( - - ) : ( - - )} -
-
- ); - } - return ( -

- {item.text} -

- ); - })} -
- ); -} - -function ChatArea() { - return ( -
- - -
- ); -} - -function TerminalPane() { - return ( -
-
- {TERMINAL_LINES.map((line) => ( -
- {line.text} -
- ))} -
-
- ); -} - -function ExplorerSidebar() { - return ( -
- {/* Explorer header — h-12, Changes/Files tabs */} -
-
-
- Changes -
-
- Files -
-
-
- - {/* Secondary header — h-9, Uncommitted dropdown + filter icons */} -
-
- Uncommitted - -
-
-
- -
-
-
- - {/* File header — expanded */} -
-
- - dashboard.tsx - src/pages - - New - -
-
- +16 - -0 -
-
- - {/* Diff lines */} -
- {DIFF_LINES.map((line) => ( - - ))} -
- - {/* Collapsed file rows */} - {[ - { name: "return-table.tsx", dir: "src/components", added: 42, removed: 8 }, - { name: "filter-bar.tsx", dir: "src/components", added: 28, removed: 3 }, - { name: "status-chart.tsx", dir: "src/components", added: 19, removed: 0, isNew: true }, - { name: "returns.ts", dir: "src/api", added: 12, removed: 5 }, - { name: "index.tsx", dir: "src/pages", added: 6, removed: 2 }, - ].map((file) => ( -
-
- - {file.name} - {file.dir} - {file.isNew && ( - - New - - )} -
-
- +{file.added} - -{file.removed} -
-
- ))} - -
-
- ); -} - -// Snake traversal order matching the real app: [0, 1, 3, 5, 4, 2] (2-col × 3-row grid) -// DOT_SEQUENCE[sequenceIndex] = dotIndex, so sequenceIndex = indexOf(dotIndex) -const DOT_SEQUENCE = [0, 1, 3, 5, 4, 2] as const; -const SYNCED_LOADER_DURATION_MS = 950; -const DOT_COUNT = 6; -const DOT_INDICES = Array.from({ length: DOT_COUNT }, (_, i) => i); -const STEP_MS = SYNCED_LOADER_DURATION_MS / DOT_COUNT; // 158.333…ms per step - -interface SyncedLoaderDotProps { - dotIndex: number; - dotSize: number; - gap: number; -} - -function SyncedLoaderDot({ dotIndex, dotSize, gap }: SyncedLoaderDotProps) { - const col = dotIndex % 2; - const row = Math.floor(dotIndex / 2); - const sequenceIndex = DOT_SEQUENCE.indexOf(dotIndex as (typeof DOT_SEQUENCE)[number]); - const delayMs = -(sequenceIndex * STEP_MS); - const style = React.useMemo( - () => ({ - position: "absolute" as const, - left: col * (dotSize + gap), - top: row * (dotSize + gap), - width: dotSize, - height: dotSize, - borderRadius: "50%", - backgroundColor: "#f59e0b", - animationName: "synced-snake-dot", - animationDuration: `${SYNCED_LOADER_DURATION_MS}ms`, - animationTimingFunction: "linear", - animationIterationCount: "infinite", - animationDelay: `${delayMs}ms`, - }), - [col, row, dotSize, gap, delayMs], - ); - return
; -} - -function SyncedLoader({ size = 11 }: { size?: number }) { - const gap = Math.max(1, Math.round(size * 0.12)); - const dotSize = Math.max(2, Math.floor((size - gap * 2) / 3)); - const gridW = dotSize * 2 + gap; - const gridH = dotSize * 3 + gap * 2; - - const outerStyle = React.useMemo( - () => ({ - width: size, - height: size, - display: "flex", - alignItems: "center", - justifyContent: "center", - flexShrink: 0, - }), - [size], - ); - - const innerStyle = React.useMemo( - () => ({ position: "relative" as const, width: gridW, height: gridH }), - [gridW, gridH], - ); - - return ( -
-
- {DOT_INDICES.map((dotIndex) => ( - - ))} -
-
- ); -} - -function WorkspaceStatusDot({ status }: { status: SidebarWorkspace["status"] }) { - if (status === "running") return
; - if (status === "done") return
; - return null; -} - -function Sidebar() { - // Shared left edge: pl-3 (12px) for traffic lights, icons, footer dot - // Workspace indent: pl-7 (28px) - // PR badge aligns with workspace text: pl-[42px] - return ( -
- {/* Traffic lights */} -
- -
- - {/* Sessions header */} -
- - Sessions -
- - {/* Project list */} -
- {SIDEBAR_PROJECTS.map((project) => ( -
- {/* Project row — icon aligns with traffic lights / sessions */} -
-
- - {project.initial} - -
- - {project.name} - -
- - {/* Workspace rows — indented one level */} - {project.workspaces.map((workspace) => ( - - ))} -
- ))} -
- - {/* Footer — dot aligns with traffic lights / icons */} -
-
-
- MacBook Pro -
-
-
- -
-
- -
-
-
-
- ); -} - -const TERMINAL_TABS: TabDef[] = [ - { name: "npm run dev", provider: "terminal", done: false, active: true }, -]; - -function TitleBarLeft() { - return ( -
-
- -
-
- main - - acme/returns-app - -
- -
-
-
- ); -} - -function TitleBarRight() { - return ( -
-
-
- - Commit -
-
- -
-
-
- - +247 - -15 -
-
- ); -} - -function DesktopSplitPanes() { - return ( -
- {/* Left pane: all agent tabs + chat */} -
- - - - - - -
- - {/* Resize handle */} -
- - {/* Right pane: terminal only */} - - - - -
- ); -} - -// ── Desktop Mockup ────────────────────────────────────── - -function DesktopMockup() { - const containerRef = React.useRef(null); - const [scale, setScale] = React.useState(1); - - React.useEffect(() => { - function updateScale() { - if (!containerRef.current) return; - const parentWidth = containerRef.current.parentElement?.clientWidth ?? 1200; - const designWidth = 1200; - setScale(Math.min(1, parentWidth / designWidth)); - } - updateScale(); - window.addEventListener("resize", updateScale); - return () => window.removeEventListener("resize", updateScale); - }, []); - - const containerStyle = React.useMemo(() => ({ height: `${1200 * (9 / 16) * scale}px` }), [scale]); - const innerStyle = React.useMemo(() => ({ width: 1200, transform: `scale(${scale})` }), [scale]); - - return ( -
-
- {/* Top-level: left sidebar | center column | explorer sidebar — all full height */} -
- {/* Left sidebar — full height */} - - - - - {/* Center column: title bar + split panes */} -
- {/* Title bar — belongs to center column only */} - - - - - - -
- - {/* Explorer sidebar — full height, pushes center column */} - - - -
-
-
- ); -} - -// ── Export ─────────────────────────────────────────────── - export function HeroMockup() { - return ; + return ( +
+ Paseo desktop app with coding agents, a conversation, and a code diff open side by side +
+ ); } diff --git a/public-docs/cli.md b/public-docs/cli.md index 24df7f4ca..f568df3e0 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -1,6 +1,6 @@ --- title: CLI -description: "Paseo CLI reference: manage agents, daemons, permissions, and worktrees from your terminal." +description: "Paseo CLI reference: manage agents, workspaces, schedules, daemons, and permissions from your terminal." nav: CLI order: 3 category: Getting started @@ -10,7 +10,7 @@ category: Getting started The Paseo CLI lets you manage agents from your terminal. It's the same interface exposed by the daemon's API, so anything you can do in the app you can do from the command line. -> **Agent orchestration:** You can tell coding agents to use the Paseo CLI to spawn and manage other agents. This enables multi-agent workflows where one agent delegates subtasks to others and waits for results. +> **Agent orchestration:** You can tell coding agents to use the Paseo CLI to spawn and manage other agents. Paseo recognizes the calling agent, so CLI-created workers get the same workspace and parent defaults as MCP-created workers. ## Quick reference @@ -30,17 +30,59 @@ Use `paseo run` to start a new agent with a task: ```bash paseo run "implement user authentication" paseo run --provider codex "refactor the API layer" -paseo run --detach "run the full test suite" # background -paseo run --worktree feature-x "implement feature X" +paseo run --background "run the focused test suite" +paseo run --isolation worktree --base main "implement feature X" +paseo run --workspace "review the current diff" paseo run --output-schema schema.json "extract release notes" paseo run --output-schema '{"type":"object","properties":{"summary":{"type":"string"}},"required":["summary"]}' "summarize release notes" ``` -The `--worktree` flag creates the agent in an isolated git worktree, useful for parallel feature development. +From a human shell, a bare `paseo run` creates a new local workspace for the current directory. Use `--workspace ` to add the agent to an existing workspace, or `--isolation worktree` to create a new workspace backed by an isolated git worktree. -Use `--output-schema` to return only matching JSON output. You can pass a schema file path or an inline JSON schema object. This mode cannot be used with `--detach`. +When an existing Paseo agent runs the same command, Paseo recognizes it through `PASEO_AGENT_ID`. Without explicit placement, the new agent becomes its subagent in the same workspace. `--workspace` can place that subagent elsewhere without changing its parent. -By default, `paseo run` waits for completion. Use `--detach` to run in the background. +Use `--output-schema` to return only matching JSON output. You can pass a schema file path or an inline JSON schema object. This mode cannot be used with `--background`. + +By default, `paseo run` waits for completion. Use `--background` to return immediately while the agent keeps running. + +## Workspaces + +Create a workspace independently when you want to prepare its files before starting an agent: + +```bash +paseo workspace create --isolation local --path ~/dev/my-app --title main + +paseo workspace create \ + --isolation worktree \ + --path ~/dev/my-app \ + --mode branch-off \ + --new-branch feature/auth \ + --worktree-slug feature-auth \ + --base main + +paseo workspace create \ + --isolation worktree \ + --path ~/dev/my-app \ + --mode checkout-branch \ + --branch feature/existing \ + --worktree-slug existing-copy + +paseo workspace create \ + --isolation worktree \ + --path ~/dev/my-app \ + --mode checkout-pr \ + --pr-number 2186 +``` + +Then list, use, or archive it: + +```bash +paseo workspace ls +paseo run --workspace "implement authentication" +paseo workspace archive +``` + +Add `--forge ` to PR checkout when Paseo cannot infer the forge from the source checkout. See [Git worktrees](/docs/worktrees) for setup hooks and services. ## Listing agents @@ -93,7 +135,7 @@ Useful in scripts or when one agent needs to wait for another. ## Schedules -Run an agent on an interval or a cron. See [Schedules from the CLI](/docs/schedules-cli) for the full reference. +Run an agent on a cron schedule. The CLI also accepts simple cadence presets and compiles them to cron. See [Schedules from the CLI](/docs/schedules-cli) for the full reference. ```bash paseo schedule create --every 30m --cwd ~/dev/my-app "Continue the refactor and leave a note." @@ -119,8 +161,11 @@ Change an agent's operational mode (provider-specific): paseo agent mode --list # Show available modes paseo agent mode bypass # Set bypass mode paseo agent mode plan # Set plan mode +paseo agent detach # Make a subagent top-level ``` +Detaching is an explicit lifecycle action, not a creation flag. The agent keeps running; only its relationship to its parent changes. + ## Daemon management ```bash @@ -157,11 +202,13 @@ The CLI is designed to be used by agents themselves. You can instruct an agent t ```bash # Agent A spawns Agent B and waits for it -paseo run --detach "implement the API" --name api-agent -paseo wait api-agent -paseo logs api-agent --tail 5 +agent_id=$(paseo run --background --quiet --title api-agent "implement the API") +paseo wait "$agent_id" +paseo logs "$agent_id" --tail 5 ``` +Because Agent A's ID is present in the environment, Agent B is created as its subagent in the same workspace unless `--workspace` is specified. + Simple implement + verify loop: ```bash diff --git a/public-docs/mcp.md b/public-docs/mcp.md index 26ab1b2a8..71ba24f08 100644 --- a/public-docs/mcp.md +++ b/public-docs/mcp.md @@ -16,31 +16,44 @@ Depending on the provider, Paseo delivers the catalog through its native tool in The MCP server itself is controlled by `daemon.mcp.enabled`. Existing agents may need a reload. +## Mental model + +Workspaces decide where work happens; agent parentage decides who owns the work. + +- An agent that calls `create_agent` without a `workspaceId` gets a subagent in its own workspace. +- Passing a `workspaceId` places that subagent in another workspace without detaching it from its parent. +- A top-level MCP caller without a workspace gets a new local workspace. +- Create a workspace first when you need worktree isolation, a specific branch, or a pull request checkout. + +MCP does not expose an agent-detach tool. Detaching is a manual user action in the app or CLI. + ## Tools ### Agents -| Tool | Function | -| -------------------- | ---------------------------------------------------------------------------------------------------- | -| `create_agent` | Create an agent tied to a working directory, optionally with initial settings or a new git worktree. | -| `send_agent_prompt` | Send a task to a running agent. | -| `get_agent_status` | Return the latest snapshot for an agent. | -| `list_agents` | List recent agents as compact metadata. | -| `cancel_agent` | Abort an agent's current run but keep the agent alive. | -| `archive_agent` | Soft-delete an agent and remove it from the active list. | -| `kill_agent` | Terminate an agent session permanently. | -| `update_agent` | Update an agent name, labels, or runtime settings such as mode/model/thinking/features. | -| `get_agent_activity` | Return recent agent timeline entries as a curated summary. | -| `set_agent_mode` | Switch an agent's session mode. | +| Tool | Function | +| -------------------- | --------------------------------------------------------------------------------------- | +| `create_agent` | Create an agent, optionally placing it in an existing workspace with `workspaceId`. | +| `send_agent_prompt` | Send a task to a running agent. | +| `get_agent_status` | Return the latest snapshot for an agent. | +| `list_agents` | List recent agents as compact metadata. | +| `cancel_agent` | Abort an agent's current run but keep the agent alive. | +| `archive_agent` | Soft-delete an agent and remove it from the active list. | +| `kill_agent` | Terminate an agent session permanently. | +| `update_agent` | Update an agent name, labels, or runtime settings such as mode/model/thinking/features. | +| `get_agent_activity` | Return recent agent timeline entries as a curated summary. | +| `set_agent_mode` | Switch an agent's session mode. | -### Workspaces and worktrees +### Workspaces -| Tool | Function | -| ------------------ | ----------------------------------------------------------------------------- | -| `rename_workspace` | Change the user-visible name of the current or specified workspace. | -| `list_worktrees` | List Paseo-managed git worktrees for a repository. | -| `create_worktree` | Create a Paseo-managed git worktree from a branch, base branch, or GitHub PR. | -| `archive_worktree` | Delete a Paseo-managed git worktree. | +| Tool | Function | +| ------------------- | ----------------------------------------------------------------------------------------------------- | +| `create_workspace` | Create a local or worktree-isolated workspace. Worktrees can branch off, check out a branch, or a PR. | +| `list_workspaces` | List active workspaces and their directories and isolation. | +| `rename_workspace` | Change the user-visible name of the current or specified workspace. | +| `archive_workspace` | Archive a workspace and the sessions it owns. | + +For worktree isolation, `create_workspace` accepts the same useful choices as the app: branch off from a base, check out an existing branch, or check out a pull request. The worktree remains an implementation detail of the workspace lifecycle. ### Terminals @@ -52,19 +65,25 @@ The MCP server itself is controlled by `daemon.mcp.enabled`. Existing agents may | `capture_terminal` | Capture plain-text output from a terminal session. | | `send_terminal_keys` | Send text or special key tokens to a terminal session. | -### Schedules +### Schedules and heartbeats -| Tool | Function | -| ------------------ | ----------------------------------------------------------------- | -| `create_schedule` | Create a recurring schedule that runs on an agent or a new agent. | -| `create_heartbeat` | Send a recurring prompt back into the current agent. | -| `list_schedules` | List schedules managed by the daemon. | -| `inspect_schedule` | Inspect a schedule and its run history. | -| `pause_schedule` | Pause an active schedule. | -| `resume_schedule` | Resume a paused schedule. | -| `update_schedule` | Change the cadence, prompt, limits, or other schedule settings. | -| `schedule_logs` | Return recent runs and output for a schedule. | -| `delete_schedule` | Delete a schedule permanently. | +Both use the same cron engine, but they have deliberately different interfaces. + +| Tool | Function | +| ------------------- | ---------------------------------------------------------------------------- | +| `create_schedule` | Create a cron schedule that starts a new agent for each run. | +| `list_schedules` | List new-agent schedules managed by the daemon. | +| `inspect_schedule` | Inspect a schedule and its run history. | +| `pause_schedule` | Pause an active schedule. | +| `resume_schedule` | Resume a paused schedule. | +| `update_schedule` | Change a schedule's cron, prompt, agent settings, limits, or other settings. | +| `schedule_logs` | Return recent runs and output for a schedule. | +| `run_schedule_once` | Start one new-agent schedule run without changing its cron. | +| `delete_schedule` | Delete a new-agent schedule permanently. | +| `create_heartbeat` | Send a recurring cron-backed prompt into the current agent. | +| `delete_heartbeat` | Delete one of the current agent's heartbeats. | + +MCP heartbeats are ephemeral: create or delete them. To change one, delete it and create a replacement. Pause, resume, update, inspect, logs, and run-once apply to new-agent schedules only. ### Providers diff --git a/public-docs/metadata-generation.md b/public-docs/metadata-generation.md index a5ca7fbb8..f9e7a7c21 100644 --- a/public-docs/metadata-generation.md +++ b/public-docs/metadata-generation.md @@ -13,7 +13,7 @@ Paseo asks a language model to write short pieces of text for you so you don't h Paseo generates these kinds of metadata: - **Workspace titles** — a short, task-shaped label for a workspace, shown in the sidebar. -- **Worktree branch names** — a slug for the branch a new worktree agent runs on. +- **Worktree branch names** — a slug for a new worktree-isolated workspace's branch. - **Commit messages** — a concise message for the changes you're committing. - **Pull request title and body** — drafted from the diff when you open a PR. diff --git a/public-docs/orchestration-workflows.md b/public-docs/orchestration-workflows.md index 0fbbefbac..c1b60328a 100644 --- a/public-docs/orchestration-workflows.md +++ b/public-docs/orchestration-workflows.md @@ -16,8 +16,8 @@ Keep a strong planner in the main chat and send implementation to a workhorse: ```text Stay as the orchestrator. Use Paseo to find the available Codex 5.6 model, then -create a subagent in a new worktree. Ask it to implement the parser change and -run the focused tests. +create a worktree-isolated workspace and launch a subagent there. Ask it to +implement the parser change and run the focused tests. ``` Ask the orchestrator to inspect providers first when you are unsure of the exact model ID. Available models come from your own installed and authenticated CLIs. @@ -36,12 +36,13 @@ Each worker appears in the Subagents track, and the orchestrator can keep workin ## Parallelize edits without collisions -Give each independent implementation its own git worktree: +Give each independent implementation its own worktree-isolated workspace: ```text -Split these two issues between two Paseo subagents. Create a separate worktree -from main for each issue, use the best available implementation model, and have -each agent run the focused checks for its change. Summarize both diffs when done. +Split these two issues between two Paseo subagents. Create a separate workspace +with worktree isolation from main for each issue, use the best available +implementation model, and have each agent run the focused checks for its change. +Summarize both diffs when done. ``` Use the current workspace for collaboration on the same files. Use worktrees when agents may edit independently. @@ -51,9 +52,10 @@ Use the current workspace for collaboration on the same files. Use worktrees whe Use different models for making and judging the change: ```text -Create a worker in a new worktree to implement this feature. When it finishes, -create a second subagent on the same worktree to review the diff for correctness, -missing tests, and unnecessary complexity. Bring the review back here. +Create a worktree-isolated workspace and launch a worker there to implement this +feature. When it finishes, create a second subagent in the same workspace to +review the diff for correctness, missing tests, and unnecessary complexity. +Bring the review back here. ``` The second agent sees the worker's files without sharing its conversation context, which makes the review more independent. diff --git a/public-docs/orchestration.md b/public-docs/orchestration.md index 2ab26bfe5..aec9b51f9 100644 --- a/public-docs/orchestration.md +++ b/public-docs/orchestration.md @@ -8,7 +8,7 @@ category: Orchestration # Orchestration -Paseo orchestration gives a coding agent control of the Paseo daemon. The agent can discover every provider and model you have configured, create worktrees, launch other agents, send them follow-ups, and create heartbeats or schedules. The same work stays visible in the Paseo app. +Paseo orchestration gives a coding agent control of the Paseo daemon. The agent can discover every provider and model you have configured, create workspaces, launch other agents, send them follow-ups, and create heartbeats or schedules. The same work stays visible in the Paseo app. ## Native subagents vs Paseo subagents @@ -22,13 +22,13 @@ Cursor => Claude Code (Fable 5) Native subagents belong to one provider. Claude Code launches Claude Code subagents; Codex launches Codex subagents. They are useful when the parent provider can handle the whole task itself. -Paseo subagents are full agents managed by the Paseo daemon. The orchestrator can choose any configured provider and model, place the worker in the current workspace or a new worktree, and keep coordinating it after launch. Use them when you want one model to plan, another to implement, and another to review. +Paseo subagents are full agents managed by the Paseo daemon. The orchestrator can choose any configured provider and model, keep the worker in the current workspace, or place it in another workspace created for the task. Use them when you want one model to plan, another to implement, and another to review. | | Native subagent | Paseo subagent | | -------------------- | ----------------------------------------- | -------------------------------------------------- | | Provider | Same provider as its parent | Any provider configured in Paseo | -| Working directory | Managed by the parent provider | Current workspace, existing workspace, or worktree | -| Lifecycle | Owned by the parent provider | Managed by Paseo; can receive follow-ups or detach | +| Working directory | Managed by the parent provider | Current or explicitly selected workspace | +| Lifecycle | Owned by the parent provider | Managed by Paseo; can receive follow-ups | | Where you inspect it | Read-only timeline in the Subagents track | Full agent session in the Subagents track | | Best for | Fast, provider-native delegation | Cross-provider work and explicit workspace control | @@ -40,26 +40,30 @@ Then ask naturally: ```text Stay as the orchestrator. Use Paseo to find my available Codex models, then -launch a GPT-5.6 subagent in a new worktree. Ask it to implement the parser -change, run the focused tests, and report back here. +create a worktree-isolated workspace, then launch a GPT-5.6 subagent there. Ask +it to implement the parser change, run the focused tests, and report back here. ``` The orchestrator discovers the provider and model IDs, starts the worker, and receives a notification when it finishes. You can keep talking to the orchestrator in the meantime. +Agent creation has one default: when an agent creates another agent without a workspace ID, the new agent is its subagent in the same workspace. Passing a workspace ID changes where the subagent works, not who its parent is. + ## Where the work appears Spawned work appears in the **Subagents track** above the composer. Open a row to read the live conversation. Both kinds of subagent appear there: -- **Paseo subagents** open as full agent sessions. You can talk to them directly, change their settings, detach them, or archive them. +- **Paseo subagents** open as full agent sessions. You can talk to them directly, change their settings, or archive them. - **Native provider subagents** open as read-only timelines. You can inspect their work, but their provider owns their lifecycle. +A cross-workspace subagent still belongs to its parent's Subagents track. Paseo also opens its workspace so the work is not hidden in an otherwise empty workspace. If you want to turn any subagent into a top-level agent, detach it manually in the app or with `paseo agent detach`; detachment is not an agent-creation mode. + If an agent says background work is running but the track is empty, update Paseo. Provider-created subagent timelines require Paseo 0.1.107 or newer. ## Keep an agent working with a heartbeat -A heartbeat sends a prompt back into the same agent on a cadence. Use one when the agent should keep reassessing a live task: continue a refactor, babysit CI, watch a deployment, or retry after an external system changes. +A heartbeat sends a prompt back into the same agent on a cron cadence. Use one when the agent should keep reassessing a live task: continue a refactor, babysit CI, watch a deployment, or retry after an external system changes. Ask the agent directly: diff --git a/public-docs/schedules-chat.md b/public-docs/schedules-chat.md index 807a81b24..dc5e67419 100644 --- a/public-docs/schedules-chat.md +++ b/public-docs/schedules-chat.md @@ -16,6 +16,6 @@ Example prompts: - "Check the release build every 5 minutes until it passes, and fix the cause if it fails." - "Keep working on this refactor — wake yourself every 20 minutes and continue where you left off." -The agent picks the cadence, target, and prompt from what you asked, creates the schedule, and reports back. You can manage it the same way — "pause the triage schedule", "make the build check run every 2 minutes instead", "delete it" — or from the [Schedules view](/docs/schedules) and the [CLI](/docs/schedules-cli). +The agent picks the cron cadence, agent settings, and prompt from what you asked, creates the schedule, and reports back. You can manage a new-agent schedule the same way — "pause the triage schedule", "make the build check run every 2 minutes instead", "run it once now", or "delete it" — or from the [Schedules view](/docs/schedules) and the [CLI](/docs/schedules-cli). -An agent scheduling itself to wake up later is a **heartbeat**. See [Paseo MCP](/docs/mcp) for the underlying tools. +An agent scheduling itself to wake up later is a **heartbeat**. Over MCP, an agent can create or delete its heartbeat; changing one means deleting it and creating a replacement. This small surface prevents a heartbeat from silently becoming a different job or agent. See [Paseo MCP](/docs/mcp) for the underlying tools. diff --git a/public-docs/schedules-cli.md b/public-docs/schedules-cli.md index a1efabc40..48ffbd66c 100644 --- a/public-docs/schedules-cli.md +++ b/public-docs/schedules-cli.md @@ -8,7 +8,7 @@ category: Schedules # Schedules from the CLI -`paseo schedule` creates and manages [schedules](/docs/schedules) from your terminal, useful for headless boxes and scripts. +`paseo schedule` creates and manages new-agent [schedules](/docs/schedules) from your terminal, useful for headless boxes and scripts. Every run starts a fresh agent. ## Create @@ -62,16 +62,28 @@ paseo schedule create \ "Review overnight CI failures and summarize anything urgent." ``` -Heartbeat the current agent: +## Heartbeats + +Inside a running Paseo agent, create a heartbeat for that same conversation: ```bash -paseo schedule create \ - --every 20m \ - --target self \ +paseo heartbeat create \ + --cron "*/20 * * * *" \ --name heartbeat \ "Check the current task state and continue with the next useful step." ``` +The heartbeat interface is deliberately small: + +```bash +paseo heartbeat update --cron "*/10 * * * *" +paseo heartbeat delete +``` + +Updating a heartbeat changes only its cron cadence and optional time zone. Its target and prompt stay fixed. Heartbeat commands require `PASEO_AGENT_ID`, which Paseo sets inside agent sessions. + +Heartbeats require a raw `--cron` expression. The `--every` presets below are available only for new-agent schedules. + ## Manage ```bash @@ -87,8 +99,10 @@ paseo schedule delete ## Cadence -Use `--every ` for intervals and `--cron ""` for 5-field cron. Cron schedules default to UTC. Pass `--timezone ` to interpret cron fields in a local wall-clock time zone, for example `--timezone America/New_York`. The persisted `nextRunAt` is still a UTC instant, but it is computed from that local time zone so recurring jobs stay at the same local time across daylight saving time changes. +Use `--cron ""` for a 5-field cron expression. For common cron-compatible cadences, `--every ` accepts presets such as `5m` or `1h` and compiles them to cron. It does not create a rolling interval anchored to creation time. -Interval schedules run once immediately by default; pass `--no-run-now` to wait for the first interval. Cron schedules wait for the next matching time; pass `--run-now` to fire once immediately. +Schedules default to UTC. Pass `--timezone ` to interpret cron fields in a local wall-clock time zone, for example `--timezone America/New_York`. The persisted `nextRunAt` is still a UTC instant, but it is computed from that local time zone so recurring jobs stay at the same local time across daylight saving time changes. + +Schedules wait for the next matching cron time by default. Pass `--run-now` to start one immediate run on creation. When targeting a remote daemon with `--host`, pass `--cwd`; your local working directory may not exist on the remote machine. diff --git a/public-docs/schedules.md b/public-docs/schedules.md index 17611df65..5a1130737 100644 --- a/public-docs/schedules.md +++ b/public-docs/schedules.md @@ -1,6 +1,6 @@ --- title: Schedules -description: Run Paseo agents on a schedule — every few minutes or on a cron. +description: Run fresh agents on cron schedules or wake an existing agent with a heartbeat. nav: Overview order: 25 category: Schedules @@ -8,27 +8,28 @@ category: Schedules # Schedules -A schedule runs an agent for you on a cadence: at this interval or cron time, run this prompt, in this repo, with this agent. +A schedule starts a new agent for you on a cron cadence: at this time, run this prompt, in this repo, with these agent settings. -The target can be: +Paseo also has **heartbeats**. A heartbeat sends a recurring prompt back into one existing agent so it can reassess and continue the same conversation. -- A new agent each run — fresh daily jobs and long-running watchers. -- An existing agent — when you want continuity. -- The agent that created the schedule — heartbeats from inside an agent. +Both concepts use the same cron engine, but their product surfaces stay separate: -Cadence is either an interval, like every 30 minutes, or a cron expression, like every weekday morning. Every run is recorded, and you can pause, resume, run once, update, or delete a schedule at any time. +- **Schedules** create a new agent each run. You can inspect, pause, resume, run once, update, or delete them. +- **Heartbeats** target one existing agent. They are intentionally lightweight: create or delete them over MCP; from the CLI you can also update only their cron period. + +Cron is the canonical cadence. The CLI accepts simple presets such as `5m` or `1h`, but compiles them to cron rather than storing a separate interval type. ## What it's for -- **Overnight refactors:** wake an agent every 30 minutes to continue a scoped refactor, run checks, and leave notes. -- **Heartbeats:** have an agent periodically reassess state and keep moving. -- **Build babysitting:** check CI, EAS, Docker, or release builds until they pass. +- **Fresh recurring jobs:** start a clean agent for daily triage, reports, or maintenance. +- **Heartbeats:** have the current agent periodically reassess state and keep moving. +- **Build babysitting:** keep one agent checking CI, EAS, Docker, or release builds until they pass. - **Daily triage:** scan issues, PRs, and failing checks every morning. - **Maintenance sweeps:** refresh dependencies, audit docs, or clean stale branches. ## Ways to create one -- **In the app** — open the Schedules view and create one with an agent, a cadence, a repo, and a prompt. This is the main way to create and manage schedules. +- **In the app** — open the Schedules view and create one with agent settings, a cron cadence, a repo, and a prompt. This is the main way to create and manage schedules. - **[From chat](/docs/schedules-chat)** — ask the agent in a chat and it sets the schedule up for you. - **[From the CLI](/docs/schedules-cli)** — `paseo schedule create`, for headless boxes and scripts. - **[Over MCP](/docs/mcp)** — agents create and manage schedules programmatically. diff --git a/public-docs/skills.md b/public-docs/skills.md index a4509797e..1c4bd6f86 100644 --- a/public-docs/skills.md +++ b/public-docs/skills.md @@ -23,22 +23,22 @@ When the desktop app finds installed Paseo skills, it keeps the bundled skills u ## `/paseo`, Paseo Reference -The foundational skill. Paseo reference for managing agents and worktrees. Load it when an agent needs to create agents, send them prompts, or manage worktrees. +The foundational skill. Paseo reference for managing agents and workspaces. Load it when an agent needs to create agents, send them prompts, or manage workspace isolation. Not typically invoked directly by users, it's a reference that other skills depend on. ``` -/paseo show me the Paseo CLI surface for creating an agent in a worktree +/paseo show me the Paseo CLI surface for creating an agent in a worktree-isolated workspace ``` ## `/paseo-handoff`, Task Handoff Hands off the current task to another agent with full context. Use it when you say "handoff", "hand off", "hand this to", or want to pass work to another agent. -The receiving agent gets a self-contained briefing with the task, context, relevant files, current state, what's been tried, decisions, acceptance criteria, and constraints. Provider comes from orchestration preferences unless you name one. Supports worktrees when you ask for one. +The receiving agent gets a self-contained briefing with the task, context, relevant files, current state, what's been tried, decisions, acceptance criteria, and constraints. Provider comes from orchestration preferences unless you name one. Supports worktree-isolated workspaces when you ask for one. ``` -/paseo-handoff hand off the auth fix to codex in a worktree +/paseo-handoff hand off the auth fix to codex in a worktree-isolated workspace /paseo-handoff hand this to claude opus for review ``` diff --git a/public-docs/why.md b/public-docs/why.md index 89ce141e0..130c720fa 100644 --- a/public-docs/why.md +++ b/public-docs/why.md @@ -37,7 +37,7 @@ Paseo is a self-hostable platform for running and orchestrating coding agents. I ## Automation - The CLI exposes the same surface as the app. Anything in the UI is scriptable. -- [Paseo tools](/docs/orchestration). Agents can drive Paseo themselves: create worktrees, spawn other agents, open terminals, and send prompts. +- [Paseo tools](/docs/orchestration). Agents can drive Paseo themselves: create isolated workspaces, spawn subagents, open terminals, and send prompts. ## What it isn't diff --git a/public-docs/workspaces.md b/public-docs/workspaces.md index 70ed9b8af..f095c890c 100644 --- a/public-docs/workspaces.md +++ b/public-docs/workspaces.md @@ -1,6 +1,6 @@ --- title: Workspaces -description: Understand Paseo's project, workspace, and session model before setting up agents or git worktrees. +description: Understand how Paseo groups working directories, agents, terminals, and browsers into workspaces. nav: Workspaces order: 10 category: Workspaces @@ -35,14 +35,30 @@ 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 + +Every workspace has an isolation mode: + +- **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. + +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. + ## Creating a workspace -When you create a new workspace, Paseo creates a working directory for it. If you are using git, this is an isolated git worktree on its own branch, so agents can work in parallel without touching your main checkout. Paseo names the branch from your first prompt. +You can create a workspace in the app or from the CLI: + +```bash +paseo workspace create --isolation local --path ~/dev/my-app --title main +paseo workspace create --isolation worktree --path ~/dev/my-app --base main +``` You can also create a workspace without starting an agent right away. The workspace is still there with its working directory ready; you can open terminals, run services, or browse files, then start an agent later. Either way, once the workspace exists you can add more sessions to it. Open a terminal alongside an agent, start a second agent to review changes, or open a browser tab to check a local service. Every session lives as a tab inside the same workspace. +Creating an agent and creating a workspace are separate actions. Pass a workspace ID when you want an agent in a specific existing workspace. A bare `paseo run` from a human shell creates a new local workspace; when one agent runs it, Paseo recognizes the caller and creates a subagent in the caller's workspace. + ## Worktrees Every workspace in Paseo is backed by a working directory. When that directory is a git worktree, you get a separate branch and isolated environment for each task. diff --git a/public-docs/worktrees.md b/public-docs/worktrees.md index 2911d0d9b..e413202b5 100644 --- a/public-docs/worktrees.md +++ b/public-docs/worktrees.md @@ -16,12 +16,12 @@ This page covers the git-specific details: where worktrees live, how branches ar ## Layout and workflow -Worktrees live under `$PASEO_HOME/worktrees/` by default, grouped by a hash of the source checkout path. You can change the base directory with `worktrees.root` in `config.json`. Each worktree gets a random slug; the branch name is chosen when you first launch an agent. +Worktrees live under `$PASEO_HOME/worktrees/` by default, grouped by a hash of the source checkout path. You can change the base directory with `worktrees.root` in `config.json`. Each worktree gets a slug and a branch when its workspace is created. ``` ~/.paseo/worktrees/ └── 1vnnm9k3/ # hash of source checkout path - ├── tidy-fox/ # worktree slug (branch set on first agent) + ├── tidy-fox/ # worktree slug └── bold-owl/ ``` @@ -35,10 +35,46 @@ With a custom root, Paseo keeps the same hashed layout under that directory: } ``` -1. Create a worktree, Paseo runs your setup hooks -2. Launch an agent, a branch is created or assigned +1. Create a workspace with worktree isolation, Paseo creates the worktree and runs your setup hooks +2. Launch one or more agents in that workspace 3. Review the diff against the base branch -4. Merge or archive, archive runs teardown and removes the directory +4. Merge or archive the workspace; after the last workspace using it is archived, Paseo runs teardown and removes the worktree + +## Create a worktree-backed workspace + +The examples below use the current directory as the source checkout. Pass `--path ~/dev/my-app` to create the workspace from another checkout. + +Branch off from a base branch: + +```bash +paseo workspace create \ + --isolation worktree \ + --mode branch-off \ + --new-branch feature/auth \ + --worktree-slug feature-auth \ + --base main +``` + +Check out an existing branch: + +```bash +paseo workspace create \ + --isolation worktree \ + --mode checkout-branch \ + --branch feature/existing \ + --worktree-slug existing-copy +``` + +Or open a pull request in its own workspace: + +```bash +paseo workspace create \ + --isolation worktree \ + --mode checkout-pr \ + --pr-number 2186 +``` + +Add `--forge ` when Paseo cannot infer the forge from the source checkout. ## paseo.json @@ -169,10 +205,12 @@ Services additionally get: - `$PASEO_SERVICE__PORT` / `_URL`, peer service ports and URLs - `$HOST`, `127.0.0.1` for local-only daemons, `0.0.0.0` when the daemon binds all interfaces -## CLI +## Manage the workspace ```bash -paseo run --worktree feature-auth --base main "implement auth" -paseo worktree ls -paseo worktree archive feature-auth +paseo workspace ls +paseo run --workspace "implement auth" +paseo workspace archive ``` + +For the common case, `paseo run --isolation worktree --base main "implement auth"` creates both the workspace and its first agent. diff --git a/skills/paseo-handoff/SKILL.md b/skills/paseo-handoff/SKILL.md index 45f2ea4ae..102297544 100644 --- a/skills/paseo-handoff/SKILL.md +++ b/skills/paseo-handoff/SKILL.md @@ -17,7 +17,7 @@ Read the **paseo** skill. Before choosing a provider, read `~/.paseo/orchestrati ## Parsing arguments 1. **Provider** — explicit user request first; otherwise resolve from `impl` preference (or `ui` if the task is styling-only). -2. **Worktree** — "in a worktree" / "worktree" → create a worktree via Paseo with a short branch name derived from the task, based on the current branch. +2. **Isolation** — "in a worktree" / "worktree" → create a workspace with `isolation: "worktree"`, using a short branch name derived from the task. 3. **Task description** — anything else the user said. ## The handoff prompt @@ -54,18 +54,14 @@ The receiving agent has zero context. Include: ## Launch -Create the agent via Paseo with a `[Handoff] ` title, the briefing as initial prompt, and `relationship: { kind: "detached" }`. +Prepare the handoff in a dedicated workspace: -Use `workspace` for placement: +1. Select the current workspace or call `create_workspace` with the requested isolation. +2. Call `create_agent` with a `[Handoff] ` title, the briefing as initial prompt, and the selected `workspaceId` when explicit placement is needed. +3. Return the agent and workspace to the user, explaining that it remains in your subagent track until they detach it manually. -- No worktree: `workspace: { kind: "current" }`. -- Worktree: `workspace: { kind: "create", source: { kind: "worktree", target: { kind: "branch-off", worktreeSlug: "", branchName: "fix/" } } }`. -- Existing worktree already created by `create_worktree`: `workspace: { kind: "existing", workspaceId: "" }`. - -Do not use `workspace: { kind: "current", cwd: "" }` to place a handoff in a worktree; that keeps the agent in the caller's workspace with only a different runtime cwd. +Do not encode independence as a create mode and do not invoke CLI or wire-level detach operations. Detach is a user gesture in the subagents track. Leave `notifyOnFinish` omitted unless the user explicitly wants no callback. -Handoff agents are siblings/root agents, not your subagents. They must survive you being archived and must not appear in your subagent track. - Don't wait by default — the user decides whether to follow along or move on. Tell them the agent ID and how to follow along (the paseo skill explains). diff --git a/skills/paseo-loop/SKILL.md b/skills/paseo-loop/SKILL.md index 966983e8d..2ee7be549 100644 --- a/skills/paseo-loop/SKILL.md +++ b/skills/paseo-loop/SKILL.md @@ -8,6 +8,8 @@ user-invocable: true A loop is a worker/verifier cycle: launch a worker → check verification → repeat until done or limits hit. Use for "keep trying", "babysit", or "watch this until X." +For lightweight recurring checks that should return to this same conversation, prefer a cron heartbeat (`create_heartbeat`, then `delete_heartbeat` when finished). To change it, delete and recreate it. Use a loop when each iteration needs a worker/verifier lifecycle; use a schedule when each firing should create a fresh independent agent and workspace. + **User's arguments:** $ARGUMENTS ## Prerequisites diff --git a/skills/paseo/SKILL.md b/skills/paseo/SKILL.md index 198b0a0fb..c94595b93 100644 --- a/skills/paseo/SKILL.md +++ b/skills/paseo/SKILL.md @@ -1,48 +1,29 @@ --- name: paseo -description: Paseo reference for managing agents and worktrees. Load whenever you need to create agents, send them prompts, or manage worktrees. +description: Paseo reference for managing workspaces, agents, schedules, and heartbeats. --- Paseo is a daemon that supervises AI coding agents on your machine. Control it through tools or a CLI. -## Worktrees +## Workspaces -**`create_worktree`** — same target union as `create_agent.workspace.source.worktree.target`: +**`create_workspace`** — create a workspace independently of any agent. Required: `isolation` (`local` or `worktree`). Worktree isolation supports `mode: "branch-off" | "checkout-branch" | "checkout-pr"`: use `branchName`/`baseBranch` for a new branch, `branch` for an existing branch, or `prNumber` plus optional `forge`/`projectPath` for a change request. `worktreeSlug` controls the managed path. Returns the workspace descriptor centered on `workspaceId`. -- From a PR: `{ target: { kind: "checkout-pr", githubPrNumber: 503 } }`. -- Branch off a base: `{ target: { kind: "branch-off", worktreeSlug: "foo", branchName: "fix/foo", baseBranch: "main" } }`. -- Checkout an existing branch: `{ target: { kind: "checkout-branch", branch: "feat/bar" } }`. +**`list_workspaces`** — list active workspaces. -Returns `{ branchName, worktreePath, workspaceId }`. Pass `cwd` to target a specific repo. +**`archive_workspace`** — `{ workspaceId }`. Archives the workspace, its agents, and its terminals. Local directories remain; Paseo removes an owned worktree only after its final active workspace reference is archived. -In `branch-off`, `worktreeSlug` controls the worktree path slug and `branchName` controls the git branch. If `branchName` is omitted, Paseo defaults it from `worktreeSlug`. The returned `branchName` is authoritative; checkout and PR flows may return a branch name that differs from any requested slug. - -**`list_worktrees`** — current repo (or pass `cwd`). -**`archive_worktree`** — `{ worktreePath }` or `{ worktreeSlug }`. Removes worktree and branch. +Worktree creation and reference accounting are implementation details of `isolation: "worktree"`. ## Agents -**`create_agent`** — required: `relationship`, `workspace`, `title`, `provider` (`claude/opus`, `codex/gpt-5.4`, …), `initialPrompt`. Common: `notifyOnFinish`, `settings`, `labels`. Returns `{ agentId, … }`. +**`create_agent`** — required: `title`, `provider` (`claude/opus`, `codex/gpt-5.4`, …), `initialPrompt`. Optional: `workspaceId`, `notifyOnFinish`, `settings`, `labels`. Returns `{ agentId, workspaceId, … }`. Initial runtime settings live under `settings`: `modeId`, `thinkingOptionId`, and provider-specific `features`. For Codex fast mode, pass `settings: { features: { "fast_mode": true } }` when creating the agent. -To create a new worktree and launch an agent in it, use `create_agent.workspace.source.kind = "worktree"`. Use `create_worktree` separately only when you need a worktree without launching an agent, or when you need a split flow; in a split flow, pass the returned `workspaceId` to `create_agent` with `workspace: { kind: "existing", workspaceId }`. +Agent-scoped creation always creates your subagent. Omit `workspaceId` to use your current workspace; pass a workspace returned by `create_workspace` for isolated delegation. Placement never changes parentage. -### Agent relationships - -`relationship` controls parentage only: - -- `{ kind: "subagent" }` — child under your subagents track. Use for advisors, committee members, planners, implementers, auditors, loop workers, and any agent whose lifetime belongs to your task. -- `{ kind: "detached" }` — root/sibling agent. Use for handoffs and fire-and-forget delegations the user may continue after you are archived. - -`workspace` controls placement only: - -- `{ kind: "current" }` — same workspace as the caller, with optional `cwd`. -- `{ kind: "existing", workspaceId: string, cwd?: string }` — attach to an existing workspace, usually from `create_worktree`. -- `{ kind: "create", source: { kind: "directory", path?: string } }` — new workspace rooted at a directory. -- `{ kind: "create", source: { kind: "worktree", cwd?: string, target: { kind: "branch-off", worktreeSlug?: string, branchName?: string, baseBranch?: string } } }` -- `{ kind: "create", source: { kind: "worktree", cwd?: string, target: { kind: "checkout-branch", branch: string } } }` -- `{ kind: "create", source: { kind: "worktree", cwd?: string, target: { kind: "checkout-pr", githubPrNumber: number } } }` +Detach is an explicit user action in the subagents track, not an agent tool. A cross-workspace child remains your subagent even though it also appears as a normal tab in its workspace. Agent-scoped `create_agent` defaults `notifyOnFinish` to true. Set it to `false` only for truly fire-and-forget agents. @@ -70,6 +51,10 @@ Only set feature IDs returned by `inspect_provider`. For Codex fast mode, look f **`create_heartbeat`** — sends you a prompt on a cron cadence. Required: `prompt`, `cron`. Optional: `timezone`, `name`, `maxRuns`, `expiresIn`. Use for reminders, PR/build babysitting, and status checks that should return to this conversation. +**`delete_heartbeat`** stops it. MCP intentionally exposes no heartbeat update tool; delete and recreate when its task or cadence changes. + +Schedules have the full list/inspect/update/pause/resume/run-once/log/delete surface. Heartbeats deliberately do not. + ## Models `claude/sonnet` (default), `claude/opus` (harder reasoning), `codex/gpt-5.4` (frontier coding), `claude/haiku` (tests only). @@ -110,16 +95,19 @@ For agent-scoped `create_agent` and background `send_agent_prompt`, leave `notif Don't poll `list_agents` or `get_agent_status` to "check on" a running agent. The notification will tell you. -## CLI parity +## CLI semantics -The `paseo` CLI is a thin wrapper over the same daemon. Same surface: +The CLI and tools use the same ownership semantics even where their syntax differs: ```bash -paseo run --provider codex/gpt-5.4 --mode full-access --worktree feat/x "" +paseo workspace create --isolation worktree --mode branch-off --new-branch fix-x --base main +paseo workspace create --isolation worktree --mode checkout-branch --branch existing-work +paseo workspace create --isolation worktree --mode checkout-pr --pr-number 42 +paseo run --provider codex/gpt-5.4 --mode full-access --workspace "" paseo send "" paseo ls -paseo worktree ls paseo schedule create --cron "*/15 * * * *" "ping main build" +paseo heartbeat create --cron "*/15 * * * *" "check the build" ``` Discover with `paseo --help` and `paseo --help`.