diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 76fa00d00..18f4c8b37 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -148,6 +148,7 @@ jobs: packages/ui/src/stores/session-generation-recovery.test.ts packages/ui/src/stores/session-pagination.test.ts packages/ui/src/stores/session-pending-state.test.ts + packages/ui/src/stores/session-tree.test.ts packages/ui/src/stores/session-previews.test.ts packages/ui/src/stores/workspace-load-readiness.test.ts packages/ui/src/types/session.test.ts @@ -171,6 +172,8 @@ jobs: packages/ui/src/stores/session-request-authority.test.ts packages/ui/src/stores/session-send-lifecycle.test.ts packages/ui/src/stores/session-status.test.ts + packages/ui/src/stores/worktree-ready.test.ts + packages/ui/src/stores/worktrees-delete.test.ts - name: Test server run: node --import tsx --test "packages/server/src/**/*.test.ts" diff --git a/AGENTS.md b/AGENTS.md index f4aadc84d..7472c56bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,8 +51,8 @@ Behavior for agents: - Use the `write` tool only when creating new files from scratch. ## V2 Runtime Launch -- Launch the release executable from PowerShell with the dedicated WebView2 profile, CDP port, Rust backtraces, and Node source maps described in `MIGRATION_V2.md`. -- Stop the running CodeNomad instance before rebuilding the same release path, then relaunch it from the independent OpenCode TUI. +- Enable **Developer Mode** from the session tab bar and fully restart CodeNomad once; do not configure a fixed CDP port or a manual WebView2 profile. +- Rebuild Electron before calling `codenomad.act({ action: "restart" })`. For Windows Tauri, stop and relaunch the release executable only when the linker cannot replace it; never stop the shared OpenCode daemon. ## Commit Message Guidelines - When creating commits, use detailed commit messages: a concise conventional-style subject followed by body paragraphs that explain the user-visible behavior change, the implementation approach, important edge cases or platform considerations, and the validation or test coverage added. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e8f195b15..a4c5b9272 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -112,7 +112,7 @@ Then open a pull request on GitHub targeting the `dev` branch. ### OpenCode V2 Boundaries - Server and UI follow `@opencode-ai/client@beta`. Refresh the client lock before API audits or release validation. The runtime CLI is managed independently, and startup must not reject an otherwise compatible service solely for a different version string. Review current OpenCode documentation, installed declarations, and proxy/API parity whenever the client contract changes. -- Upgrade references: [OpenCode releases](https://github.com/anomalyco/opencode/releases), [OpenCode documentation](https://opencode.ai/docs/), and `node_modules/@opencode-ai/client/dist/promise/`. +- Upgrade references: the published npm `beta` tags, [OpenCode V2 documentation](https://opencode.ai/v2/docs/), [OpenCode V2 HTTP API](https://opencode.ai/v2/docs/api/), and `node_modules/@opencode-ai/client/dist/promise/`. Public GitHub releases may still describe V1 and are not a V2 compatibility authority. - `packages/server/src/workspaces/opencode-service.ts` uses the selected host or WSL CLI's official `service status`, `service start`, and `service get password` lifecycle to connect to one externally owned global daemon. CodeNomad owns no private port, database, registration, or daemon PID and never stops the daemon on backend shutdown. - WSL requires Windows localhost forwarding and runs the Linux CLI lifecycle inside the distribution; never inspect or signal Linux PIDs from Windows. - OpenCode owns the global daemon's standard state and database. Configured allowed environment variables apply only when CodeNomad starts a missing daemon; an existing daemon is unchanged, and legacy `OPENCODE_DB`/`XDG_STATE_HOME` ownership settings are ignored. diff --git a/DESKTOP_V2_COMPARISON.md b/DESKTOP_V2_COMPARISON.md index 1ee1c1497..dadc377d2 100644 --- a/DESKTOP_V2_COMPARISON.md +++ b/DESKTOP_V2_COMPARISON.md @@ -4,9 +4,9 @@ This review compares: -- CodeNomad `DEV-v2` at `dea20996` (2026-09-03). -- Official OpenCode Desktop V2 from `anomalyco/opencode` branch `upstream/beta` at `eb1ac54d73` (2026-08-25). -- CodeNomad declares `@opencode-ai/client@beta`; UI and server resolve `beta-18999`, while the runtime CLI version is managed independently. +- CodeNomad `DEV-v2` at `44a1816b` (2026-09-04), merged with the worktree-session changes reviewed here. +- Published OpenCode V2 `beta-18999` at `887f319769c55718e3e64f64b32c9aafb13c5d66`, including an operation-by-operation and generated-declaration comparison from `beta-18684` at `106629aa118086be7def6123241a9bf056ba77b6`. +- CodeNomad declares `@opencode-ai/client@beta`; UI and server resolve `beta-18999`, the independently managed runtime CLI reports `beta-18999`, and the official V2 documentation is rooted at . The official reference is `packages/desktop` for the Electron host, `packages/app` for the shared UI, and the V2 client, protocol, schema, server, and core packages for wire behavior. Older `v2`, `opencode-2-0`, and intermediate `desktop-v2-*` branches are historical, not the current Desktop V2 reference. @@ -18,6 +18,17 @@ CodeNomad implements the important V2 architecture rather than emulating the V1 The comparison and subsequent beta-contract audit found concrete CodeNomad defects in pagination, navigation, model projection, inbox delivery, follow behavior, background control, location selectors, and proxy route coverage. Those defects are fixed in the commits accompanying this document. The remaining differences are scoped workflow defects, release hardening, or optional Desktop features. None requires restoring V1 code or replacing native V2 cursors. +## Published Beta 18684 to 18999 Impact + +The generated OpenAPI surface grows from 136 to 140 operations. It adds only `plugin.awaitActivation`, `plugin.check`, `plugin.update`, and `rpc.call`; it removes nothing and does not change the session, message, location, worktree, Shell, or PTY operation sets. Session pages retain the `{ data, cursor }` envelope, exclusive opaque cursors, and native ordering semantics. + +- **Pagination:** all session continuations now send only the returned cursor. This includes UI inventory, worktree family transactions, proxy validation, and server-side Yolo restoration. Repeated-cursor and bounded-inventory checks remain where destructive worktree operations require a complete snapshot. +- **Locations:** OpenCode's unavailable-location change is official-app recovery UI, not an API change. CodeNomad preserves unavailable restored tabs and native session locations but does not weaken logical-workspace ownership to recover a session whose source is outside every owned root/worktree. +- **Moves:** session-aware execution and chained-move continuation improve the native server. CodeNomad still refuses worktree family moves while any member is active, serializes the project transaction, rechecks before each move, verifies the complete inventory, and rolls back on failure. +- **Events:** the client now isolates cancellation among consumers of its lazy shared stream, which CodeNomad inherits through `OpenCode.make()`. Typed `rpc.*` events require a native location and are routed only to owning logical workspaces. Raw `/api/event`, generic RPC calls, and plugin lifecycle mutations remain blocked by the workspace proxy. +- **PTY and Shell:** socket detach, post-exit drain, and fast-output fixes are runtime-only. CodeNomad inherits them without changing its HTTP proxy or introducing an embedded terminal; owned running PTYs, persistent PTYs, and Shells continue to block unsafe worktree deletion. +- **Generated types:** the new plugin state shape is safe because plugin IDs are narrowed before projection. The config update-policy rename is unused, provider/model canonical fields are additive, and native `SessionInfo.location`, `workspaceID`, and durable JSON `metadata` remain preserved. + ## Closed Findings ### Message history pagination diff --git a/MIGRATION_V2.md b/MIGRATION_V2.md index 1afe1ba95..cd27b1006 100644 --- a/MIGRATION_V2.md +++ b/MIGRATION_V2.md @@ -6,7 +6,7 @@ This branch replaces CodeNomad's OpenCode V1 SDK, custom plugin, and per-workspa The work grew beyond an SDK swap. It also introduces location-based ownership, native Forms and Shell resources, project-wide session pagination, reconnect reconciliation, bounded virtualized timelines, multi-window desktop state, and a content-addressed restore format. -Server and UI declare `@opencode-ai/client@beta`. The latest published beta is always the source of truth. Refreshing that dependency updates `node_modules` and rewrites `package-lock.json`; the lock is only the generated snapshot of the last dependency resolution, never a compatibility authority. Refresh it before migration audits or builds. It does not constrain the independently managed runtime CLI. The tested 2026-09-03 client and runtime snapshot is `beta-18999`. +Server and UI declare `@opencode-ai/client@beta`. The latest published beta is always the source of truth. Refreshing that dependency updates `node_modules` and rewrites `package-lock.json`; the lock is only the generated snapshot of the last dependency resolution, never a compatibility authority. Refresh it before migration audits or builds. It does not constrain the independently managed runtime CLI. The tested 2026-09-04 client and runtime snapshot is `beta-18999`. The incremental comparison with official OpenCode Desktop V2, including closed findings and remaining gaps, is recorded in [`DESKTOP_V2_COMPARISON.md`](DESKTOP_V2_COMPARISON.md). @@ -16,27 +16,27 @@ The incremental comparison with official OpenCode Desktop V2, including closed f - Use native APIs for projects, sessions, messages, prompts, commands, models, agents, providers, MCP, permissions, Forms, files, VCS, instructions, Shells, and PTYs. - Use native session lifecycle and output events, including `session.created`, `session.renamed`, `session.moved`, `session.status`, `session.idle`, `session.execution.*`, `session.compaction.*`, `session.text.*`, `session.reasoning.*`, and `session.tool.*`. - Use `@opencode-ai/client/solid` `createData` for live message, tool, permission, and Form projection while preserving REST-loaded history and optimistic local sends. -- Replace the legacy Question request lifecycle with native Forms. Question tool output rendering remains. The proxy still contains inert legacy Question allowlist entries, but `beta-18866` declares no Question client API and its runtime does not serve those routes. +- Replace the legacy Question request lifecycle with native Forms. Question tool output rendering remains. The proxy still contains inert legacy Question allowlist entries, but `beta-18999` declares no Question client API and its runtime does not serve those routes. - Replace shell-mode prompts with native `session.shell`. - Replace CodeNomad background processes with native `shell.*` resources. The Status UI lists, displays bounded output for, and removes Shells; create/output/timeout routes remain available through the ownership-checked proxy. Interactive `pty.*` terminals remain separate. - Store voice-mode instructions with `session.instructions.entry` and synchronize them before prompts, commands, and session Shell calls. - Inherit native durable JSON `SessionMetadata` directly from `SessionInfo`. Do not widen it to arbitrary `unknown` values or maintain a parallel CodeNomad-only metadata contract. - Keep the narrow project-local `codenomad.automation` exception on the V2 `setup` and `tool.transform` contract; it remains active under `beta-18999`. -### Beta 18866 Contract Review (Historical) +### Published Beta Contract Audit -The `beta-18414` to `beta-18866` review found these additive client surfaces: +The authoritative npm artifacts map `beta-18684` to OpenCode `106629aa118086be7def6123241a9bf056ba77b6` and `beta-18999` to `887f319769c55718e3e64f64b32c9aafb13c5d66`. Their generated OpenAPI contracts contain 136 and 140 operations respectively. The only added operations are `plugin.awaitActivation`, `plugin.check`, `plugin.update`, and typed `rpc.call`; no operation was removed, and the session, message, location, worktree, Shell, and PTY route sets are unchanged. -| Native addition | CodeNomad decision | Reason | -| --- | --- | --- | -| `plugin.check` | Deferred candidate for a future plugin inventory UI. | It is read-like and location-scoped, but CodeNomad currently has no plugin-management workflow. | -| `plugin.update` | Keep blocked. | It installs or replaces executable plugin code and requires an explicit trusted update flow, confirmation, and mutation fencing. | -| `rpc.call` and typed plugin RPC/events | Keep the generic route blocked; permit only reviewed RPC definitions through a constrained broker if a concrete integration needs them. | A blanket RPC proxy would bypass the method-specific allowlist. The existing Developer Mode bridge has different process and authentication boundaries and is not automatically replaced by plugin RPC. | -| `experimental.persistentPty.read` | Keep blocked. | CodeNomad uses native Shell resources for background output and has no owned persistent-session-terminal lifecycle. | -| `vcs.base` and diff base selection | Deferred read-only candidate. | The current Git Changes UI displays working-tree and index changes and does not yet offer base-branch comparison. | -| `Service.stop({ pty })` handoff/clear behavior | Do not adopt. | CodeNomad does not own or stop the shared OpenCode service. | +| Reviewed change | CodeNomad impact and decision | +| --- | --- | +| Session projection storage and unavailable-location recovery ([#46075](https://github.com/anomalyco/opencode/pull/46075), [#46215](https://github.com/anomalyco/opencode/pull/46215)) | The pagination envelope, ordering, and exclusive cursor contract are unchanged. Unavailable-location recovery is an official-app workflow, not a wire change. CodeNomad keeps its own restore preservation and location-ownership checks rather than weakening source ownership. | +| Shared event-consumer isolation ([#46393](https://github.com/anomalyco/opencode/pull/46393)) | `OpenCode.make()` now supplies one lazy shared connection with subscriber-local cancellation. CodeNomad adopts this automatically and retains cancellation of superseded `createData` and REST consumers. No custom transport is needed. | +| Session-aware execution and chained active moves ([#46442](https://github.com/anomalyco/opencode/pull/46442), `6dd1733b`) | Upstream can preserve a running model continuation across native location handoffs. CodeNomad intentionally still blocks active members before a family move and rechecks each member during the transaction; upstream continuation does not replace family locking, complete inventory, verification, or rollback. | +| PTY socket detach and Shell drain fixes ([#46068](https://github.com/anomalyco/opencode/pull/46068), [#46085](https://github.com/anomalyco/opencode/pull/46085)) | These are server-runtime fixes with no HTTP shape change. CodeNomad inherits them from the shared service. It still has no embedded PTY socket UI and continues to use owned PTY/Shell inventories as worktree-deletion blockers. | +| Plugin lifecycle, typed RPC, and custom `rpc.*` events ([#46105](https://github.com/anomalyco/opencode/pull/46105)) | Plugin inventory remains read-only. The new plugin mutations and generic RPC call stay outside the workspace proxy allowlist, and raw `/api/event` remains blocked. Custom events have a mandatory native location, so the existing event bridge scopes them to owning logical workspaces; no CodeNomad feature consumes them. | +| `PluginInfo`, config-update, and provider/model canonical fields | The installed declarations are compatible with current consumers: plugin IDs are narrowed before use, CodeNomad does not consume the renamed update policy, and canonical provider/model fields are additive. | -The subsequent refresh to `beta-18999` retained these integrations. Both UI and server resolve the same client version, and the independently managed runtime used for the final native validation also reported `beta-18999`. +The earlier `beta-18414` baseline also confirmed durable `SessionInfo.metadata`, `experimental.persistentPty.read`, and `vcs.base`. CodeNomad preserves native JSON session metadata, keeps persistent-PTY reads blocked because it owns no persistent terminal lifecycle, and defers `vcs.base` until the Git Changes UI has a reviewed base-comparison workflow. `Service.stop({ pty })` handoff remains unused because CodeNomad never owns or stops the shared service. Both UI and server resolve `beta-18999`, and the independently managed runtime used for native validation reports the same published beta. ## Shared Service Model @@ -64,7 +64,7 @@ The subsequent refresh to `beta-18999` retained these integrations. Both UI and ## Sessions, Streaming, and Reconciliation - Query a complete project-scoped session inventory across root and worktree subpaths without one request per parent; native `global` projects remain scoped to the selected workspace directory. -- Follow native `cursor.next` values for session and message pagination. The proxy decodes session cursors only to validate embedded directory/project scope, strips competing selectors, and forwards the original cursor unchanged. +- Follow native `cursor.next` values for session and message pagination. Continuation requests carry only the opaque cursor (plus a required path identity such as `sessionID` for messages). The proxy rejects competing session selectors, resolves the page through the authenticated native client, validates every returned location, and returns the native envelope unchanged; it never decodes or synthesizes cursors. - Hydrate only missing ancestor chains with `session.get` and fetch active status for later session pages. - Load message history lazily into a replace-in-place 200-message resident window. Older, newer, oldest, and latest navigation swaps authoritative pages without accumulating the transcript, while delayed REST responses cannot overwrite newer event state. - Route location-scoped events to every owning logical workspace and resolve locationless session, permission, Form, Shell, and PTY events through native ownership. @@ -109,7 +109,7 @@ Before deleting a Git worktree, CodeNomad now: 8. Removes the Git worktree inside the same rollback boundary. 9. Restores moved sessions if verification or deletion fails. -Git status is hybrid: native `vcs.status` is augmented with CodeNomad server detail data. Selected-file diff, stage, unstage, and commit remain server operations. Although `beta-18866` exposes native worktree creation and removal, CodeNomad retains its server workflow for ownership fencing, session evacuation, verification, and rollback semantics. +Git status is hybrid: native `vcs.status` is augmented with CodeNomad server detail data. Selected-file diff, stage, unstage, and commit remain server operations. Although `beta-18999` exposes native worktree creation and removal, CodeNomad retains its server workflow for ownership fencing, session evacuation, verification, and rollback semantics. ## Proxy and Security Boundaries @@ -137,15 +137,11 @@ Git status is hybrid: native `vcs.status` is augmented with CodeNomad server det - Fence late workspace creation and cleanup so cancelled restore requests cannot leak or delete the wrong logical instance. - Build Electron and Tauri server resources reproducibly from the integrity-pinned root workspace lock for the requested OS/CPU target; no independent server lockfile or prebuild dependency repair remains. -### Native Release Debug Launch +### Developer Mode Validation -For interactive validation of the native V2 application, use the existing release binary rather than `tauri dev`, which compiles and runs a different debug environment. Enable **Developer Mode** from the session tab bar and restart CodeNomad when prompted. The native host then selects its derived persistent developer profile, enables Rust backtraces and Node source maps, and opens CDP on a dynamically assigned loopback port. +For interactive validation of the native V2 application, use a release build rather than `tauri dev`, which compiles and runs a different debug environment. Enable **Developer Mode** from the session tab bar and fully restart CodeNomad once. The native host owns its derived persistent browser profile, dynamically assigned loopback CDP endpoint, backtraces, and source maps; do not set manual WebView2 or remote-debugging environment variables and do not assume port `9223`. -```powershell -& "$PWD\packages\tauri-app\target\release\codenomad-tauri.exe" -``` - -The Developer Mode bridge discovers and verifies the actual dynamic `cdpUrl`; do not set or assume port `9223`. Stop the running instance before rebuilding this release path, then relaunch it from the independent OpenCode TUI or through the pinned `codenomad.act({ action: "restart" })` workflow. +After rebuilding Electron, use `codenomad.act({ action: "restart" })` so the persistent OpenCode adapter can reconnect this session to the same pinned host and artifact identity in the new native process generation. For Windows Tauri, stop and relaunch `packages/tauri-app/target/release/codenomad-tauri.exe` manually only when the linker cannot replace the running binary. The shared OpenCode daemon remains alive in both cases. See `dev-docs/DEVELOPER_MODE.md` for the complete contract and trust boundaries. ## Removed Legacy Architecture @@ -196,4 +192,4 @@ At the 2026-09-03 timeline stabilization head (`dea20996`): - The generated V2 client remains experimental. Review its current documentation, installed declarations, proxy/API parity, runtime health, and `/api/plugin` failures whenever the beta contract changes. The SDK documentation describes an alternative embedded host; CodeNomad uses the network client. - V1-style global plugins are outside the CodeNomad client migration. Under the reviewed V2 contract through `beta-18999`, the installed After Effects, Blender, Microsoft 365, Resolve, Unreal, Ponytail, and Gemini Auth integrations require independent migrations to a V2 definition with an `id` and `setup` or `effect`. -- Upgrade references: [OpenCode releases](https://github.com/anomalyco/opencode/releases), [OpenCode V2 documentation](https://opencode.ai/v2/docs/), `packages/server/node_modules/@opencode-ai/client/dist/promise/`, and `packages/ui/node_modules/@opencode-ai/client/dist/promise/`. +- Upgrade references: the published npm `beta` artifacts, [OpenCode V2 documentation](https://opencode.ai/v2/docs/), [OpenCode V2 HTTP API](https://opencode.ai/v2/docs/api/), `packages/server/node_modules/@opencode-ai/client/dist/promise/`, and `packages/ui/node_modules/@opencode-ai/client/dist/promise/`. Public GitHub releases may still describe V1 and are not a V2 compatibility authority. diff --git a/dev-docs/SUMMARY.md b/dev-docs/SUMMARY.md index 26e70235e..c19a0a99b 100644 --- a/dev-docs/SUMMARY.md +++ b/dev-docs/SUMMARY.md @@ -158,7 +158,7 @@ dev-docs/ Development documentation ### External: -- OpenCode server API: https://opencode.ai/docs/server/ +- OpenCode V2 HTTP API: https://opencode.ai/v2/docs/api/ - Electron docs: https://electronjs.org/docs - SolidJS docs: https://solidjs.com - Kobalte UI: https://kobalte.dev diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index 17e5cfbaf..7b889d5a5 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -93,9 +93,15 @@ export interface WorktreeDescriptor { slug: string /** Absolute directory path on the server host. */ directory: string + /** Equivalent path in the OpenCode service namespace (notably WSL). */ + serviceDirectory?: string + /** Exact path registered in Git's worktree inventory. */ + registeredDirectory?: string kind: WorktreeKind /** Optional VCS branch name when available. */ branch?: string + /** Commit recorded by the Git worktree inventory. */ + head?: string } export interface WorktreeListResponse { @@ -110,6 +116,16 @@ export interface WorktreeCreateRequest { branch?: string } +export interface WorktreeSessionMoveRequest { + worktreeSlug: string +} + +export interface WorktreeSessionMoveResponse { + rootSessionId: string + sessionIds: string[] + worktreeSlug: string +} + export type GitChangeKind = "added" | "modified" | "deleted" | "renamed" | "copied" | "untracked" | "unmerged" export interface WorktreeGitStatusEntry { diff --git a/packages/server/src/opencode/automation-plugin.test.ts b/packages/server/src/opencode/automation-plugin.test.ts index 8dbc2b76c..dfc7c4e92 100644 --- a/packages/server/src/opencode/automation-plugin.test.ts +++ b/packages/server/src/opencode/automation-plugin.test.ts @@ -6,6 +6,7 @@ import path from "node:path" import test from "node:test" import { AUTOMATION_BRIDGE_PATH, + automationBridgeDirectory, automationBridgeDirectories, createAutomationBridgeRegistration, parseDeveloperAction, @@ -50,6 +51,25 @@ function closeServer(server: http.Server | undefined): Promise { return new Promise((resolve) => server?.close(() => resolve()) ?? resolve()) } +function isolateAutomationBridgeDirectory(root: string): () => void { + const previous = { + HOME: process.env.HOME, + LOCALAPPDATA: process.env.LOCALAPPDATA, + WSL_DISTRO_NAME: process.env.WSL_DISTRO_NAME, + XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR, + } + process.env.HOME = root + process.env.LOCALAPPDATA = root + process.env.XDG_RUNTIME_DIR = root + delete process.env.WSL_DISTRO_NAME + return () => { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + } +} + test("validates Developer Mode actions", () => { assert.deepEqual(parseDeveloperAction({ action: "type", ref: "e4", text: "CodeNomad" }), { action: "type", @@ -100,8 +120,7 @@ test("removes only the generated legacy global plugin shim", async () => { test("restart waits for a new native generation and returns a fresh inspection", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-automation-restart-")) - const previousLocalAppData = process.env.LOCALAPPDATA - process.env.LOCALAPPDATA = root + const restoreEnvironment = isolateAutomationBridgeDirectory(root) const definitions: ToolDefinition[] = [] let removeOld: (() => Promise) | undefined let removeNew: (() => Promise) | undefined @@ -162,16 +181,14 @@ test("restart waits for a new native generation and returns a fresh inspection", await closeServer(newServer) await closeServer(preexistingServer) await Promise.all(distractorServers.map(closeServer)) - if (previousLocalAppData === undefined) delete process.env.LOCALAPPDATA - else process.env.LOCALAPPDATA = previousLocalAppData + restoreEnvironment() await rm(root, { recursive: true, force: true }) } }) test("keeps inspected targets isolated per plugin setup", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-automation-isolation-")) - const previousLocalAppData = process.env.LOCALAPPDATA - process.env.LOCALAPPDATA = root + const restoreEnvironment = isolateAutomationBridgeDirectory(root) let removeBridge: (() => Promise) | undefined let server: http.Server | undefined try { @@ -190,16 +207,14 @@ test("keeps inspected targets isolated per plugin setup", async () => { } finally { await removeBridge?.() await closeServer(server) - if (previousLocalAppData === undefined) delete process.env.LOCALAPPDATA - else process.env.LOCALAPPDATA = previousLocalAppData + restoreEnvironment() await rm(root, { recursive: true, force: true }) } }) test("pins parallel sessions to their independently inspected bridges", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-automation-sessions-")) - const previousLocalAppData = process.env.LOCALAPPDATA - process.env.LOCALAPPDATA = root + const restoreEnvironment = isolateAutomationBridgeDirectory(root) const removals: Array<() => Promise> = [] const servers: http.Server[] = [] try { @@ -220,16 +235,14 @@ test("pins parallel sessions to their independently inspected bridges", async () } finally { await Promise.all(removals.map((remove) => remove())) await Promise.all(servers.map(closeServer)) - if (previousLocalAppData === undefined) delete process.env.LOCALAPPDATA - else process.env.LOCALAPPDATA = previousLocalAppData + restoreEnvironment() await rm(root, { recursive: true, force: true }) } }) test("prunes stale registry pressure before limiting discovery", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-automation-stale-")) - const previousLocalAppData = process.env.LOCALAPPDATA - process.env.LOCALAPPDATA = root + const restoreEnvironment = isolateAutomationBridgeDirectory(root) let removeBridge: (() => Promise) | undefined let server: http.Server | undefined try { @@ -238,7 +251,7 @@ test("prunes stale registry pressure before limiting discovery", async () => { : { result: { target: { id: "live", title: "Live", url: "http://app.test" }, nodes: [], diagnostics: [] } }) server = bridge.server removeBridge = await publishAutomationBridge(createAutomationBridgeRegistration(bridge.url)) - const directory = path.join(root, "CodeNomad", "automation-bridges") + const directory = automationBridgeDirectory() const base = Date.now() + 10_000 for (let index = 0; index < 70; index += 1) { const startedAt = base + index @@ -257,8 +270,7 @@ test("prunes stale registry pressure before limiting discovery", async () => { } finally { await removeBridge?.() await closeServer(server) - if (previousLocalAppData === undefined) delete process.env.LOCALAPPDATA - else process.env.LOCALAPPDATA = previousLocalAppData + restoreEnvironment() await rm(root, { recursive: true, force: true }) } }) diff --git a/packages/server/src/permissions/opencode-yolo-metadata.test.ts b/packages/server/src/permissions/opencode-yolo-metadata.test.ts index a71709988..82987a29b 100644 --- a/packages/server/src/permissions/opencode-yolo-metadata.test.ts +++ b/packages/server/src/permissions/opencode-yolo-metadata.test.ts @@ -92,8 +92,8 @@ describe("OpenCode Yolo persistence", () => { }, ]) assert.deepEqual(listInputs, [ - { directory: "/repo", limit: 10_000, cursor: undefined }, - { directory: "/repo", limit: 10_000, cursor: "page-2" }, + { directory: "/repo", limit: 10_000 }, + { cursor: "page-2" }, ]) }) diff --git a/packages/server/src/permissions/opencode-yolo-metadata.ts b/packages/server/src/permissions/opencode-yolo-metadata.ts index eee343f62..84d9db784 100644 --- a/packages/server/src/permissions/opencode-yolo-metadata.ts +++ b/packages/server/src/permissions/opencode-yolo-metadata.ts @@ -49,7 +49,9 @@ export function createOpencodeYoloPersistence( const sessions: SessionInfo[] = [] let cursor: string | undefined do { - const page = await client.session.list({ directory, limit: SESSION_LIST_LIMIT, cursor }) + const page = await client.session.list(cursor + ? { cursor } + : { directory, limit: SESSION_LIST_LIMIT }) sessions.push(...page.data) cursor = page.cursor.next ?? undefined } while (cursor) diff --git a/packages/server/src/server/__tests__/instance-proxy.test.ts b/packages/server/src/server/__tests__/instance-proxy.test.ts index e8809887a..ce5a54114 100644 --- a/packages/server/src/server/__tests__/instance-proxy.test.ts +++ b/packages/server/src/server/__tests__/instance-proxy.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, it } from "node:test" import { Readable } from "node:stream" import Fastify, { type FastifyInstance } from "fastify" import replyFrom from "@fastify/reply-from" -import type { OpenCodeClient, SessionInfo } from "@opencode-ai/client" +import type { LocationRef, OpenCodeClient, SessionInfo } from "@opencode-ai/client" import type { Logger } from "../../logger" import { redactSecrets, registerInstanceProxyRoutes, type InstanceProxyWorkspaceManager } from "../http-server" import { WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuation" @@ -11,8 +11,6 @@ import { WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuat const apps: FastifyInstance[] = [] afterEach(async () => Promise.all(apps.splice(0).map((app) => app.close()))) -const cursor = (value: object) => Buffer.from(JSON.stringify(value)).toString("base64url") - function logger(): Logger { const value = { debug() {}, trace() {}, error() {}, isLevelEnabled() { return false } } return value as unknown as Logger @@ -21,13 +19,14 @@ function logger(): Logger { async function harness( sessionDirectory = "/repo/worktree", activeSessions: Record = {}, - sessionLocations: Record = {}, + sessionLocations: Record = {}, workspacePath = "/repo", serviceDirectory = workspacePath, pathMappings: Record = {}, ptyDirectories: Record = {}, shellDirectories: Record = {}, directoryMappings: Record = {}, + sessionListLocation: LocationRef = { directory: sessionDirectory, workspaceID: "worktree-location" }, ) { const upstream = Fastify() apps.push(upstream) @@ -84,9 +83,13 @@ async function harness( sessionGets.push(sessionID) const location = sessionLocations[sessionID] ?? sessionDirectory if (location instanceof Error) throw location - return { id: sessionID, location: { directory: location } } as SessionInfo + return { id: sessionID, location: typeof location === "string" ? { directory: location } : location } as SessionInfo }, active: async () => activeSessions, + list: async () => ({ + data: [{ id: "listed", projectID: "owned-project", location: sessionListLocation, time: { created: 1, updated: 1 } } as SessionInfo], + cursor: { next: null }, + }), }, pty: { list: async () => ({ @@ -121,6 +124,7 @@ async function harness( invalidateSharedServiceConnection: () => { invalidations += 1 }, getInstanceAuthorizationHeader: () => "Basic internal-secret", getServiceDirectory: () => serviceDirectory, + getServiceLocation: () => ({ directory: serviceDirectory, workspaceID: "owned-location" }), getServiceDirectoryForPath: async (_id, directory) => directory === workspacePath ? serviceDirectory : owned.has(directory) ? directoryMappings[directory] ?? directory : undefined, @@ -390,7 +394,7 @@ describe("instance proxy location enforcement", () => { url: "/workspaces/workspace/instance/api/session?project=owned-project", }) assert.equal(response.statusCode, 200) - assert.equal(JSON.parse(response.body).url, "/api/session?project=owned-project") + assert.equal(JSON.parse(response.body).url, "/api/session?project=owned-project&workspace=owned-location") const foreign = await app.inject({ method: "GET", @@ -399,41 +403,25 @@ describe("instance proxy location enforcement", () => { assert.equal(foreign.statusCode, 403) }) - it("uses the cursor scope and rejects malformed or forged session-list cursors", async () => { + it("treats session-list cursors as opaque and validates the returned locations", async () => { const { app, requestCount } = await harness() - const ownedCursor = cursor({ directory: "/repo/worktree", anchor: { id: "session-1", time: 1, direction: "next" } }) const response = await app.inject({ method: "GET", - url: `/workspaces/workspace/instance/api/session?cursor=${ownedCursor}&directory=%2Fother&workspace=foreign-location`, + url: "/workspaces/workspace/instance/api/session?cursor=opaque.native-cursor", }) assert.equal(response.statusCode, 200) - const upstreamUrl = JSON.parse(response.body).url as string - assert.match(upstreamUrl, new RegExp(`cursor=${ownedCursor}`)) - assert.doesNotMatch(upstreamUrl, /directory=/) - assert.doesNotMatch(upstreamUrl, /workspace=/) - - const forgedCursor = cursor({ directory: "/other", anchor: { id: "session-1", time: 1, direction: "next" } }) - assert.equal((await app.inject({ - method: "GET", - url: `/workspaces/workspace/instance/api/session?cursor=${forgedCursor}&directory=%2Frepo`, - })).statusCode, 403) - assert.equal((await app.inject({ - method: "GET", - url: "/workspaces/workspace/instance/api/session?cursor=not-json", - })).statusCode, 400) + assert.deepEqual(JSON.parse(response.body).data.map((session: SessionInfo) => session.id), ["listed"]) assert.equal((await app.inject({ method: "GET", - url: `/workspaces/workspace/instance/api/session?cursor=${cursor({ directory: "/repo" })}`, + url: "/workspaces/workspace/instance/api/session?cursor=opaque&directory=%2Frepo", })).statusCode, 400) - assert.equal((await app.inject({ + assert.equal(requestCount(), 0) + + const foreign = await harness("/repo/worktree", {}, {}, "/repo", "/repo", {}, {}, {}, {}, { directory: "/other" }) + assert.equal((await foreign.app.inject({ method: "GET", - url: `/workspaces/workspace/instance/api/session?cursor=${cursor({ workspace: "foreign-location", directory: "/repo", anchor: { id: "session-1", time: 1, direction: "next" } })}`, + url: "/workspaces/workspace/instance/api/session?cursor=opaque", })).statusCode, 403) - assert.equal((await app.inject({ - method: "GET", - url: `/workspaces/workspace/instance/api/session?cursor=${cursor({ workspace: "owned-location", anchor: { id: "session-1", time: 1, direction: "next" } })}`, - })).statusCode, 200) - assert.equal(requestCount(), 2) }) it("filters PTYs and rejects foreign PTY access", async () => { @@ -659,7 +647,7 @@ describe("instance proxy location enforcement", () => { it("filters active sessions to the workspace without failing on stale ids", async () => { const active = { owned: { type: "running" as const }, foreign: { type: "running" as const }, stale: { type: "running" as const } } const { app, sessionGets, requestCount } = await harness("/repo/worktree", active, { - foreign: "/other", + foreign: { directory: "/repo/worktree", workspaceID: "foreign-location" }, stale: new Error("missing"), }) const response = await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/session/active" }) @@ -669,7 +657,7 @@ describe("instance proxy location enforcement", () => { assert.equal(requestCount(), 0) }) - it("blocks global routes through a workspace", async () => { + it("blocks global and unreviewed extension routes through a workspace", async () => { const { app, requestCount } = await harness() for (const route of ["global/dispose", "global/config", "global/upgrade"]) { const response = await app.inject({ method: "POST", url: `/workspaces/workspace/instance/${route}` }) @@ -679,6 +667,10 @@ describe("instance proxy location enforcement", () => { const response = await app.inject({ method: "GET", url: `/workspaces/workspace/instance/api/${route}` }) assert.equal(response.statusCode, 403) } + for (const route of ["plugin/await-activation", "plugin/check", "plugin/update", "rpc/example/run"]) { + const response = await app.inject({ method: "POST", url: `/workspaces/workspace/instance/api/${route}` }) + assert.equal(response.statusCode, 403) + } assert.equal((await app.inject({ method: "POST", url: "/workspaces/workspace/instance/api/service/stop", diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 19d1d8b53..9bf39f138 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -410,6 +410,7 @@ export interface InstanceProxyWorkspaceManager { invalidateSharedServiceConnection?(): void getInstanceAuthorizationHeader(id: string): string | undefined getServiceDirectory?(id: string): string | undefined + getServiceLocation?(id: string): LocationRef | undefined getServiceDirectoryForPath?(id: string, directory: string): Promise getWorktreeIdentityForPath(id: string, directory: string): Promise getServicePathForPath?(id: string, candidate: string): Promise @@ -667,7 +668,7 @@ async function proxyWorkspaceRequest(args: { const entries = await Promise.all(Object.entries(active).map(async ([sessionId, status]) => { try { const session = await client.session.get({ sessionID: sessionId }) - return await workspaceManager.ownsDirectory(workspaceId, session.location.directory) ? [sessionId, status] as const : null + return await workspaceManager.ownsLocation(workspaceId, session.location) ? [sessionId, status] as const : null } catch { return null } @@ -690,11 +691,29 @@ async function proxyWorkspaceRequest(args: { const sessionListHasScope = request.method === "GET" && pathname.replace(/\/+$/, "") === "/api/session" && (targetUrl.searchParams.has("cursor") || targetUrl.searchParams.has("project") || targetUrl.searchParams.has("workspace")) + if (request.method === "GET" + && pathname.replace(/\/+$/, "") === "/api/session" + && !targetUrl.searchParams.has("cursor") + && !targetUrl.searchParams.has("workspace")) { + const workspaceID = workspaceManager.getServiceLocation?.(workspaceId)?.workspaceID + if (workspaceID) targetUrl.searchParams.set("workspace", workspaceID) + } const sessionListScope = await authorizeSessionList(targetUrl, request.method, workspaceManager, workspaceId) if (sessionListScope !== "allowed") { reply.code(sessionListScope === "invalid" ? 400 : 403).send({ error: "Session list does not belong to workspace" }) return } + const sessionCursor = targetUrl.searchParams.get("cursor") + if (sessionCursor) { + const page = await (await workspaceManager.getSharedServiceClient()).session.list({ cursor: sessionCursor }) + const ownership = await Promise.all(page.data.map((session) => workspaceManager.ownsLocation(workspaceId, session.location))) + if (ownership.some((owned) => !owned)) { + reply.code(403).send({ error: "Session list does not belong to workspace" }) + return + } + reply.send(page) + return + } const serviceDirectory = workspaceManager.getServiceDirectory?.(workspaceId) ?? workspace.path let globalFormLocation: LocationRef | undefined if (isGlobalFormAction(pathname, request.method)) { @@ -856,7 +875,7 @@ async function proxyWorkspaceRequest(args: { } throw error } - if (!(await workspaceManager.ownsDirectory(workspaceId, session.location.directory))) { + if (!(await workspaceManager.ownsLocation(workspaceId, session.location))) { reply.code(403).send({ error: "Session does not belong to workspace" }) return } @@ -1106,12 +1125,7 @@ async function authorizeSessionList( const cursors = targetUrl.searchParams.getAll("cursor") if (cursors.length > 1) return "invalid" if (cursors.length === 1) { - const scope = decodeSessionListCursor(cursors[0]) - if (!scope) return "invalid" - for (const key of ["directory", "workspace", "location[directory]", "location[workspace]", "project", "subpath"]) { - targetUrl.searchParams.delete(key) - } - return ownsSessionListScope(manager, workspaceId, scope) + return cursors[0] && [...targetUrl.searchParams.keys()].every((key) => key === "cursor") ? "allowed" : "invalid" } const projects = targetUrl.searchParams.getAll("project") @@ -1141,39 +1155,6 @@ type SessionListScope = { subpath?: string } -function decodeSessionListCursor(cursor: string): SessionListScope | null { - if (!cursor || !/^[A-Za-z0-9_-]+$/.test(cursor)) return null - try { - const value = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as Record - if (!value || typeof value !== "object" || Array.isArray(value)) return null - const anchor = value.anchor as Record | undefined - if (!anchor || typeof anchor !== "object" || Array.isArray(anchor) - || typeof anchor.id !== "string" || !anchor.id - || typeof anchor.time !== "number" || !Number.isFinite(anchor.time) - || (anchor.direction !== "previous" && anchor.direction !== "next")) return null - if (value.workspace !== undefined && (typeof value.workspace !== "string" || !value.workspace.trim())) return null - if (value.search !== undefined && typeof value.search !== "string") return null - if (value.order !== undefined && value.order !== "asc" && value.order !== "desc") return null - if (typeof value.directory === "string" && value.directory.trim() && value.project === undefined && value.subpath === undefined) { - return { ...(typeof value.workspace === "string" ? { workspace: value.workspace } : {}), directory: value.directory } - } - if (typeof value.project === "string" && value.project.trim() && value.directory === undefined) { - if (value.subpath === undefined) { - return { ...(typeof value.workspace === "string" ? { workspace: value.workspace } : {}), project: value.project } - } - if (typeof value.subpath === "string" && isSafeRelativePath(value.subpath)) { - return { ...(typeof value.workspace === "string" ? { workspace: value.workspace } : {}), project: value.project, subpath: value.subpath } - } - } - if (typeof value.workspace === "string" && value.directory === undefined && value.project === undefined && value.subpath === undefined) { - return { workspace: value.workspace } - } - return null - } catch { - return null - } -} - function isSafeRelativePath(value: string): boolean { return value === "" || (!path.posix.isAbsolute(value) && !path.win32.isAbsolute(value) && !value.split(/[\\/]/).includes("..")) } diff --git a/packages/server/src/server/routes/worktrees.test.ts b/packages/server/src/server/routes/worktrees.test.ts index 784af16e5..f4cc28789 100644 --- a/packages/server/src/server/routes/worktrees.test.ts +++ b/packages/server/src/server/routes/worktrees.test.ts @@ -1,18 +1,117 @@ import assert from "node:assert/strict" import { execFileSync } from "node:child_process" -import { mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import path from "node:path" import { describe, it } from "node:test" -import Fastify from "fastify" import type { OpenCodeClient, SessionInfo } from "@opencode-ai/client" +import Fastify from "fastify" import type { WorkspaceDescriptor } from "../../api-types" import type { WorkspaceManager } from "../../workspaces/manager" import { registerWorktreeRoutes } from "./worktrees" import { WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuation" describe("worktree routes", () => { - it("fails a direct delete call closed when session evacuation fails", async () => { +it("reserves the physical worktree and rejects a same-HEAD replacement before deletion", async () => { + const temp = mkdtempSync(path.join(tmpdir(), "codenomad-worktree-route-")) + const repo = path.join(temp, "repo") + const linked = path.join(temp, "feature-worktree") + const workspacePath = path.join(repo, "apps", "web") + const linkedWorkspacePath = path.join(linked, "apps", "web") + const app = Fastify({ logger: false }) + + try { + mkdirSync(repo, { recursive: true }) + execFileSync("git", ["init", "-b", "main", repo], { stdio: "ignore" }) + mkdirSync(workspacePath, { recursive: true }) + writeFileSync(path.join(workspacePath, "README.md"), "nested workspace\n") + execFileSync("git", ["-C", repo, "add", "."], { stdio: "ignore" }) + execFileSync("git", ["-C", repo, "-c", "user.name=CodeNomad", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "init"], { stdio: "ignore" }) + execFileSync("git", ["-C", repo, "worktree", "add", "-b", "feature", linked], { stdio: "ignore" }) + + const current: SessionInfo = { + id: "session", + projectID: "project", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + location: { directory: path.join(linked, "..cache", "session"), workspaceID: "native-feature" }, + } + let lists = 0 + const client = { + project: { list: async () => [{ id: "project" }] }, + debug: { location: { list: async () => [] } }, + shell: { list: async () => ({ data: [] }) }, + pty: { list: async () => ({ data: [] }) }, + experimental: { persistentPty: { list: async () => [] } }, + location: { + get: async ({ location }: { location?: { directory?: string } }) => ({ + directory: location?.directory ?? workspacePath, + workspaceID: path.resolve(location?.directory ?? workspacePath) === path.resolve(linkedWorkspacePath) ? "native-feature" : undefined, + project: { id: "project", directory: workspacePath, canonical: workspacePath }, + }), + }, + session: { + list: async () => { + if (++lists === 3) { + execFileSync("git", ["-C", repo, "worktree", "remove", "--force", linked], { stdio: "ignore" }) + execFileSync("git", ["-C", repo, "worktree", "add", linked, "feature"], { stdio: "ignore" }) + writeFileSync(path.join(linked, "replacement.txt"), "must survive\n") + } + return { data: [structuredClone(current)], cursor: {} } + }, + active: async () => ({}), + move: async ({ directory, workspaceID }: { directory: string; workspaceID?: string }) => { + current.location = { directory, workspaceID } + }, + get: async () => structuredClone(current), + }, + } as unknown as OpenCodeClient + let reserved = "" + let released = false + const manager = { + get: () => ({ + id: "workspace", + path: workspacePath, + status: "ready", + proxyPath: "/workspaces/workspace/instance", + binaryId: "opencode", + binaryLabel: "opencode", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + reserveWorktreeDeletion: async (directory: string) => { + reserved = directory + return () => { released = true } + }, + getSharedServiceClient: async () => client, + getServiceLocation: () => ({ directory: workspacePath }), + getServiceDirectoryForPath: async (_id: string, directory: string) => { + assert.notEqual(path.resolve(directory), path.resolve(linked), "OpenCode must receive the mirrored workspace path") + return directory + }, + getWorktreeIdentityForPath: async (_id: string, directory: string) => ( + path.resolve(directory).startsWith(path.resolve(linked)) ? "workspace:feature" : "workspace:root" + ), + } as unknown as WorkspaceManager + registerWorktreeRoutes(app, { workspaceManager: manager, worktreeDeletionFence: new WorktreeDeletionFence() }) + + const response = await app.inject({ method: "DELETE", url: "/api/workspaces/workspace/worktrees/feature" }) + + assert.equal(response.statusCode, 409) + assert.equal(path.resolve(reserved), path.resolve(linked)) + assert.equal(released, true) + assert.equal(path.resolve(current.location.directory), path.resolve(workspacePath)) + assert.equal(execFileSync("git", ["-C", linked, "status", "--short"], { encoding: "utf8" }).trim(), "?? replacement.txt") + const inventory = execFileSync("git", ["-C", repo, "worktree", "list", "--porcelain"], { encoding: "utf8" }) + assert.ok(inventory.replace(/\\/g, "/").includes(linked.replace(/\\/g, "/"))) + } finally { + await app.close() + rmSync(temp, { recursive: true, force: true }) + } +}) + +it("fails a direct delete call closed when session evacuation fails", async () => { const temp = mkdtempSync(path.join(tmpdir(), "codenomad-delete-worktree-")) const target = path.join(temp, "doomed") const app = Fastify({ logger: false }) @@ -25,9 +124,28 @@ describe("worktree routes", () => { const workspace = { id: "workspace", path: temp, status: "ready" } as WorkspaceDescriptor const nativeSession = { id: "unloaded", projectID: "project", location: { directory: target }, cost: 0, tokens: {}, time: { created: 1, updated: 1 } } as SessionInfo + let blocker: "shell" | "pty" | "persistent" | undefined = "shell" const client = { - project: { - list: async () => [{ id: "project", canonical: temp, sandboxes: [target], time: { created: 1, updated: 1 } }], + project: { list: async () => [{ id: "project" }] }, + debug: { location: { list: async () => [{ directory: path.join(temp, "unrelated"), workspaceID: "root-location" }] } }, + shell: { list: async ({ location }: { location?: { workspace?: string } }) => ({ + data: blocker === "shell" && location?.workspace === "root-location" + ? [{ id: "sh_blocker", status: "running", cwd: target }] + : [], + }) }, + pty: { list: async ({ location }: { location?: { workspace?: string } }) => ({ + data: blocker === "pty" && location?.workspace === "root-location" + ? [{ id: "pty_blocker", status: "running", cwd: target }] + : [], + }) }, + experimental: { persistentPty: { list: async () => ( + blocker === "persistent" ? [{ id: "pty_persistent", status: "running", cwd: target }] : [] + ) } }, + location: { + get: async ({ location }: { location?: { directory?: string } }) => ({ + directory: location?.directory ?? temp, + project: { id: "project", canonical: temp, directory: temp }, + }), }, session: { list: async () => ({ data: [nativeSession], cursor: {} }), @@ -35,25 +153,35 @@ describe("worktree routes", () => { move: async (input: { directory: string }) => { if (input.directory === temp) throw new Error("native move failed") }, + get: async () => nativeSession, }, } as unknown as OpenCodeClient const manager = { get: () => workspace, getSharedServiceClient: async () => client, - getServiceDirectory: () => temp, + getServiceLocation: () => ({ directory: temp }), getServiceDirectoryForPath: async (_id: string, directory: string) => directory, + reserveWorktreeDeletion: async () => () => undefined, getWorktreeIdentityForPath: async () => "workspace:doomed", } as unknown as WorkspaceManager registerWorktreeRoutes(app, { workspaceManager: manager, worktreeDeletionFence: new WorktreeDeletionFence() }) + for (const [kind, message] of [["shell", "Running Shell"], ["pty", "Running PTY"], ["persistent", "Running persistent PTY"]] as const) { + blocker = kind + const blocked = await app.inject({ method: "DELETE", url: "/api/workspaces/workspace/worktrees/doomed" }) + assert.equal(blocked.statusCode, 409) + assert.match(blocked.json().error, new RegExp(message)) + } + blocker = undefined + const response = await app.inject({ method: "DELETE", url: "/api/workspaces/workspace/worktrees/doomed" }) - assert.equal(response.statusCode, 400) + assert.equal(response.statusCode, 502) assert.match(response.json().error, /native move failed/) assert.match(execFileSync("git", ["-C", temp, "worktree", "list", "--porcelain"], { encoding: "utf8" }), /doomed/) } finally { await app.close() rmSync(temp, { recursive: true, force: true }) } - }) +}) }) diff --git a/packages/server/src/server/routes/worktrees.ts b/packages/server/src/server/routes/worktrees.ts index dde07fce8..9f4c175db 100644 --- a/packages/server/src/server/routes/worktrees.ts +++ b/packages/server/src/server/routes/worktrees.ts @@ -1,4 +1,6 @@ import type { FastifyInstance, FastifyReply } from "fastify" +import { stat } from "node:fs/promises" +import path from "node:path" import { z } from "zod" import { WorkspaceManager } from "../../workspaces/manager" import { @@ -8,11 +10,20 @@ import { createManagedWorktree, removeWorktree, } from "../../workspaces/git-worktrees" -import { invalidateWorktreeCache } from "../../workspaces/worktree-directory" -import type { WorktreeListResponse } from "../../api-types" +import type { + WorktreeListResponse, + WorktreeSessionMoveRequest, + WorktreeSessionMoveResponse, +} from "../../api-types" import { ensureCodenomadGitExclude } from "../../workspaces/worktree-map" -import { createInstanceClient } from "../../workspaces/instance-client" -import { evacuateWorktreeSessions, type WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuation" +import { invalidateWorktreeCache } from "../../workspaces/worktree-directory" +import { + moveProjectSessionFamily, + listCompleteProjectSessions, + ProjectSessionError, + removeProjectWorktree, +} from "../../workspaces/project-session-families" +import type { WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuation" interface RouteDeps { workspaceManager: WorkspaceManager @@ -24,6 +35,10 @@ const WorktreeCreateSchema = z.object({ branch: z.string().trim().min(1).optional(), }) +const WorktreeSessionMoveSchema = z.object({ + worktreeSlug: z.string().trim().min(1), +}) + export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { app.get<{ Params: { id: string } }>("/api/workspaces/:id/worktrees", async (request, reply) => { const workspace = deps.workspaceManager.get(request.params.id) @@ -33,7 +48,11 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { } const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log) - const worktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log }) + const listed = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log }) + const worktrees = await Promise.all(listed.map(async (worktree) => ({ + ...worktree, + serviceDirectory: await deps.workspaceManager.getServiceDirectoryForPath(workspace.id, worktree.directory), + }))) const response: WorktreeListResponse = { worktrees, isGitRepo } return response }) @@ -78,6 +97,7 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { logger: request.log, }) invalidateWorktreeCache(workspace.id) + await refreshOpenCodeWorktrees(deps.workspaceManager, workspace.id, request.log) reply.code(201) return created @@ -86,6 +106,70 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { } }) + app.post<{ + Params: { id: string; sessionId: string } + Body: WorktreeSessionMoveRequest + }>("/api/workspaces/:id/sessions/:sessionId/worktree", async (request, reply) => { + const workspace = deps.workspaceManager.get(request.params.id) + if (!workspace) { + reply.code(404) + return { error: "Workspace not found" } + } + + try { + const { worktreeSlug } = WorktreeSessionMoveSchema.parse(request.body ?? {}) + const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log) + if (!isGitRepo) throw new ProjectSessionError("Workspace is not a Git repository", 409) + const worktrees = await strictWorktrees({ + repoRoot, + workspaceFolder: workspace.path, + logger: request.log, + failClosed: true, + }) + const target = worktrees.find((worktree) => worktree.slug === worktreeSlug) + if (!target) throw new ProjectSessionError("Worktree not found", 404) + const projectLocation = deps.workspaceManager.getServiceLocation(workspace.id) + const targetDirectory = await deps.workspaceManager.getServiceDirectoryForPath(workspace.id, target.directory) + if (!projectLocation || !targetDirectory) throw new ProjectSessionError("Unable to resolve OpenCode worktree paths", 409) + const moved = await moveProjectSessionFamily({ + client: await deps.workspaceManager.getSharedServiceClient(), + projectLocation, + sessionId: request.params.sessionId, + targetDirectory, + validateTarget: async () => { + const refreshed = await strictWorktrees({ + repoRoot, + workspaceFolder: workspace.path, + logger: request.log, + failClosed: true, + }) + return refreshed.some((worktree) => worktree.slug === worktreeSlug + && worktree.registeredDirectory === target.registeredDirectory + && worktree.head === target.head) + }, + runMutation: async (directories, operation) => { + const identities = await Promise.all(directories.map((directory) => ( + deps.workspaceManager.getWorktreeIdentityForPath(workspace.id, directory) + ))) + if (identities.some((identity) => !identity)) { + throw new ProjectSessionError("Unable to identify session worktrees", 409) + } + const release = deps.worktreeDeletionFence.enter(identities as string[]) + if (!release) throw new ProjectSessionError("A session worktree is being removed", 409) + try { + return await operation() + } finally { + release() + } + }, + }) + const response: WorktreeSessionMoveResponse = { ...moved, worktreeSlug } + return response + } catch (error) { + return handleError(error, reply) + } + }) + app.delete<{ Params: { id: string; slug: string }; Querystring: { force?: string } }>( "/api/workspaces/:id/worktrees/:slug", async (request, reply) => { @@ -110,34 +194,91 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { const force = (request.query?.force ?? "").toString().toLowerCase() === "true" try { - const worktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log }) + const worktrees = await strictWorktrees({ + repoRoot, + workspaceFolder: workspace.path, + logger: request.log, + failClosed: true, + }) const match = worktrees.find((wt) => wt.slug === slug) if (!match || match.kind === "root") { reply.code(404) return { error: "Worktree not found" } } - - const [client, targetDirectory] = await Promise.all([ - createInstanceClient(deps.workspaceManager, workspace.id), - deps.workspaceManager.getServiceDirectoryForPath(workspace.id, match.directory), - ]) - const projectDirectory = deps.workspaceManager.getServiceDirectory(workspace.id) - if (!client || !projectDirectory || !targetDirectory) { - throw new Error("Unable to inventory sessions before deleting worktree") + const targetHostDirectory = match.registeredDirectory ?? match.directory + const targetIdentity = await deps.workspaceManager.getWorktreeIdentityForPath(workspace.id, match.directory) + const pathIdentity = await readPathIdentity(targetHostDirectory) + if (!targetIdentity || !pathIdentity) throw new ProjectSessionError("Unable to identify worktree before deletion", 409) + let releaseDeletion: () => void + try { + releaseDeletion = await deps.workspaceManager.reserveWorktreeDeletion(targetHostDirectory) + } catch (error) { + throw new ProjectSessionError(error instanceof Error ? error.message : "Unable to reserve worktree deletion", 409) } - const worktreeIdentity = await deps.workspaceManager.getWorktreeIdentityForPath(workspace.id, match.directory) - if (!worktreeIdentity) throw new Error("Unable to identify worktree before deletion") - await deps.worktreeDeletionFence.run(worktreeIdentity, [worktreeIdentity], () => ( - evacuateWorktreeSessions({ + try { + const client = await deps.workspaceManager.getSharedServiceClient() + const projectLocation = deps.workspaceManager.getServiceLocation(workspace.id) + const rootHostDirectory = worktrees.find((worktree) => worktree.kind === "root")!.directory + const [targetDirectory, rootDirectory] = await Promise.all([ + deps.workspaceManager.getServiceDirectoryForPath(workspace.id, match.directory), + deps.workspaceManager.getServiceDirectoryForPath(workspace.id, rootHostDirectory), + ]) + if (!projectLocation || !targetDirectory || !rootDirectory) { + throw new ProjectSessionError("Unable to resolve OpenCode worktree paths", 409) + } + const targetServiceRoot = resolveServiceWorktreeRoot(targetHostDirectory, match.directory, targetDirectory) + const isTargetRegistered = async () => { + const refreshed = await strictWorktrees({ + repoRoot, + workspaceFolder: workspace.path, + logger: request.log, + failClosed: true, + }) + const current = refreshed.find((worktree) => worktree.slug === slug && worktree.kind === "worktree") + return Boolean(current + && current.registeredDirectory === match.registeredDirectory + && current.head === match.head + && current.branch === match.branch + && samePathIdentity(pathIdentity, await readPathIdentity(targetHostDirectory))) + } + await removeProjectWorktree({ client, - projectDirectory, + projectLocation, targetDirectory, - rootDirectory: projectDirectory, - resolveDirectoryIdentity: (directory) => deps.workspaceManager.getWorktreeIdentityForPath(workspace.id, directory), - remove: () => removeWorktree({ workspaceFolder: workspace.path, directory: match.directory, force, logger: request.log }), + rootDirectory, + matchesTarget: async (directory) => servicePathContains(targetServiceRoot, directory), + validateBeforeRemove: async (projectID) => { + await assertNoWorktreeBlockers(client, projectID, targetServiceRoot, targetDirectory) + }, + runMutation: async (directories, operation) => { + const identities = await Promise.all(directories.map(async (directory) => ( + await deps.workspaceManager.getWorktreeIdentityForPath(workspace.id, directory) + ?? (servicePathContains(targetServiceRoot, directory) ? targetIdentity : undefined) + ))) + if (identities.some((identity) => !identity)) { + throw new ProjectSessionError("Unable to identify session worktrees", 409) + } + return deps.worktreeDeletionFence.run(targetIdentity, identities as string[], operation) + }, + remove: async () => { + try { + await removeWorktree({ + workspaceFolder: workspace.path, + directory: targetHostDirectory, + force, + logger: request.log, + }) + } catch (error) { + throw new ProjectSessionError(error instanceof Error ? error.message : "Unable to remove worktree", 409) + } + }, + isTargetRegistered, }) - )) - invalidateWorktreeCache(workspace.id) + invalidateWorktreeCache(workspace.id) + await refreshOpenCodeWorktrees(deps.workspaceManager, workspace.id, request.log) + } finally { + releaseDeletion() + } reply.code(204) } catch (error) { @@ -147,7 +288,110 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { ) } +function strictWorktrees(params: Parameters[0]) { + return listWorktrees(params).catch((error) => { + throw new ProjectSessionError(error instanceof Error ? error.message : "Unable to read Git worktree inventory", 502) + }) +} + +type PathIdentity = { dev: number; ino: number; birthtimeMs: number } + +async function readPathIdentity(directory: string): Promise { + try { + const value = await stat(directory) + return { dev: value.dev, ino: value.ino, birthtimeMs: value.birthtimeMs } + } catch { + return undefined + } +} + +function samePathIdentity(left: PathIdentity, right: PathIdentity | undefined): boolean { + return Boolean(right && left.dev === right.dev && left.ino === right.ino && left.birthtimeMs === right.birthtimeMs) +} + +function resolveServiceWorktreeRoot(hostRoot: string, hostWorkspace: string, serviceWorkspace: string): string { + const relative = path.relative(hostRoot, hostWorkspace) + if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new ProjectSessionError("Workspace path is outside the registered worktree", 409) + } + const servicePath = /^[A-Za-z]:[\\/]|^(?:\\\\|\/\/)/.test(serviceWorkspace) ? path.win32 : path.posix + return relative.split(/[\\/]/).filter(Boolean).reduce((directory) => servicePath.dirname(directory), serviceWorkspace) +} + +function servicePathContains(root: string, candidate: string): boolean { + const servicePath = /^[A-Za-z]:[\\/]|^(?:\\\\|\/\/)/.test(root) ? path.win32 : path.posix + const relative = servicePath.relative(root, candidate) + return relative === "" || (relative !== ".." && !relative.startsWith(`..${servicePath.sep}`) && !servicePath.isAbsolute(relative)) +} + +async function assertNoWorktreeBlockers( + client: Awaited>, + currentProjectID: string, + targetRoot: string, + targetDirectory: string, +): Promise { + const [nativeLocations, projects] = await Promise.all([ + client.debug.location.list(), + client.project.list(), + ]) + const locations = new Map() + const candidates: Array<{ directory: string; workspace?: string }> = [{ directory: targetDirectory }, ...nativeLocations + .map(({ directory, workspaceID }) => ({ directory, workspace: workspaceID }))] + for (const location of candidates) { + locations.set(`${location.directory}\0${location.workspace ?? ""}`, location) + } + const resources = await Promise.all([...locations.values()].map(async (location) => Promise.all([ + client.shell.list({ location }), + client.pty.list({ location }), + ]))) + const shell = resources.flatMap(([shells]) => shells.data) + .find((entry) => entry.status === "running" && servicePathContains(targetRoot, entry.cwd)) + if (shell) throw new ProjectSessionError(`Running Shell blocks deletion: ${shell.id}`, 409) + const pty = resources.flatMap(([, ptys]) => ptys.data) + .find((entry) => entry.status === "running" && servicePathContains(targetRoot, entry.cwd)) + if (pty) throw new ProjectSessionError(`Running PTY blocks deletion: ${pty.id}`, 409) + + const sessions = [] + for (const projectID of new Set([currentProjectID, ...projects.map(({ id }) => id)])) { + const projectSessions = await listCompleteProjectSessions(client, projectID) + if (projectID !== currentProjectID) { + const blocker = projectSessions.find((session) => servicePathContains(targetRoot, session.location.directory)) + if (blocker) { + throw new ProjectSessionError(`Session from another project blocks deletion: ${blocker.id}`, 409) + } + } + sessions.push(...projectSessions) + } + for (let index = 0; index < sessions.length; index += 16) { + const persistentPtys = await Promise.all(sessions.slice(index, index + 16) + .map((session) => client.experimental.persistentPty.list({ sessionID: session.id }))) + for (const persistentPty of persistentPtys) { + const persistent = persistentPty.find((entry) => entry.status === "running" && servicePathContains(targetRoot, entry.cwd)) + if (persistent) throw new ProjectSessionError(`Running persistent PTY blocks deletion: ${persistent.id}`, 409) + } + } +} + +async function refreshOpenCodeWorktrees( + manager: WorkspaceManager, + workspaceId: string, + logger: FastifyInstance["log"], +): Promise { + const location = manager.getServiceLocation(workspaceId) + if (!location) return + try { + const client = await manager.getSharedServiceClient() + const signal = AbortSignal.timeout(5_000) + const resolved = await client.location.get({ + location: { directory: location.directory, workspace: location.workspaceID }, + }, { signal }) + await client.worktree.refresh({ projectID: resolved.project.id }, { signal }) + } catch (error) { + logger.warn({ err: error }, "Failed to refresh OpenCode worktrees") + } +} + function handleError(error: unknown, reply: FastifyReply) { - reply.code(400) + reply.code(error instanceof ProjectSessionError ? error.statusCode : 400) return { error: error instanceof Error ? error.message : "Unable to fulfill request" } } diff --git a/packages/server/src/workspaces/git-worktrees.ts b/packages/server/src/workspaces/git-worktrees.ts index 087009015..c2ad75241 100644 --- a/packages/server/src/workspaces/git-worktrees.ts +++ b/packages/server/src/workspaces/git-worktrees.ts @@ -49,7 +49,7 @@ export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise logger?.debug?.({ folder, err: result.error }, "Folder is not a Git repository; using workspace folder as root") return { repoRoot: folder, isGitRepo: false } } - const repoRoot = result.stdout.trim() + const repoRoot = result.stdout.replace(/\r?\n$/, "") if (!repoRoot) { return { repoRoot: folder, isGitRepo: false } } @@ -61,27 +61,30 @@ export async function isGitAvailable(folder: string): Promise { return result.ok || !isGitUnavailableResult(result) } -function parseWorktreePorcelain(output: string): Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> { - const records: Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> = [] - const lines = output.split(/\r?\n/) - let current: { worktree?: string; branch?: string; head?: string; detached?: boolean } = {} +function parseWorktreePorcelain(output: string): Array<{ worktree: string; branch?: string; head?: string; detached?: boolean; prunable?: boolean }> { + const records: Array<{ worktree: string; branch?: string; head?: string; detached?: boolean; prunable?: boolean }> = [] + let current: { worktree?: string; branch?: string; head?: string; detached?: boolean; prunable?: boolean } = {} const flush = () => { if (current.worktree) { - records.push({ worktree: current.worktree, branch: current.branch }) + records.push({ + worktree: current.worktree, + branch: current.branch, + head: current.head, + detached: current.detached, + prunable: current.prunable, + }) } current = {} } - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed) { - flush() - continue - } - const [key, ...rest] = trimmed.split(" ") - const value = rest.join(" ").trim() + for (const field of output.split("\0")) { + if (!field) continue + const separator = field.indexOf(" ") + const key = separator === -1 ? field : field.slice(0, separator) + const value = separator === -1 ? "" : field.slice(separator + 1) if (key === "worktree") { + flush() current.worktree = value } else if (key === "branch") { // branch is like refs/heads/foo @@ -90,6 +93,8 @@ function parseWorktreePorcelain(output: string): Array<{ worktree: string; branc current.head = value } else if (key === "detached") { current.detached = true + } else if (key === "prunable") { + current.prunable = true } } flush() @@ -100,27 +105,39 @@ export async function listWorktrees(params: { repoRoot: string workspaceFolder: string logger?: LogLike + failClosed?: boolean }): Promise { const { repoRoot, workspaceFolder, logger } = params - const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder) + const result = await runGit(["worktree", "list", "--porcelain", "-z"], workspaceFolder) if (!result.ok) { + if (params.failClosed) throw result.error const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: workspaceFolder, kind: "root" } logger?.debug?.({ repoRoot, err: result.error }, "Failed to list git worktrees; returning root only") return [rootDescriptor] } const records = parseWorktreePorcelain(result.stdout) + if (params.failClosed && records.some((record) => record.prunable)) { + throw new Error("Git worktree inventory contains a prunable entry") + } const rootRecord = records.find((record) => path.resolve(record.worktree) === path.resolve(repoRoot)) + if (params.failClosed && !rootRecord) throw new Error("Git worktree inventory is missing the repository root") const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: workspaceFolder, + registeredDirectory: rootRecord?.worktree, kind: "root", branch: rootRecord?.branch, + head: rootRecord?.head, } const worktrees: WorktreeDescriptor[] = [rootDescriptor] const seen = new Set(["root"]) + const relativeWorkspacePath = path.relative(repoRoot, workspaceFolder) + if (params.failClosed && (path.isAbsolute(relativeWorkspacePath) || relativeWorkspacePath.startsWith(`..${path.sep}`) || relativeWorkspacePath === "..")) { + throw new Error("Workspace folder is outside the repository root") + } const normalizeSlug = (record: { branch?: string; head?: string; detached?: boolean; worktree: string }): string => { const branch = (record.branch ?? "").trim() @@ -151,10 +168,18 @@ export async function listWorktrees(params: { continue } if (seen.has(slug)) { + if (params.failClosed) throw new Error(`Git worktree inventory contains duplicate slug: ${slug}`) continue } seen.add(slug) - worktrees.push({ slug, directory: abs, kind: "worktree", branch: record.branch }) + worktrees.push({ + slug, + directory: relativeWorkspacePath ? path.join(abs, relativeWorkspacePath) : abs, + registeredDirectory: abs, + kind: "worktree", + branch: record.branch, + head: record.head, + }) } return worktrees @@ -238,7 +263,7 @@ export async function removeWorktree(params: { logger?: LogLike }): Promise { const { workspaceFolder, logger } = params - const directory = (params.directory ?? "").trim() + const directory = params.directory ?? "" if (!directory) { throw new Error("Invalid worktree directory") } diff --git a/packages/server/src/workspaces/instance-events.test.ts b/packages/server/src/workspaces/instance-events.test.ts index c587e33e3..769b32275 100644 --- a/packages/server/src/workspaces/instance-events.test.ts +++ b/packages/server/src/workspaces/instance-events.test.ts @@ -575,6 +575,34 @@ describe("InstanceEventBridge", () => { } }) + it("scopes typed plugin events to the owner of their required native location", async () => { + const events = [{ + id: "rpc-event", + created: 1, + type: "rpc.example.updated", + location: { directory: "/repo-b", workspaceID: "workspace-b" }, + data: { itemID: "item" }, + }] as OpenCodeEvent[] + const workspaces = [{ id: "a", path: "/repo-a" }, { id: "b", path: "/repo-b" }] + const { manager, sessionGets } = locationlessManager(events, {}, workspaces) + const bus = new EventBus() + const received: any[] = [] + bus.on("instance.event", (event) => { + if (event.event.type !== "server.connected") received.push(event) + }) + const bridge = new InstanceEventBridge({ workspaceManager: manager, eventBus: bus, logger }) + + try { + bus.publish({ type: "workspace.started", workspace: manager.list()[0] as any }) + await waitFor(() => received.length === 1) + assert.equal(sessionGets(), 0) + assert.equal(received[0].instanceId, "b") + assert.equal(received[0].event.type, "rpc.example.updated") + } finally { + bridge.shutdown() + } + }) + it("broadcasts safe global locationless service events", async () => { const events = [ { type: "agent.updated", data: {} }, diff --git a/packages/server/src/workspaces/manager.test.ts b/packages/server/src/workspaces/manager.test.ts index cac6ca1ea..f48e4e1cc 100644 --- a/packages/server/src/workspaces/manager.test.ts +++ b/packages/server/src/workspaces/manager.test.ts @@ -1,5 +1,7 @@ import assert from "node:assert/strict" +import os from "node:os" import path from "node:path" +import { mkdir, mkdtemp, rm } from "node:fs/promises" import { describe, it } from "node:test" import type { LocationRef, OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client" import pino from "pino" @@ -158,6 +160,18 @@ describe("workspace manager shared service lifecycle", () => { ) }) + it("keeps WSL worktree reservation paths case-sensitive", { skip: process.platform !== "win32" }, async () => { + const { manager } = createHarness(new ControlledSharedService(), { platform: "win32" }) + const releaseUpper = await manager.reserveWorktreeDeletion("\\\\wsl.localhost\\Ubuntu\\repo\\Foo") + const releaseLower = await manager.reserveWorktreeDeletion("\\\\wsl.localhost\\Ubuntu\\repo\\foo") + await assert.rejects( + () => manager.reserveWorktreeDeletion("\\\\wsl.localhost\\Ubuntu\\repo\\Foo\\nested"), + /already in progress/, + ) + releaseLower() + releaseUpper() + }) + it("pins a bounded host CLI lifecycle with binary, platform, and startup environment identity", async () => { const service = new ControlledSharedService() let factoryCall: unknown[] | undefined @@ -490,6 +504,21 @@ describe("workspace manager shared service lifecycle", () => { assert.equal(harness.service.evictionCalls[0]?.signal, undefined) }) + it("refuses deletion while another workspace occupies the worktree", async () => { + const temp = await mkdtemp(path.join(os.tmpdir(), "codenomad-worktree-owner-")) + const worktree = path.join(temp, "worktree") + const nested = path.join(worktree, "apps", "web") + await mkdir(nested, { recursive: true }) + const harness = createHarness() + try { + const { workspace } = await harness.manager.create(nested) + await assert.rejects(() => harness.manager.reserveWorktreeDeletion(worktree), /open as another workspace/) + await harness.manager.delete(workspace.id) + } finally { + await rm(temp, { recursive: true, force: true }) + } + }) + it("evicts a ready location on explicit final deletion without stopping the daemon", async () => { const harness = createHarness() const created = await harness.manager.create(process.cwd()) diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 7b6559646..2a2967652 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -159,6 +159,7 @@ export interface WorkspaceCreateOptions { type CreationRequestState = "owner" | "cancelled" | "released" export class WorkspaceManager { private readonly workspaces = new Map() + private readonly deletingWorktreeRoots = new Set() private readonly cancelledCreationRequests = new Set() private readonly pendingCreationRequests = new Set() private readonly activeLocationCreations = new Set>() @@ -192,6 +193,12 @@ export class WorkspaceManager { return record?.[WORKSPACE_STATE].published ? record.location?.directory ?? record.path : undefined } + getServiceLocation(id: string): LocationRef | undefined { + const record = this.workspaces.get(id) + if (!record?.[WORKSPACE_STATE].published) return undefined + return record.location ?? { directory: record.path } + } + async getSharedServiceEndpoint(id: string): Promise { if (!this.workspaces.get(id)?.[WORKSPACE_STATE].published) return undefined try { @@ -208,6 +215,21 @@ export class WorkspaceManager { return this.sharedService.client() } + async reserveWorktreeDeletion(directory: string): Promise<() => void> { + const submitted = path.isAbsolute(directory) ? directory : path.resolve(this.options.rootDir, directory) + const target = canonicalWorktreeIdentity(await realpath(submitted).catch(() => path.normalize(submitted)), this.options.platform) + if (Array.from(this.deletingWorktreeRoots).some((root) => pathsOverlap(root, target))) { + throw new Error("Worktree deletion is already in progress") + } + if (Array.from(this.workspaces.values()).some((workspace) => ( + pathContains(target, canonicalWorktreeIdentity(workspace.path, this.options.platform)) + ))) { + throw new Error("Worktree is open as another workspace") + } + this.deletingWorktreeRoots.add(target) + return () => this.deletingWorktreeRoots.delete(target) + } + invalidateSharedServiceConnection(): void { this.sharedService.invalidate?.() } @@ -404,6 +426,10 @@ export class WorkspaceManager { launchDeadlineAt, launchTimeoutMs, ) + const workspaceIdentity = canonicalWorktreeIdentity(workspacePath, this.options.platform) + if (Array.from(this.deletingWorktreeRoots).some((root) => pathContains(root, workspaceIdentity))) { + throw new Error("Workspace directory is being removed") + } if (options.requestId && this.cancelledCreationRequests.has(options.requestId)) { throw new Error(`Workspace creation request ${options.requestId} was cancelled`) } @@ -1036,3 +1062,24 @@ export class WorkspaceManager { return candidates[0] ?? "" } } + +function pathContains(parent: string, child: string): boolean { + const left = parseWorktreeIdentity(parent) + const right = parseWorktreeIdentity(child) + if (left || right) { + if (!left || !right || left.distro !== right.distro) return false + const relative = path.posix.relative(left.linuxPath, right.linuxPath) + return relative === "" || (relative !== ".." && !relative.startsWith("../") && !path.posix.isAbsolute(relative)) + } + const relative = path.relative(parent, child) + return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) +} + +function pathsOverlap(left: string, right: string): boolean { + return pathContains(left, right) || pathContains(right, left) +} + +function parseWorktreeIdentity(identity: string): { distro: string; linuxPath: string } | undefined { + const match = /^wsl:([^:]+):(\/.*)$/.exec(identity) + return match ? { distro: match[1]!, linuxPath: match[2]! } : undefined +} diff --git a/packages/server/src/workspaces/opencode-service.ts b/packages/server/src/workspaces/opencode-service.ts index 199383262..a2556a91d 100644 --- a/packages/server/src/workspaces/opencode-service.ts +++ b/packages/server/src/workspaces/opencode-service.ts @@ -69,7 +69,10 @@ export class OpenCodeSharedService { serviceOptions?: OpenCodeSharedServiceOptions, ): Promise { const result = await this.withClient(serviceOptions, (client) => client.location.get({ - location: { directory: location.directory }, + location: { + directory: location.directory, + ...(location.workspaceID ? { workspace: location.workspaceID } : {}), + }, }, requestOptions?.signal ? { signal: requestOptions.signal } : undefined), requestOptions) if ( !result diff --git a/packages/server/src/workspaces/project-session-families.test.ts b/packages/server/src/workspaces/project-session-families.test.ts new file mode 100644 index 000000000..d69ede3ea --- /dev/null +++ b/packages/server/src/workspaces/project-session-families.test.ts @@ -0,0 +1,369 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import type { OpenCodeClient, SessionInfo } from "@opencode-ai/client" +import { + listCompleteProjectSessions, + moveProjectSessionFamily, + ProjectSessionError, + removeProjectWorktree, + resolveSessionFamilies, +} from "./project-session-families" + +const ROOT = "/repo" +const WORKTREE = "/repo/.codenomad/worktrees/feature" + +function session(id: string, parentID?: string, directory = ROOT, workspaceID?: string): SessionInfo { + return { + id, + parentID, + projectID: "project", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + location: { directory, workspaceID }, + } +} + +function clientHarness(initial: SessionInfo[], options: { + active?: string[] | (() => string[]) + failMove?: (sessionId: string, call: number) => boolean + visibilityDelayGets?: number + workspaceID?: string +} = {}) { + const sessions = new Map(initial.map((value) => [value.id, structuredClone(value)])) + const moveCalls: string[] = [] + let moveCall = 0 + const pending = new Map() + const client = { + location: { + get: async ({ location }: { location?: { directory?: string } }) => ({ + directory: location?.directory ?? ROOT, + workspaceID: options.workspaceID, + project: { id: "project", directory: ROOT, canonical: ROOT }, + }), + }, + session: { + list: async (input?: { workspace?: string }) => ({ + data: Array.from(sessions.values()) + .filter((value) => !input?.workspace || value.location.workspaceID === input.workspace) + .map((value) => structuredClone(value)), + cursor: {}, + }), + active: async () => Object.fromEntries((typeof options.active === "function" ? options.active() : options.active ?? []).map((id) => [id, { type: "running" as const }])), + get: async ({ sessionID }: { sessionID: string }) => { + const update = pending.get(sessionID) + if (update && update.remaining-- <= 0) { + sessions.get(sessionID)!.location = update.location + pending.delete(sessionID) + } + return structuredClone(sessions.get(sessionID)!) + }, + move: async ({ sessionID, directory, workspaceID }: { sessionID: string; directory: string; workspaceID?: string }) => { + moveCall += 1 + moveCalls.push(sessionID) + if (options.failMove?.(sessionID, moveCall)) throw new Error(`move failed: ${sessionID}`) + const location = { directory, workspaceID } + if (options.visibilityDelayGets) pending.set(sessionID, { location, remaining: options.visibilityDelayGets }) + else sessions.get(sessionID)!.location = location + }, + }, + } as unknown as OpenCodeClient + return { client, sessions, moveCalls } +} + +describe("project session families", () => { + it("loads the complete paginated project inventory", async () => { + const calls: Array<{ project?: string; workspace?: string; cursor?: string; limit?: number; order?: string }> = [] + const pages = [ + session("root", undefined, ROOT, "workspace"), + session("child-1", "root", ROOT, "workspace"), + session("child-2", "root", ROOT, "workspace"), + session("child-3", "root", ROOT, "workspace"), + session("child-4", "root", ROOT, "workspace"), + ] + const client = { + session: { + list: async (input: { project?: string; cursor?: string }) => { + calls.push(input) + const index = input.cursor ? Number(input.cursor.slice("page-".length)) - 1 : 0 + return { data: [pages[index]!], cursor: { next: index < pages.length - 1 ? `page-${index + 2}` : null } } + }, + }, + } as unknown as OpenCodeClient + + assert.deepEqual((await listCompleteProjectSessions(client, "project", "workspace")).map(({ id }) => id), pages.map(({ id }) => id)) + assert.deepEqual(calls[0], { project: "project", workspace: "workspace", limit: 500, order: "asc" }) + assert.deepEqual(calls.slice(1), ["page-2", "page-3", "page-4", "page-5"].map((cursor) => ({ cursor }))) + }) + + it("rejects malformed native cursors", async () => { + const client = { session: { list: async () => ({ data: [session("root")], cursor: { next: 42 } }) } } as unknown as OpenCodeClient + await assert.rejects(() => listCompleteProjectSessions(client, "project"), /invalid session inventory cursor/) + }) + + it("runs a family move inside the supplied worktree mutation guard", async () => { + const harness = clientHarness([session("root"), session("child", "root")]) + const guarded: string[][] = [] + + await moveProjectSessionFamily({ + client: harness.client, + projectLocation: { directory: ROOT }, + sessionId: "root", + targetDirectory: WORKTREE, + runMutation: async (directories, operation) => { + guarded.push(directories) + return operation() + }, + }) + + assert.deepEqual(guarded, [[ROOT, ROOT, WORKTREE]]) + }) + + it("resolves complete families and rejects incomplete ancestry", () => { + assert.deepEqual( + Array.from(resolveSessionFamilies([session("child", "root"), session("root")]).values()) + .map((family) => family.map(({ id }) => id)), + [["root", "child"]], + ) + assert.throws(() => resolveSessionFamilies([session("child", "missing")]), /missing parent/) + }) + + it("moves a complete family to the target", async () => { + const harness = clientHarness([session("root"), session("child", "root")]) + const moved = await moveProjectSessionFamily({ + client: harness.client, + projectLocation: { directory: ROOT }, + sessionId: "child", + targetDirectory: WORKTREE, + }) + assert.deepEqual(moved.sessionIds, ["root", "child"]) + assert.ok([...harness.sessions.values()].every(({ location }) => location.directory === WORKTREE)) + }) + + it("rejects a family split across native workspaces", async () => { + const harness = clientHarness([ + session("root", undefined, ROOT, "owned-workspace"), + session("child", "root", ROOT, "foreign-workspace"), + ], { workspaceID: "owned-workspace" }) + + await assert.rejects(() => moveProjectSessionFamily({ + client: harness.client, + projectLocation: { directory: ROOT, workspaceID: "owned-workspace" }, + sessionId: "root", + targetDirectory: WORKTREE, + }), /another workspace/) + assert.deepEqual(harness.moveCalls, []) + }) + + it("waits for delayed move visibility", async () => { + const harness = clientHarness([session("root")], { visibilityDelayGets: 2 }) + await moveProjectSessionFamily({ client: harness.client, projectLocation: { directory: ROOT }, sessionId: "root", targetDirectory: WORKTREE }) + assert.equal(harness.sessions.get("root")?.location.directory, WORKTREE) + }) + + it("rolls back transaction-owned moves after partial failure", async () => { + const harness = clientHarness([session("root"), session("child", "root")], { + failMove: (id, call) => id === "child" && call === 2, + }) + await assert.rejects(() => moveProjectSessionFamily({ + client: harness.client, + projectLocation: { directory: ROOT }, + sessionId: "root", + targetDirectory: WORKTREE, + }), /move failed/) + assert.deepEqual(harness.moveCalls, ["root", "child", "root"]) + assert.equal(harness.sessions.get("root")?.location.directory, ROOT) + }) + + it("blocks deletion while an attached session is active", async () => { + const harness = clientHarness([session("blocked", undefined, WORKTREE)], { active: ["blocked"] }) + await assert.rejects(() => removeProjectWorktree({ + client: harness.client, + projectLocation: { directory: ROOT }, + targetDirectory: WORKTREE, + rootDirectory: ROOT, + remove: async () => assert.fail("Git removal must not run"), + isTargetRegistered: async () => true, + }), (error: unknown) => error instanceof ProjectSessionError && error.statusCode === 409) + assert.deepEqual(harness.moveCalls, []) + }) + + it("re-inventories an initially empty worktree before removing it", async () => { + const harness = clientHarness([]) + let lists = 0 + ;(harness.client.session.list as any) = async () => ({ + data: ++lists < 3 ? [] : [session("intruder", undefined, WORKTREE)], + cursor: {}, + }) + + await assert.rejects(() => removeProjectWorktree({ + client: harness.client, + projectLocation: { directory: ROOT }, + targetDirectory: WORKTREE, + rootDirectory: ROOT, + remove: async () => assert.fail("Git removal must not run"), + isTargetRegistered: async () => true, + }), /Sessions remain attached/) + }) + + it("rechecks deletion blockers after evacuation", async () => { + const harness = clientHarness([session("root", undefined, WORKTREE)]) + let checks = 0 + await assert.rejects(() => removeProjectWorktree({ + client: harness.client, + projectLocation: { directory: ROOT }, + targetDirectory: WORKTREE, + rootDirectory: ROOT, + remove: async () => assert.fail("Git removal must not run"), + isTargetRegistered: async () => true, + validateBeforeRemove: async () => { + if (++checks === 2) throw new ProjectSessionError("running resource", 409) + }, + }), /running resource/) + assert.equal(checks, 2) + assert.equal(harness.sessions.get("root")?.location.directory, WORKTREE) + }) + + it("rolls back when a family member becomes active during evacuation", async () => { + let harness: ReturnType + harness = clientHarness([session("root", undefined, WORKTREE), session("child", "root", WORKTREE)], { + active: () => harness.moveCalls.includes("root") ? ["child"] : [], + }) + await assert.rejects(() => removeProjectWorktree({ + client: harness.client, + projectLocation: { directory: ROOT }, + targetDirectory: WORKTREE, + rootDirectory: ROOT, + remove: async () => assert.fail("Git removal must not run"), + isTargetRegistered: async () => true, + }), /Active sessions block/) + assert.equal(harness.sessions.get("root")?.location.directory, WORKTREE) + }) + + it("rolls back from session state when inventory visibility is stale", async () => { + const harness = clientHarness([session("root"), session("child", "root")], { + failMove: (id, call) => id === "child" && call === 2, + }) + const stale = [session("root"), session("child", "root")] + ;(harness.client.session.list as any) = async () => ({ data: structuredClone(stale), cursor: {} }) + await assert.rejects(() => moveProjectSessionFamily({ + client: harness.client, + projectLocation: { directory: ROOT }, + sessionId: "root", + targetDirectory: WORKTREE, + }), /move failed/) + assert.equal(harness.sessions.get("root")?.location.directory, ROOT) + }) + + it("treats WSL service directories as case-sensitive POSIX paths", async () => { + const harness = clientHarness([session("upper", undefined, "/home/dev/Foo")]) + let removed = false + await removeProjectWorktree({ + client: harness.client, + projectLocation: { directory: ROOT }, + targetDirectory: "/home/dev/foo", + rootDirectory: ROOT, + remove: async () => { removed = true }, + isTargetRegistered: async () => true, + }) + assert.equal(removed, true) + assert.deepEqual(harness.moveCalls, []) + }) + + it("evacuates a complete family before removing its worktree", async () => { + const harness = clientHarness([session("root", undefined, WORKTREE), session("child", "root", WORKTREE)]) + let removed = false + await removeProjectWorktree({ + client: harness.client, + projectLocation: { directory: ROOT }, + targetDirectory: WORKTREE, + rootDirectory: ROOT, + remove: async () => { removed = true }, + isTargetRegistered: async () => true, + }) + assert.equal(removed, true) + assert.ok([...harness.sessions.values()].every(({ location }) => location.directory === ROOT)) + }) + + it("guards every family source worktree during evacuation", async () => { + const sibling = "/repo/.codenomad/worktrees/sibling" + const harness = clientHarness([session("root", undefined, sibling), session("child", "root", WORKTREE)]) + let guarded: string[] = [] + await removeProjectWorktree({ + client: harness.client, + projectLocation: { directory: ROOT }, + targetDirectory: WORKTREE, + rootDirectory: ROOT, + remove: async () => undefined, + isTargetRegistered: async () => true, + runMutation: async (directories, operation) => { + guarded = directories + return operation() + }, + }) + assert.ok(guarded.includes(sibling)) + assert.ok(guarded.includes(WORKTREE)) + assert.ok(guarded.includes(ROOT)) + }) + + it("blocks physical deletion when another workspace still owns a target session", async () => { + const harness = clientHarness([ + session("foreign", undefined, WORKTREE, "foreign-workspace"), + ], { workspaceID: "owned-workspace" }) + await assert.rejects(() => removeProjectWorktree({ + client: harness.client, + projectLocation: { directory: ROOT, workspaceID: "owned-workspace" }, + targetDirectory: WORKTREE, + rootDirectory: ROOT, + remove: async () => assert.fail("Git removal must not run"), + isTargetRegistered: async () => true, + }), /another workspace/) + }) + + it("ignores unrelated sessions from another workspace when fencing deletion", async () => { + const harness = clientHarness([ + session("owned", undefined, WORKTREE, "owned-workspace"), + session("foreign", undefined, "/other/project", "foreign-workspace"), + ], { workspaceID: "owned-workspace" }) + let guarded: string[] = [] + await removeProjectWorktree({ + client: harness.client, + projectLocation: { directory: ROOT, workspaceID: "owned-workspace" }, + targetDirectory: WORKTREE, + rootDirectory: ROOT, + remove: async () => undefined, + isTargetRegistered: async () => true, + runMutation: async (directories, operation) => { + guarded = directories + return operation() + }, + }) + assert.equal(guarded.includes("/other/project"), false) + }) + + it("rolls back only while the original worktree identity remains", async () => { + const original = clientHarness([session("original", undefined, WORKTREE)]) + await assert.rejects(() => removeProjectWorktree({ + client: original.client, + projectLocation: { directory: ROOT }, + targetDirectory: WORKTREE, + rootDirectory: ROOT, + remove: async () => { throw new ProjectSessionError("dirty worktree", 409) }, + isTargetRegistered: async () => true, + }), /dirty worktree/) + assert.equal(original.sessions.get("original")?.location.directory, WORKTREE) + + const replacement = clientHarness([session("replacement", undefined, WORKTREE)]) + let identityChecks = 0 + await assert.rejects(() => removeProjectWorktree({ + client: replacement.client, + projectLocation: { directory: ROOT }, + targetDirectory: WORKTREE, + rootDirectory: ROOT, + remove: async () => { throw new ProjectSessionError("worktree changed", 409) }, + isTargetRegistered: async () => ++identityChecks === 1, + }), /Worktree changed/) + assert.deepEqual(replacement.moveCalls, ["replacement"]) + assert.equal(replacement.sessions.get("replacement")?.location.directory, ROOT) + }) +}) diff --git a/packages/server/src/workspaces/project-session-families.ts b/packages/server/src/workspaces/project-session-families.ts new file mode 100644 index 000000000..a32a0e88e --- /dev/null +++ b/packages/server/src/workspaces/project-session-families.ts @@ -0,0 +1,439 @@ +import path from "node:path" +import type { LocationGetOutput, LocationRef, OpenCodeClient, SessionInfo } from "@opencode-ai/client" + +const SESSION_PAGE_LIMIT = 500 +const MAX_SESSION_PAGES = 1000 +const MOVE_VERIFY_ATTEMPTS = 100 +const MOVE_VERIFY_DELAY_MS = 50 +const projectLocks = new Map>() + +export class ProjectSessionError extends Error { + constructor(message: string, readonly statusCode: number) { + super(message) + this.name = "ProjectSessionError" + } +} + +export interface SessionFamilyMoveResult { + rootSessionId: string + sessionIds: string[] +} + +interface ProjectContext { + client: OpenCodeClient + project: LocationGetOutput["project"] + workspaceID?: string +} + +export async function listCompleteProjectSessions( + client: OpenCodeClient, + projectID: string, + workspaceID?: string, +): Promise { + const sessions: SessionInfo[] = [] + const sessionIds = new Set() + const cursors = new Set() + let cursor: string | undefined + let page = 0 + do { + if (++page > MAX_SESSION_PAGES) throw new ProjectSessionError("Session inventory exceeded the page limit", 502) + const response = await client.session.list(cursor + ? { cursor } + : { project: projectID, workspace: workspaceID, limit: SESSION_PAGE_LIMIT, order: "asc" }) + if (!response || !Array.isArray(response.data) || !response.cursor || typeof response.cursor !== "object") { + throw new ProjectSessionError("OpenCode returned an invalid session inventory", 502) + } + for (const session of response.data) { + if (!session?.id + || session.projectID !== projectID + || !session.location?.directory + || (workspaceID && session.location.workspaceID !== workspaceID)) { + throw new ProjectSessionError("OpenCode returned a session outside the requested project", 409) + } + if (sessionIds.has(session.id)) throw new ProjectSessionError("Session inventory contains duplicate sessions", 409) + sessionIds.add(session.id) + sessions.push(session) + } + + const rawNext = response.cursor.next + if (rawNext != null && (typeof rawNext !== "string" || !rawNext.trim())) { + throw new ProjectSessionError("OpenCode returned an invalid session inventory cursor", 502) + } + const next = rawNext ?? undefined + if (next && cursors.has(next)) throw new ProjectSessionError(`Session inventory repeated cursor: ${next}`, 502) + if (next) cursors.add(next) + cursor = next + } while (cursor) + + return sessions +} + +export function resolveSessionFamilies(sessions: SessionInfo[]): Map { + const byId = new Map(sessions.map((session) => [session.id, session])) + if (byId.size !== sessions.length) throw new ProjectSessionError("Session inventory contains duplicate sessions", 409) + const rootById = new Map() + + const rootFor = (session: SessionInfo): string => { + const cached = rootById.get(session.id) + if (cached) return cached + const chain: SessionInfo[] = [] + const seen = new Set() + let current = session + while (current.parentID) { + if (seen.has(current.id)) throw new ProjectSessionError(`Session family contains a cycle at: ${current.id}`, 409) + seen.add(current.id) + chain.push(current) + const parent = byId.get(current.parentID) + if (!parent) throw new ProjectSessionError(`Session family is incomplete; missing parent: ${current.parentID}`, 409) + current = parent + } + if (seen.has(current.id)) throw new ProjectSessionError(`Session family contains a cycle at: ${current.id}`, 409) + rootById.set(current.id, current.id) + for (const member of chain) rootById.set(member.id, current.id) + return current.id + } + + const families = new Map() + for (const session of sessions) { + const root = rootFor(session) + const family = families.get(root) ?? [] + family.push(session) + families.set(root, family) + } + for (const family of families.values()) { + family.sort((left, right) => ancestryDepth(left, byId) - ancestryDepth(right, byId)) + } + return families +} + +export async function moveProjectSessionFamily(params: { + client: OpenCodeClient + projectLocation: LocationRef + sessionId: string + targetDirectory: string + validateTarget?: () => Promise + runMutation?: (directories: string[], operation: () => Promise) => Promise +}): Promise { + return withProject(params.client, params.projectLocation, async (context) => { + if (params.validateTarget && !await params.validateTarget()) { + throw new ProjectSessionError("Worktree changed before the session move", 409) + } + const inventory = await listCompleteProjectSessions(context.client, context.project.id) + const families = resolveSessionFamilies(inventory) + const family = Array.from(families.entries()).find(([, members]) => members.some(({ id }) => id === params.sessionId)) + if (!family) throw new ProjectSessionError("Session not found in project", 404) + assertWorkspaceFamily(family[1], context.workspaceID) + const target = await resolveProjectLocation(context, params.targetDirectory) + const move = async () => { + await assertInactive(context.client, family[1]) + if (params.validateTarget && !await params.validateTarget()) { + throw new ProjectSessionError("Worktree changed before the session move", 409) + } + await moveWithRollback(context, family[1], target) + return { rootSessionId: family[0], sessionIds: family[1].map(({ id }) => id) } + } + return params.runMutation + ? params.runMutation([...family[1].map(({ location }) => location.directory), target.directory], move) + : move() + }) +} + +export async function removeProjectWorktree(params: { + client: OpenCodeClient + projectLocation: LocationRef + targetDirectory: string + rootDirectory: string + remove: () => Promise + isTargetRegistered: () => Promise + matchesTarget?: (directory: string) => Promise + validateBeforeRemove?: (projectID: string) => Promise + runMutation?: (directories: string[], operation: () => Promise) => Promise +}): Promise { + await withProject(params.client, params.projectLocation, async (context) => { + const matchesTarget = params.matchesTarget + ?? (async (directory: string) => directoryContains(params.targetDirectory, directory)) + const initial = await matchingFamilies( + await listCompleteProjectSessions(context.client, context.project.id), + matchesTarget, + ) + for (const family of initial) assertWorkspaceFamily(family, context.workspaceID) + const destination = await resolveProjectLocation(context, params.rootDirectory) + const remove = async () => { + if (!await params.isTargetRegistered()) { + throw new ProjectSessionError("Worktree changed before deletion", 409) + } + const families = await matchingFamilies( + await listCompleteProjectSessions(context.client, context.project.id), + matchesTarget, + ) + for (const family of families) assertWorkspaceFamily(family, context.workspaceID) + await assertInactive(context.client, families.flat()) + await params.validateBeforeRemove?.(context.project.id) + const original = new Map(families.flat().map((session) => [session.id, session.location])) + const moved: string[] = [] + let root: LocationRef | undefined + + try { + if (families.length) { + root = destination + for (const family of families) await moveMembers(context, family, destination, moved) + await verifyInventory(context, moved, new Map(moved.map((id) => [id, destination]))) + } + const refreshed = await listCompleteProjectSessions(context.client, context.project.id) + if ((await Promise.all(refreshed.map(({ location }) => matchesTarget(location.directory)))).some(Boolean)) { + throw new ProjectSessionError("Sessions remain attached to the worktree after evacuation", 409) + } + await assertInactive(context.client, families.flat()) + await params.validateBeforeRemove?.(context.project.id) + if (!await params.isTargetRegistered()) { + throw new ProjectSessionError("Worktree changed before deletion", 409) + } + await params.remove() + } catch (error) { + const changed = root ? await refreshChangedSessionIds(context, moved, root) : [] + if (changed.length) { + let registered: boolean + try { + registered = await params.isTargetRegistered() + } catch (inventoryError) { + throw new ProjectSessionError( + `${errorMessage(error)}; unable to verify worktree registration, rollback skipped: ${errorMessage(inventoryError)}`, + 500, + ) + } + // A mismatched identity may now be a replacement checkout; never move sessions into it. + if (registered) await rollback(context, changed, original, error) + } + throw asProjectError(error, "Unable to remove worktree") + } + } + return params.runMutation + ? params.runMutation([...initial.flatMap((family) => family.map(({ location }) => location.directory)), params.targetDirectory, destination.directory], remove) + : remove() + }) +} + +async function matchingFamilies( + sessions: SessionInfo[], + matchesTarget: (directory: string) => Promise, +): Promise { + const matches: SessionInfo[][] = [] + for (const family of resolveSessionFamilies(sessions).values()) { + if ((await Promise.all(family.map(({ location }) => matchesTarget(location.directory)))).some(Boolean)) { + matches.push(family) + } + } + return matches +} + +function assertWorkspaceFamily(family: SessionInfo[], workspaceID: string | undefined): void { + if (!workspaceID) return + const foreign = family.filter((session) => session.location.workspaceID !== workspaceID).map(({ id }) => id) + if (foreign.length) { + throw new ProjectSessionError(`Sessions from another workspace block this operation: ${foreign.join(", ")}`, 409) + } +} + +async function withProject( + client: OpenCodeClient, + requested: LocationRef, + operation: (context: ProjectContext) => Promise, +): Promise { + let location: LocationGetOutput + try { + location = await client.location.get({ + location: { directory: requested.directory, workspace: requested.workspaceID }, + }) + } catch (error) { + throw asProjectError(error, "Unable to resolve the workspace project") + } + if (!location?.project?.id) throw new ProjectSessionError("OpenCode could not resolve the workspace project", 502) + const workspaceID = location.workspaceID ?? requested.workspaceID + const lockKey = location.project.id + const previous = projectLocks.get(lockKey) ?? Promise.resolve() + const run = previous.catch(() => undefined).then(async () => { + try { + return await operation({ client, project: location.project, workspaceID }) + } catch (error) { + throw asProjectError(error, "Project session operation failed") + } + }) + const tail = run.then(() => undefined, () => undefined) + projectLocks.set(lockKey, tail) + try { + return await run + } finally { + if (projectLocks.get(lockKey) === tail) projectLocks.delete(lockKey) + } +} + +async function resolveProjectLocation(context: ProjectContext, directory: string): Promise { + const location = await context.client.location.get({ + location: { directory, workspace: context.workspaceID }, + }) + if (!location?.directory || location.project?.id !== context.project.id) { + throw new ProjectSessionError("Target worktree does not belong to the workspace project", 409) + } + return { directory: location.directory, workspaceID: location.workspaceID } +} + +async function assertInactive(client: OpenCodeClient, sessions: SessionInfo[]): Promise { + if (!sessions.length) return + const active = await client.session.active() + const blockers = sessions.filter(({ id }) => Object.prototype.hasOwnProperty.call(active, id)).map(({ id }) => id) + if (blockers.length) throw new ProjectSessionError(`Active sessions block this operation: ${blockers.join(", ")}`, 409) +} + +async function moveWithRollback(context: ProjectContext, family: SessionInfo[], target: LocationRef): Promise { + const original = new Map(family.map((session) => [session.id, session.location])) + const moved: string[] = [] + try { + await moveMembers(context, family, target, moved) + const refreshed = await verifyInventory(context, moved, new Map(moved.map((id) => [id, target]))) + const refreshedFamily = resolveSessionFamilies(refreshed).get(family[0]!.id) + if (!refreshedFamily + || !family.every(({ id }) => refreshedFamily.some((session) => session.id === id)) + || !refreshedFamily.every((session) => sameLocation(session.location, target))) { + throw new ProjectSessionError("Session family changed during the move", 409) + } + } catch (error) { + const changed = await refreshChangedSessionIds(context, family.map(({ id }) => id), target) + await rollback(context, changed, original, error) + throw asProjectError(error, "Unable to move session family") + } +} + +async function moveMembers( + context: ProjectContext, + members: SessionInfo[], + target: LocationRef, + moved: string[], +): Promise { + for (const session of members) { + await assertInactive(context.client, [session]) + moved.push(session.id) + await context.client.session.move({ + sessionID: session.id, + directory: target.directory, + workspaceID: target.workspaceID, + }) + await waitForSessionLocation(context, session.id, target, `Session move verification failed: ${session.id}`) + } +} + +async function refreshChangedSessionIds( + context: ProjectContext, + candidates: string[], + transactionLocation: LocationRef, +): Promise { + const changed: string[] = [] + for (const id of candidates) { + try { + const session = await context.client.session.get({ sessionID: id }) + if (session.id !== id || session.projectID !== context.project.id) { + throw new ProjectSessionError(`OpenCode returned the wrong session while determining rollback state: ${id}`, 502) + } + if (sameLocation(session.location, transactionLocation)) { + changed.push(id) + } + } catch (error) { + throw new ProjectSessionError(`Unable to determine rollback state for ${id}: ${errorMessage(error)}`, 500) + } + } + return changed +} + +async function rollback( + context: ProjectContext, + moved: string[], + original: Map, + cause: unknown, +): Promise { + try { + for (const sessionId of [...moved].reverse()) { + const location = original.get(sessionId)! + await context.client.session.move({ sessionID: sessionId, directory: location.directory, workspaceID: location.workspaceID }) + await waitForSessionLocation(context, sessionId, location, `Session rollback verification failed: ${sessionId}`) + } + await verifyInventory(context, moved, original) + } catch (rollbackError) { + throw new ProjectSessionError( + `${errorMessage(cause)}; rollback failed: ${errorMessage(rollbackError)}`, + 500, + ) + } +} + +async function verifyInventory( + context: ProjectContext, + sessionIds: string[], + expected: Map, +): Promise { + for (let attempt = 0; attempt < MOVE_VERIFY_ATTEMPTS; attempt += 1) { + const sessions = await listCompleteProjectSessions(context.client, context.project.id, context.workspaceID) + const refreshed = new Map(sessions.map((session) => [session.id, session])) + if (sessionIds.every((sessionId) => { + const session = refreshed.get(sessionId) + return Boolean(session && sameLocation(session.location, expected.get(sessionId)!)) + })) return sessions + await new Promise((resolve) => setTimeout(resolve, MOVE_VERIFY_DELAY_MS)) + } + throw new ProjectSessionError("Timed out waiting for session inventory verification", 409) +} + +async function waitForSessionLocation( + context: ProjectContext, + sessionId: string, + expected: LocationRef, + message: string, +): Promise { + for (let attempt = 0; attempt < MOVE_VERIFY_ATTEMPTS; attempt += 1) { + const session = await context.client.session.get({ sessionID: sessionId }) + if (session.id !== sessionId || session.projectID !== context.project.id) { + throw new ProjectSessionError(`OpenCode returned the wrong session after move: ${sessionId}`, 502) + } + if (sameLocation(session.location, expected)) return session + await new Promise((resolve) => setTimeout(resolve, MOVE_VERIFY_DELAY_MS)) + } + throw new ProjectSessionError(message, 409) +} + +function sameLocation(left: LocationRef, right: LocationRef): boolean { + return sameDirectory(left.directory, right.directory) && left.workspaceID === right.workspaceID +} + +function ancestryDepth(session: SessionInfo, byId: Map): number { + let depth = 0 + let current = session + while (current.parentID) { + current = byId.get(current.parentID)! + depth += 1 + } + return depth +} + +function sameDirectory(left: string, right: string): boolean { + if (isWindowsPath(left) !== isWindowsPath(right)) return false + if (!isWindowsPath(left)) return path.posix.resolve(left) === path.posix.resolve(right) + return path.win32.resolve(left).toLowerCase() === path.win32.resolve(right).toLowerCase() +} + +function directoryContains(parent: string, child: string): boolean { + if (isWindowsPath(parent) !== isWindowsPath(child)) return false + const paths = isWindowsPath(parent) ? path.win32 : path.posix + const relative = paths.relative(parent, child) + return relative === "" || (relative !== ".." && !relative.startsWith(`..${paths.sep}`) && !paths.isAbsolute(relative)) +} + +function isWindowsPath(value: string): boolean { + return /^[a-z]:[\\/]/i.test(value) || value.startsWith("\\\\") +} + +function asProjectError(error: unknown, fallback: string): ProjectSessionError { + if (error instanceof ProjectSessionError) return error + return new ProjectSessionError(error instanceof Error ? error.message : fallback, 502) +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/packages/server/src/workspaces/worktree-directory.test.ts b/packages/server/src/workspaces/worktree-directory.test.ts index c66032ff6..ffa2a1863 100644 --- a/packages/server/src/workspaces/worktree-directory.test.ts +++ b/packages/server/src/workspaces/worktree-directory.test.ts @@ -38,6 +38,7 @@ test("resolves nested and junction paths to their canonical owning worktree", as assert.equal(resolved?.slug, "doomed") assert.equal(resolved?.directory, await realpath(path.join(worktree, "nested"))) + assert.equal(resolved?.worktreeDirectory, await realpath(worktree)) assert.equal((await resolveOwnedWorktreePath({ workspaceId, workspacePath: root, diff --git a/packages/server/src/workspaces/worktree-directory.ts b/packages/server/src/workspaces/worktree-directory.ts index 5348f1c3b..153ea2a31 100644 --- a/packages/server/src/workspaces/worktree-directory.ts +++ b/packages/server/src/workspaces/worktree-directory.ts @@ -6,7 +6,7 @@ import { listWorktrees, resolveRepoRoot } from "./git-worktrees" type WorktreeCacheEntry = { expiresAt: number repoRoot: string - worktrees: Array<{ slug: string; directory: string; normalizedDirectory: string }> + worktrees: Array<{ slug: string; directory: string; normalizedDirectory: string; worktreeDirectory: string }> resolvedDirectories: Map } @@ -45,6 +45,7 @@ async function getCachedWorktrees(params: { workspaceId: string; workspacePath: slug: wt.slug, directory: wt.directory, normalizedDirectory: await normalizeDirectoryPath(wt.directory), + worktreeDirectory: await normalizeDirectoryPath(wt.registeredDirectory ?? wt.directory), })), ), resolvedDirectories: new Map(), @@ -177,7 +178,7 @@ export async function resolveOwnedWorktreePath(params: { entry = await getCachedWorktrees(params) match = find(entry.worktrees) } - const resolved = match ? { slug: match.slug, directory: target, worktreeDirectory: match.normalizedDirectory } : null + const resolved = match ? { slug: match.slug, directory: target, worktreeDirectory: match.worktreeDirectory } : null if (resolved) entry.resolvedDirectories.set(target, resolved) return resolved } diff --git a/packages/server/src/workspaces/worktree-session-evacuation.test.ts b/packages/server/src/workspaces/worktree-session-evacuation.test.ts index 5d433b843..80245d718 100644 --- a/packages/server/src/workspaces/worktree-session-evacuation.test.ts +++ b/packages/server/src/workspaces/worktree-session-evacuation.test.ts @@ -1,13 +1,8 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" -import type { OpenCodeClient, SessionInfo } from "@opencode-ai/client" -import { evacuateWorktreeSessions, WorktreeDeletionFence } from "./worktree-session-evacuation" +import { WorktreeDeletionFence } from "./worktree-session-evacuation" -function session(id: string, directory: string, parentID?: string): SessionInfo { - return { id, parentID, projectID: "project", location: { directory }, cost: 0, tokens: {}, time: { created: 1, updated: 1 } } as SessionInfo -} - -describe("evacuateWorktreeSessions", () => { +describe("WorktreeDeletionFence", () => { it("serializes deletion attempts for the same worktree", async () => { const fence = new WorktreeDeletionFence() let release!: () => void @@ -57,145 +52,4 @@ describe("evacuateWorktreeSessions", () => { releaseMutation() }) - it("finds later-page sessions and waits for their asynchronous moves", async () => { - const moves: Array<{ sessionID: string; directory: string }> = [] - const lists: unknown[] = [] - let listCall = 0 - const root = session("old-root", "/repo/worktree") - const child = session("old-child", "/repo/worktree", root.id) - const grandchild = session("old-grandchild", "/repo/worktree", child.id) - const state = new Map([root, child, grandchild].map((item) => [item.id, item])) - let removed = false - const client = { - project: { - list: async () => [{ id: "project", canonical: "/repo", sandboxes: ["/repo/worktree"], time: { created: 1, updated: 1 } }], - }, - session: { - list: async (input: unknown) => { - lists.push(input) - listCall += 1 - if (listCall === 1) return { data: [session("loaded", "/repo")], cursor: { next: "older" } } - if (listCall === 2) return { data: [root, child, grandchild], cursor: {} } - return { data: [session("loaded", "/repo"), ...state.values()], cursor: {} } - }, - active: async () => ({}), - move: async (input: { sessionID: string; directory: string }) => { - moves.push(input) - setImmediate(() => state.set(input.sessionID, { ...state.get(input.sessionID)!, location: { directory: input.directory } })) - }, - }, - } as unknown as OpenCodeClient - - await evacuateWorktreeSessions({ - client, projectDirectory: "/repo", targetDirectory: "/repo/worktree", rootDirectory: "/repo", - remove: async () => { removed = true }, - }) - - assert.deepEqual(moves.map(({ sessionID }) => sessionID), [root.id, child.id, grandchild.id]) - assert.equal(removed, true) - assert.ok(listCall > 3) - assert.ok(lists.every((input: any) => input.cursor - ? Object.keys(input).length === 1 - : input.project === "project" && input.directory === undefined)) - }) - - it("evacuates sessions whose directory resolves to the target alias", async () => { - const aliased = session("aliased", "/repo/alias") - let current = aliased - let removed = false - const client = { - project: { list: async () => [{ id: "project", canonical: "/repo", sandboxes: ["/repo/worktree"], time: { created: 1, updated: 1 } }] }, - session: { - list: async () => ({ data: [current], cursor: {} }), - active: async () => ({}), - move: async (input: { directory: string }) => { current = { ...current, location: { directory: input.directory } } }, - }, - } as unknown as OpenCodeClient - - await evacuateWorktreeSessions({ - client, - projectDirectory: "/repo", - targetDirectory: "/repo/worktree", - rootDirectory: "/repo", - resolveDirectoryIdentity: async (directory) => directory === "/repo/alias" ? "/repo/worktree" : directory, - remove: async () => { removed = true }, - }) - - assert.equal(current.location.directory, "/repo") - assert.equal(removed, true) - }) - - it("evacuates sessions nested under the target worktree identity", async () => { - let current = session("nested", "/repo/worktree/nested") - let removed = false - const client = { - project: { list: async () => [{ id: "project", canonical: "/repo", sandboxes: ["/repo/worktree"], time: { created: 1, updated: 1 } }] }, - session: { - list: async () => ({ data: [current], cursor: {} }), - active: async () => ({}), - move: async (input: { directory: string }) => { current = { ...current, location: { directory: input.directory } } }, - }, - } as unknown as OpenCodeClient - - await evacuateWorktreeSessions({ - client, - projectDirectory: "/repo", - targetDirectory: "/repo/worktree", - rootDirectory: "/repo", - resolveDirectoryIdentity: async (directory) => directory.startsWith("/repo/worktree") ? "workspace:worktree" : "workspace:root", - remove: async () => { removed = true }, - }) - - assert.equal(current.location.directory, "/repo") - assert.equal(removed, true) - }) - - it("rolls sessions back when Git removal fails", async () => { - const current = session("session", "/repo/worktree") - const moves: string[] = [] - const client = { - project: { list: async () => [{ id: "project", canonical: "/repo", sandboxes: ["/repo/worktree"], time: { created: 1, updated: 1 } }] }, - session: { - list: async () => ({ data: [current], cursor: {} }), - active: async () => ({}), - move: async ({ directory }: { directory: string }) => { - moves.push(directory) - current.location = { directory } - }, - }, - } as unknown as OpenCodeClient - - await assert.rejects(evacuateWorktreeSessions({ - client, projectDirectory: "/repo", targetDirectory: "/repo/worktree", rootDirectory: "/repo", - remove: async () => { throw new Error("Git removal failed") }, - }), /Git removal failed/) - assert.deepEqual(moves, ["/repo", "/repo/worktree"]) - assert.equal(current.location.directory, "/repo/worktree") - }) - - it("re-inventories active sessions immediately before removal", async () => { - const current = session("session", "/repo/worktree") - const intruder = session("intruder", "/repo/worktree") - let listCalls = 0 - let removed = false - const client = { - project: { list: async () => [{ id: "project", canonical: "/repo", sandboxes: ["/repo/worktree"], time: { created: 1, updated: 1 } }] }, - session: { - list: async () => { - listCalls += 1 - return { data: listCalls >= 3 ? [current, intruder] : [current], cursor: {} } - }, - active: async () => ({ intruder: { type: "running" } }), - move: async ({ directory }: { directory: string }) => { current.location = { directory } }, - }, - } as unknown as OpenCodeClient - - await assert.rejects(evacuateWorktreeSessions({ - client, projectDirectory: "/repo", targetDirectory: "/repo/worktree", rootDirectory: "/repo", - remove: async () => { removed = true }, - }), /Active sessions block worktree deletion: intruder/) - - assert.equal(removed, false) - assert.equal(current.location.directory, "/repo/worktree") - }) }) diff --git a/packages/server/src/workspaces/worktree-session-evacuation.ts b/packages/server/src/workspaces/worktree-session-evacuation.ts index 4724ba2a4..86411aaa1 100644 --- a/packages/server/src/workspaces/worktree-session-evacuation.ts +++ b/packages/server/src/workspaces/worktree-session-evacuation.ts @@ -1,9 +1,5 @@ -import type { OpenCodeClient, SessionInfo } from "@opencode-ai/client" import { normalizeWslUncPath } from "./worktree-directory" -const PAGE_SIZE = 200 -const MAX_PAGES = 10_000 -const MAX_SESSIONS = 1_000_000 const MUTATION_DRAIN_TIMEOUT_MS = 30_000 function normalizeDirectory(directory: string): string { @@ -86,118 +82,3 @@ export class WorktreeDeletionFence { }) } } - -async function inventorySessions(client: OpenCodeClient, project: string): Promise { - const sessions = new Map() - const cursors = new Set() - let cursor: string | undefined - - for (let pageCount = 0; pageCount < MAX_PAGES; pageCount += 1) { - const page = await client.session.list(cursor ? { cursor } : { project, limit: PAGE_SIZE, order: "asc" }) - for (const session of page.data) { - sessions.set(session.id, session) - if (sessions.size > MAX_SESSIONS) throw new Error("Session inventory exceeded its safety limit") - } - - cursor = page.cursor.next ?? undefined - if (!cursor) return Array.from(sessions.values()) - if (cursors.has(cursor)) throw new Error(`Repeated session inventory cursor: ${cursor}`) - cursors.add(cursor) - } - - throw new Error("Session inventory exceeded its page limit") -} - -async function waitForInventory( - client: OpenCodeClient, - project: string, - predicate: (sessions: SessionInfo[]) => boolean | Promise, -): Promise { - for (let attempt = 0; attempt < 100; attempt += 1) { - if (await predicate(await inventorySessions(client, project))) return - await new Promise((resolve) => setTimeout(resolve, 50)) - } - throw new Error("Timed out waiting for session moves") -} - -export async function evacuateWorktreeSessions(params: { - client: OpenCodeClient - projectDirectory: string - targetDirectory: string - rootDirectory: string - resolveDirectoryIdentity?: (directory: string) => Promise - remove: () => Promise -}): Promise { - const identity = async (directory: string) => normalizeDirectory( - await params.resolveDirectoryIdentity?.(directory) ?? directory, - ) - const target = await identity(params.targetDirectory) - const matchesTarget = async (directory: string) => await identity(directory) === target - const projects = await params.client.project.list() - let project: (typeof projects)[number] | undefined - for (const candidate of projects) { - if (normalizeDirectory(candidate.canonical) === normalizeDirectory(params.projectDirectory) - || (await Promise.all(candidate.sandboxes.map(matchesTarget))).some(Boolean)) { - project = candidate - break - } - } - if (!project) throw new Error("Unable to resolve the OpenCode project before deleting worktree") - const sessions = await inventorySessions(params.client, project.id) - const affected = (await Promise.all(sessions.map(async (session) => ( - await matchesTarget(session.location.directory) ? session : undefined - )))).filter((session): session is SessionInfo => Boolean(session)) - const assertInactive = async (candidates = affected) => { - const active = await params.client.session.active() - const blockers = candidates.filter((session) => Object.prototype.hasOwnProperty.call(active, session.id)) - if (blockers.length) throw new Error(`Active sessions block worktree deletion: ${blockers.map((session) => session.id).join(", ")}`) - } - await assertInactive() - - const moved: SessionInfo[] = [] - try { - for (const session of affected) { - await assertInactive() - const original = { ...session, location: { ...session.location } } - await params.client.session.move({ sessionID: session.id, directory: params.rootDirectory }) - moved.push(original) - } - await waitForInventory(params.client, project.id, async (current) => ( - !(await Promise.all(current.map((session) => matchesTarget(session.location.directory)))).some(Boolean) - )) - const finalInventory = await inventorySessions(params.client, project.id) - const finalAffected = (await Promise.all(finalInventory.map(async (session) => ( - await matchesTarget(session.location.directory) ? session : undefined - )))).filter((session): session is SessionInfo => Boolean(session)) - await assertInactive(finalAffected) - if (finalAffected.length) throw new Error("Sessions appeared in the worktree during deletion") - await params.remove() - } catch (error) { - const rollbackErrors: unknown[] = [] - for (const session of moved.reverse()) { - try { - await params.client.session.move({ - sessionID: session.id, - directory: session.location.directory, - workspaceID: session.location.workspaceID, - }) - } catch (rollbackError) { - rollbackErrors.push(rollbackError) - } - } - try { - const expected = new Set(moved.map((session) => session.id)) - await waitForInventory(params.client, project.id, async (current) => { - const restored = current.filter((session) => expected.has(session.id)) - return restored.length === expected.size - && (await Promise.all(restored.map((session) => matchesTarget(session.location.directory)))).every(Boolean) - }) - } catch (rollbackError) { - rollbackErrors.push(rollbackError) - } - if (rollbackErrors.length) { - throw new AggregateError([error, ...rollbackErrors], "Session evacuation failed and could not be rolled back") - } - throw error - } -} diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 102a5ea97..9083d5bd7 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -17,6 +17,7 @@ import { SideCarView } from "./components/sidecar-view" import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context" import { showAlertDialog } from "./stores/alerts" import { initGithubStars } from "./stores/github-stars" +import { reloadWorktrees } from "./stores/worktrees" import { useCommands } from "./lib/hooks/use-commands" import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle" @@ -324,6 +325,7 @@ const App: Component = () => { sessionError = error } await Promise.all([ + reloadWorktrees(id), syncPendingRequests(id, (invalidate) => { invalidatePendingRequests = invalidate }), refreshVolatileInstanceState(id), syncLoadedSessionInboxes(id), diff --git a/packages/ui/src/components/session-list.tsx b/packages/ui/src/components/session-list.tsx index 89845551d..378a95ec0 100644 --- a/packages/ui/src/components/session-list.tsx +++ b/packages/ui/src/components/session-list.tsx @@ -23,6 +23,7 @@ import { setActiveSessionFromList, toggleSessionExpanded, loadMoreSessions, + loadAllSessions, searchSessions, getSessionHasMore, getSessionListError, @@ -32,8 +33,9 @@ import { getSessionSearchThreads, isSessionSearchLoading, } from "../stores/sessions" -import { getGitRepoStatus, getWorktreeSlugForParentSession } from "../stores/worktrees" -import { collectSessionThreadIds, findSessionThread, flattenVisibleSessionThreads, sortSessionIdsDeepestFirst } from "../stores/session-tree" +import { getGitRepoStatus, getWorktreeSlugForParentSession, getWorktrees } from "../stores/worktrees" +import { collectSessionThreadIds, findSessionThread, flattenVisibleSessionThreads, projectSessionFamilies, sortSessionIdsDeepestFirst, type SessionFamilySort } from "../stores/session-tree" +import { normalizeSessionDirectory } from "../stores/session-list-options" import { getLogger } from "../lib/logger" import { copyToClipboard } from "../lib/clipboard" import { useConfig } from "../stores/preferences" @@ -66,7 +68,19 @@ const SessionList: Component = (props) => { const [isRenaming, setIsRenaming] = createSignal(false) const [filterQuery, setFilterQuery] = createSignal("") + const [sortBy, setSortBy] = createSignal("activity") + const [worktreeDirectory, setWorktreeDirectory] = createSignal("") const normalizedQuery = createMemo(() => (props.enableFilterBar ? filterQuery().trim().toLowerCase() : "")) + let failedSortExhaustion: string | undefined + + createEffect(() => { + const selected = normalizeSessionDirectory(worktreeDirectory()) + if (!selected) return + const exists = getWorktrees(props.instanceId).some((worktree) => ( + normalizeSessionDirectory(worktree.serviceDirectory ?? worktree.directory) === selected + )) + if (!exists) setWorktreeDirectory("") + }) const [selectedSessionIds, setSelectedSessionIds] = createSignal>(new Set()) const [reloadingSessionIds, setReloadingSessionIds] = createSignal>(new Set()) @@ -113,7 +127,23 @@ const SessionList: Component = (props) => { }) const sessionListError = createMemo(() => getSessionListError(props.instanceId)) + createEffect(() => { + const sort = sortBy() + const key = `${props.instanceId}:${sort}` + if (sort === "activity") { + failedSortExhaustion = undefined + return + } + if (normalizedQuery() || failedSortExhaustion === key + || !getSessionHasMore(props.instanceId) || isFetchingSessions()) return + void loadAllSessions(props.instanceId).catch((error) => { + failedSortExhaustion = key + log.error("Failed to load all sessions for sorting:", error) + }) + }) + const handleRetrySessions = () => { + failedSortExhaustion = undefined void fetchSessions(props.instanceId, { reset: true }).catch((error) => { log.error("Failed to retry session list:", error) }) @@ -127,6 +157,7 @@ const SessionList: Component = (props) => { (entries) => { const entry = entries[0] if (entry?.isIntersecting && hasMore() && !isFetchingSessions()) { + failedSortExhaustion = undefined void loadMoreSessions(props.instanceId).catch((error) => { log.error("Failed to load more sessions:", error) }) @@ -186,32 +217,25 @@ const SessionList: Component = (props) => { return sessionId.toLowerCase().includes(query) } - const filterThreadTree = (thread: SessionThread, query: string): SessionThread | null => { - const matchingChildren: SessionThread[] = [] - for (const child of thread.children) { - const filteredChild = filterThreadTree(child, query) - if (filteredChild !== null) matchingChildren.push(filteredChild) - } - if (!sessionMatchesQuery(thread.session.id, query) && matchingChildren.length === 0) return null - return { ...thread, children: matchingChildren } - } - const filteredThreads = createMemo(() => { const query = normalizedQuery() - if (!query) return props.threads - - const searchQuery = getSessionSearchQuery(props.instanceId) - const searchLoading = isSessionSearchLoading(props.instanceId) - if (searchQuery === query && !searchLoading) { - return getSessionSearchThreads(props.instanceId) - } - - const result: SessionThread[] = [] - for (const thread of props.threads) { - const filtered = filterThreadTree(thread, query) - if (filtered !== null) result.push(filtered) + const searchThreads = query && getSessionSearchQuery(props.instanceId) === query && !isSessionSearchLoading(props.instanceId) + ? getSessionSearchThreads(props.instanceId) + : props.threads + const worktrees = getWorktrees(props.instanceId) + const getWorktreeLabel = (directory: string) => { + const normalized = normalizeSessionDirectory(directory) + const worktree = worktrees.find((candidate) => normalizeSessionDirectory(candidate.serviceDirectory ?? candidate.directory) === normalized) + return worktree?.kind === "root" ? t("sessionList.worktree.workspace") : worktree?.slug ?? directory } - return result + return projectSessionFamilies(searchThreads, { + sort: sortBy(), + worktreeDirectory: worktreeDirectory(), + getWorktreeLabel, + ...(query && searchThreads === props.threads + ? { matchesSession: (session) => sessionMatchesQuery(session.id, query) } + : {}), + }) }) const visibleProjection = createMemo(() => { @@ -251,6 +275,14 @@ const SessionList: Component = (props) => { const selectedCount = createMemo(() => selectedSessionIds().size) + createEffect(() => { + const available = new Set(allMatchingSessionIds()) + setSelectedSessionIds((selected) => { + const next = new Set([...selected].filter((id) => available.has(id))) + return next.size === selected.size ? selected : next + }) + }) + const isAllSelected = createMemo(() => { const ids = allMatchingSessionIds() if (ids.length === 0) return false @@ -423,8 +455,7 @@ const SessionList: Component = (props) => { } const getSelectableThreadIds = (sessionId: string): string[] => { - const source = normalizedQuery() ? filteredThreads() : props.threads - const thread = findSessionThread(source, sessionId) + const thread = findSessionThread(filteredThreads(), sessionId) return thread ? collectSessionThreadIds([thread]) : [sessionId] } @@ -528,14 +559,14 @@ const SessionList: Component = (props) => { const worktreeSlug = createMemo(() => { if (isChild()) return "root" - return getWorktreeSlugForParentSession(props.instanceId, sessionId()) + const slug = getWorktreeSlugForParentSession(props.instanceId, sessionId()) + return slug === "root" ? t("sessionList.worktree.workspace") : slug }) const showWorktreeBadge = createMemo(() => { if (isChild()) return false - if (getGitRepoStatus(props.instanceId) === false) return false - const slug = worktreeSlug() - return Boolean(slug) && slug !== "root" + if (getGitRepoStatus(props.instanceId) !== true) return false + return Boolean(worktreeSlug()) }) const isActive = () => props.activeSessionId === sessionId() @@ -692,7 +723,7 @@ const SessionList: Component = (props) => { - + @@ -825,6 +856,30 @@ const SessionList: Component = (props) => { +
+ + +
+ 0}>
-
{displayPathFor(opt.directory)} -
@@ -394,18 +372,26 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) { return (
- Worktree: Unavailable + + {t("sessionList.worktree.tooltip", { worktree: "" }).trim()} + {" "} + {t("instanceShell.worktree.unavailable")}
) } const value = state.selectedOption() - const label = value && value.kind === "worktree" ? (value.slug === "root" ? "Workspace" : value.slug) : "Workspace" + const label = value && value.kind === "worktree" + ? (value.slug === "root" ? t("sessionList.worktree.workspace") : value.slug) + : t("sessionList.worktree.workspace") return (
- Worktree: {label} + + {t("sessionList.worktree.tooltip", { worktree: "" }).trim()} + {" "} + {label}
) @@ -424,23 +410,80 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) { + + {(worktree) => ( +
+ + + + + + + + +
+ )} +
+ !open && setCreateOpen(false)}>
- Create worktree - Creates a git worktree + {t("instanceShell.worktree.create.action")} + + {t("instanceShell.worktree.create.description")} +
- + setCreateSlug(e.currentTarget.value)} - placeholder="worktree-name" + placeholder={t("instanceShell.worktree.create.placeholder")} disabled={isCreating()} spellcheck={false} autocapitalize="off" @@ -455,7 +498,7 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) { onClick={() => setCreateOpen(false)} disabled={isCreating()} > - Cancel + {t("sessionPicker.actions.cancel")}
@@ -502,15 +545,17 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
- Delete worktree - Deletes this branch worktree and its local folder. + {t("instanceShell.worktree.delete.action")} + + {t("instanceShell.worktree.delete.description")} +
{(target) => ( -
+

- Worktree "{target().slug}" + {t("instanceShell.worktree.delete.target", { slug: target().slug })}

{target().directory}

@@ -524,7 +569,7 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) { onChange={(e) => setForceDelete(e.currentTarget.checked)} disabled={isDeleting()} /> - Force delete (discard local changes) + {t("instanceShell.worktree.delete.force")}
@@ -534,7 +579,7 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) { onClick={closeDeleteDialog} disabled={isDeleting()} > - Cancel + {t("sessionPicker.actions.cancel")}
{(message) => ( -
+

{t("instanceShell.worktree.delete.error.title")} @@ -593,7 +640,7 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {

-
{message()}
+
{message()}