diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 125692363..75aed5abb 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -131,10 +131,11 @@ jobs: packages/ui/src/stores/message-v2/message-hydration-authority.test.ts packages/ui/src/stores/message-v2/message-status.test.ts packages/ui/src/stores/message-v2/normalizers.test.ts - packages/ui/src/stores/pty-store.test.ts + packages/ui/src/stores/shell-store.test.ts 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/workspace-load-readiness.test.ts packages/ui/src/types/session.test.ts packages/ui/src/stores/workspace-list-reconciliation-fence.test.ts @@ -149,12 +150,13 @@ jobs: packages/ui/src/stores/instances-restore-ownership.test.ts packages/ui/src/stores/opencode-data.test.ts packages/ui/src/stores/permission-lifecycle.test.ts - packages/ui/src/stores/pty-store-reactivity.test.ts + packages/ui/src/stores/shell-store-reactivity.test.ts packages/ui/src/stores/session-actions.test.ts packages/ui/src/stores/session-native-events.test.ts 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 - name: Test server run: node --import tsx --test "packages/server/src/**/*.test.ts" diff --git a/.opencode/skills/codenomad-architecture-guide/SKILL.md b/.opencode/skills/codenomad-architecture-guide/SKILL.md index 340c876ac..e92791af9 100644 --- a/.opencode/skills/codenomad-architecture-guide/SKILL.md +++ b/.opencode/skills/codenomad-architecture-guide/SKILL.md @@ -20,7 +20,7 @@ description: | - There is no `packages/opencode-plugin/`. Do not restore plugin tools, plugin routes, or plugin packaging. - The server owns one shared OpenCode service through `OpenCodeSharedService` and its lease-locked discovery, launcher, process-proof, and authenticated-stop lifecycle. Proven host shutdown delegates to native `Service.stop`; WSL uses native authenticated health stop to avoid the client's cross-namespace PID fallback. Workspaces are native OpenCode `Location`/directory scopes, not separate OpenCode processes. - The UI uses generated Promise clients from `OpenCode.make()` through the CodeNomad proxy. -- OpenCode owns session APIs, native Shell (`client.session.shell`), session instructions (`client.session.instructions.entry`), and location-scoped native PTYs. Shell remains separate. The Status panel lists PTYs, refreshes on PTY events/reconnect, displays native metadata, and supports title updates and ownership-checked removal. Current installed declarations have no PTY output/read/stream or separate stop API, so output and distinct stop are unavailable; removal is the native stop action for a running PTY. +- OpenCode owns session APIs, session Shell (`client.session.shell`), session instructions (`client.session.instructions.entry`), location-scoped background Shells, and interactive PTYs. The Status panel lists `client.shell.*` records, refreshes on Shell events/reconnect, displays native metadata, and supports ownership-checked removal. Interactive `client.pty.*` terminals remain separate. - CodeNomad owns workspace lifecycle, directory authorization, Git status/diff/stage/unstage/commit, Yolo persistence/auto-replies, and `/api/events`. - V2 service startup forces `OPENCODE_DB` to `~/.local/share/opencode2/opencode.db`; never share the V1 database with V2. @@ -61,7 +61,7 @@ description: | | Public `@opencode-ai/sdk` examples | Installed experimental `@opencode-ai/client` declarations | | One `opencode serve` per workspace | One CodeNomad-managed shared service | | Per-worktree clients/processes | Root proxy client plus native location/directory inputs | -| Reintroducing `packages/opencode-plugin` or server plugin/background-process paths | Separate native Shell/instructions and native PTY management | +| Reintroducing `packages/opencode-plugin` or server plugin/background-process paths | Native session Shell/instructions, background `shell.*`, and separate interactive `pty.*` management | | OpenCode APIs for stage/commit/Yolo policy | CodeNomad routes and managers | | Hardcoded UI strings | `t()` / `tGlobal()` and every locale | diff --git a/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md b/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md index bd903918d..b0c0486ca 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md +++ b/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md @@ -16,12 +16,12 @@ The server uses `packages/server/src/workspaces/opencode-service.ts` for a lease | Owner | Responsibilities | Main paths | |---|---|---| -| OpenCode V2 | Sessions, messages, permissions/questions, files, native Shell/instructions, location-scoped PTYs | latest reviewed experimental `@opencode-ai/client` `next` protocol | +| OpenCode V2 | Sessions, messages, permissions/questions, files, session Shell/instructions, background Shells, interactive PTYs | latest reviewed experimental `@opencode-ai/client` `next` protocol | | CodeNomad server | Shared service lifecycle, locations, proxy authorization, Git mutations, Yolo, auth, storage, speech, SSE multiplexing | `packages/server/src/` | | CodeNomad UI | Generated Promise clients, state reconciliation, interaction and rendering | `packages/ui/src/` | | Desktop hosts | Start CodeNomad and provide native OS integration | `packages/electron-app/`, `packages/tauri-app/` | -Native Shell remains separate from PTY management. The Status panel lists location-scoped PTYs, refreshes on PTY events/reconnect, displays native metadata, and supports title updates and ownership-checked removal. Current installed declarations have no PTY output/read/stream or separate stop endpoint; output display and a distinct stop action are unavailable, and removal is the native stop action for a running PTY. `packages/opencode-plugin/` and the server plugin/background-process integration remain deleted and must not be restored or used as extension points. +Session Shell remains separate from background Shell and PTY management. The Status panel lists location-scoped `shell.*` records, refreshes on Shell events/reconnect, displays native metadata, and supports ownership-checked removal. Output preserves native cursor pagination; interactive `pty.*` terminals remain separate. `packages/opencode-plugin/` and the server plugin/background-process integration remain deleted and must not be restored or used as extension points. ## HTTP And Events diff --git a/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md b/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md index 83ba99339..61371ba01 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md +++ b/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md @@ -14,9 +14,9 @@ 1. UI obtains `getRootClient(instanceId)`. 2. Conversation mode updates `client.session.instructions.entry` for the voice instruction. 3. A normal prompt calls `client.session.prompt`; `!` shell mode calls native `client.session.shell`. Native Shell remains separate from PTY management. -4. The Status panel lists native PTYs for the active location, displays their native metadata, and refreshes on PTY events and reconnect. -5. Title updates and removal use native PTY APIs; the proxy verifies native `cwd` ownership before ID-scoped operations. Removing a running PTY is its native stop action. -6. Current installed declarations have no PTY output/read/stream API or separate stop endpoint, so output display and a distinct stop action are unavailable. +4. The Status panel lists native background Shells for the active location, displays their native metadata, and refreshes on Shell events and reconnect. +5. Removal uses native Shell APIs; the proxy verifies native `cwd` ownership before every ID-scoped operation and preserves output cursors. +6. Interactive PTYs use separate `pty.*` APIs and are not background-process records. 7. The proxy checks directory/session ownership and forwards to the shared service's `/api/*` route. 8. One upstream event subscription feeds `InstanceEventBridge`, then CodeNomad `/api/events`, then UI stores. diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md index 6f6a28ee3..fd1cc9050 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md @@ -26,13 +26,13 @@ | Concern | Owner | |---|---| -| Session/message/Shell/instructions | OpenCode native API; Shell remains separate from PTY management | -| PTY list/metadata/title/remove | Location-scoped OpenCode native API through CodeNomad ownership checks; Status UI refreshes on PTY events/reconnect | -| PTY output/distinct stop | Unavailable in current installed declarations; removal is the native stop action for a running PTY | +| Session/message/Shell/instructions | OpenCode native API; session Shell remains separate from background Shell and PTY management | +| Background Shell list/metadata/output/remove | Location-scoped OpenCode native API through CodeNomad ownership checks; Status UI refreshes on Shell events/reconnect | +| Interactive PTYs | Separate native `pty.*` API | | Service discovery/start/stop | CodeNomad hardened adapter using selected OpenCode primitives | | Workspace and directory authorization | CodeNomad | | Git status/diff and mutations | CodeNomad | | Yolo policy/persistence/auto-reply | CodeNomad | | Browser event multiplexing | CodeNomad `/api/events` | -Current installed declarations have no PTY output/read/stream API or separate stop endpoint, so the UI cannot display PTY output or offer a distinct stop action. Do not restore `@opencode-ai/sdk`, per-workspace processes, `packages/opencode-plugin`, server plugin/background-process tools, or deleted plugin/runtime file paths. +Background Shell output uses native cursor pagination; interactive PTYs remain separate. Do not restore `@opencode-ai/sdk`, per-workspace processes, `packages/opencode-plugin`, server plugin/background-process tools, or deleted plugin/runtime file paths. diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md index ac0f99b59..f04daadaa 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md @@ -22,14 +22,14 @@ const client = OpenCode.make({ baseUrl, fetch: createInstanceFetch(baseUrl) }) Use `getRootClient(instanceId)` from `packages/ui/src/stores/opencode-client.ts`. Native location/directory inputs replace the old per-worktree-client pattern. Destroy cached clients when an instance is removed. -## Native Shell, Instructions, And PTYs +## Session Shell, Background Shells, And PTYs - Shell mode calls `client.session.shell({ sessionID, command })`. - Conversation mode adds/removes `client.session.instructions.entry` before `client.session.prompt`. -- Shell remains separate from native PTY management. -- PTYs are location-scoped and listed with `client.pty.list`; the Status panel refreshes on PTY lifecycle events and reconnect, displays native metadata, and supports title updates. -- PTY ID operations are ownership-checked against the native `cwd`. Removal is the native stop action for a running PTY. -- Current installed declarations have no PTY output/read/stream API or separate stop endpoint, so output display and a distinct stop action are unavailable. +- Session Shell remains separate from background Shell and native PTY management. +- Background Shells are location-scoped and listed with `client.shell.list`; the Status panel refreshes on Shell lifecycle events and reconnect and displays native metadata. +- Shell ID operations are ownership-checked against the native `cwd`; output preserves the native cursor and removal uses `client.shell.remove`. +- Interactive terminals use separate `client.pty.*` APIs. - Keep `packages/opencode-plugin` and server plugin/background-process paths deleted. ## Event Flow diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b2f122150..ce813069c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -117,8 +117,8 @@ Then open a pull request on GitHub targeting the `dev` branch. - V2 always uses `~/.local/share/opencode2/opencode.db`. Never reuse the V1 database for V2. - OpenCode session calls use `/workspaces/:id/instance/api/*`; CodeNomad control routes and multiplexed events use `/api/*` and `/api/events`. - The proxy is method/path allowlisted, so new upstream functionality is not exposed automatically. -- Native Shell (`client.session.shell`) and prompt instructions (`client.session.instructions.entry`) remain separate from native V2 PTY management. -- Native PTYs are location-scoped and listed in the Status panel. The UI refreshes them on PTY events and reconnect, displays native metadata, and supports title updates and ownership-checked removal. Current installed declarations have no PTY output/read/stream API or separate stop endpoint, so removal is the native stop action for a running PTY. `packages/opencode-plugin` and the server plugin/background-process paths remain deleted and must not be restored. +- Shell mode (`client.session.shell`) and prompt instructions (`client.session.instructions.entry`) remain separate from background shells and interactive PTYs. +- Location-scoped background shells use `client.shell.*` and are listed in the Status panel. The UI refreshes them on Shell events and reconnect, displays native metadata, and supports ownership-checked removal. `client.pty.*` remains reserved for interactive terminals. `packages/opencode-plugin` and the server plugin/background-process paths remain deleted and must not be restored. - Native events are volatile. Reconnect handlers must refetch authoritative state instead of assuming missed events will replay. - Git mutations and Yolo policy remain CodeNomad-owned server boundaries. diff --git a/MIGRATION_V2.md b/MIGRATION_V2.md index cf7a9d7fd..791c21e7d 100644 --- a/MIGRATION_V2.md +++ b/MIGRATION_V2.md @@ -24,7 +24,7 @@ The migration removes the V1 compatibility layer rather than maintaining both in - Remove the custom `packages/opencode-plugin` package. - Remove V1 plugin communication channels and per-workspace runtime management. -- Replace interactive shell-mode requests with native V2 Shell and expose native V2 PTYs in the Status panel. +- Replace shell-mode requests with native `session.shell` and expose native background `shell.*` processes in the Status panel; keep interactive `pty.*` terminals separate. - Replace per-workspace OpenCode binary selection with one global `opencode2` binary. - Migrate the persisted V1 default command `opencode` to `opencode2` during workspace launch. - Remove message and part deletion controls because V2 currently has no equivalent API. @@ -39,7 +39,7 @@ The migration removes the V1 compatibility layer rather than maintaining both in ## Security and Service Lifecycle -- Restrict proxied Shell and PTY working directories to workspace-owned roots and Git worktrees; PTY controls also verify the native PTY `cwd` before forwarding ID-scoped requests. +- Restrict proxied Shell and PTY working directories to workspace-owned roots and Git worktrees; ID-scoped controls verify the native `cwd` before forwarding requests. - Remove CodeNomad authentication cookies before forwarding requests to OpenCode. - Prevent OpenCode `Set-Cookie` headers from being relayed to the browser. - Avoid logging unredacted secret-bearing proxy request bodies. @@ -61,7 +61,7 @@ The migration removes the V1 compatibility layer rather than maintaining both in - The proxy validates the decoded scope of native session cursors before forwarding them and supports native global Form reply/cancel routes without treating `global` as a session ID. - Before deleting a worktree, the server inventories the complete native project, evacuates affected session families with verification and rollback, and fails closed for direct API callers. One canonical folder maps to one logical workspace instead of creating non-isolated duplicates. - The current working tree retains the isolated V2 database, deferred location eviction, and proxy path/location ownership validation. -- Current installed client declarations provide native PTY list/get/create/title-or-size update/remove and lifecycle events, but no output/read/stream API and no separate stop API. Removing a running PTY is therefore the only native stop action, and PTY output is not displayed. +- Current installed client declarations provide background Shell list/get/create/output/timeout/remove and lifecycle events. Shell output pagination keeps the native cursor authoritative. Interactive PTY APIs remain separate and are not used by the background-process panel. - Local validation passes server/UI/Electron typechecks, the CI UI partitions, Electron native tests, server tests (with three platform skips), standalone server lockfile dry-run installation, UI/server/Electron builds, Tauri `cargo check --locked`, and `git diff --check`. ## Remaining Work @@ -146,9 +146,9 @@ The smoke is complete only after all of these actions succeed in the visible V2 3. Open an existing session from the session list; direct API session creation is not a substitute. 4. Send a prompt from the composer and receive its visible assistant response. 5. Reload the V2 window and confirm the workspace and session list recover. While V1 owns cross-host restore, reopen the existing V2 session from the list and confirm its messages and pending state recover correctly. -6. Exercise one PTY create/list/remove cycle through the workspace proxy, then close only the V2 process after collecting its logs. PTY creation is not currently exposed in the visible UI. +6. Exercise one background Shell create/list/output/remove cycle through the workspace proxy, then close only the V2 process after collecting its logs. Shell creation is not currently exposed in the visible UI. -Do not count direct HTTP/CDP calls as validation for workspace, session, prompt, response, or reload behavior. CDP may inspect the V2 DOM and operate visible controls, but it must follow the same controls and state transitions as a user. The PTY protocol check is the sole exception until the UI exposes creation. +Do not count direct HTTP/CDP calls as validation for workspace, session, prompt, response, or reload behavior. CDP may inspect the V2 DOM and operate visible controls, but it must follow the same controls and state transitions as a user. The background Shell protocol check is the sole exception until the UI exposes creation. ## Review Notes diff --git a/dev-docs/SUMMARY.md b/dev-docs/SUMMARY.md index 2d66bd117..e4568c538 100644 --- a/dev-docs/SUMMARY.md +++ b/dev-docs/SUMMARY.md @@ -170,7 +170,8 @@ dev-docs/ Development documentation - Database: V2 always uses `~/.local/share/opencode2/opencode.db`, separate from V1 - Events: volatile native stream with authoritative reconnect reconciliation - Proxy: explicit method/path allowlist; upstream additions are not automatic -- Shell and instructions: native session APIs, separate from PTY management -- PTYs: location-scoped native entries in Status, refreshed on PTY events/reconnect with metadata, title updates, and ownership-checked removal; current installed declarations have no output/read/stream or separate stop API, so output and distinct stop are unavailable and removal stops a running PTY +- Shell mode and instructions: native session APIs, separate from background Shell and PTY management +- Background Shells: location-scoped native `shell.*` entries in Status, refreshed on Shell events/reconnect with metadata and ownership-checked removal; output uses native cursor pagination +- PTYs: separate native interactive terminals, not background-process records - Legacy plugin/background processes: `packages/opencode-plugin` and server plugin/background-process paths remain deleted - Git mutations and Yolo: CodeNomad-owned diff --git a/dev-docs/architecture.md b/dev-docs/architecture.md index 83ad283fa..85ce0dc6c 100644 --- a/dev-docs/architecture.md +++ b/dev-docs/architecture.md @@ -57,14 +57,14 @@ Current native events include session lifecycle/output events (`session.created` | Sessions, messages, permission/question APIs | OpenCode V2 | | Shell mode | `client.session.shell` | | Conversation instructions | `client.session.instructions.entry` | -| PTY management | Location-scoped OpenCode V2 API through the ownership-checking proxy; Status panel UI | -| PTY output and distinct stop | Unavailable in the current installed declarations; removal is the native stop action for a running PTY | +| Background Shell management | Location-scoped OpenCode V2 `shell.*` API through the ownership-checking proxy; Status panel UI | +| Interactive PTY management | Separate OpenCode V2 `pty.*` API | | Workspace lifecycle and directory authorization | CodeNomad | | Git status/diff/stage/unstage/commit | CodeNomad server | | Yolo state, persistence and auto-accept | CodeNomad server | | Browser SSE multiplexing | CodeNomad server | -Native Shell remains separate from PTY management. The Status panel lists location-scoped native PTYs, refreshes on PTY events/reconnect, displays native metadata, and allows title updates and ownership-checked removal. Current installed declarations expose no PTY output/read/stream API or separate stop endpoint, so output display and a distinct stop action are unavailable. `packages/opencode-plugin` and the server plugin/background-process paths remain deleted and must not be restored. +Session Shell remains separate from background Shell and PTY management. The Status panel lists location-scoped native background Shells, refreshes on Shell events/reconnect, displays native metadata, and allows ownership-checked removal. Output requests preserve native cursor pagination. Interactive PTYs remain separate. `packages/opencode-plugin` and the server plugin/background-process paths remain deleted and must not be restored. ## Persistence diff --git a/dev-docs/technical-implementation.md b/dev-docs/technical-implementation.md index 6d2191e40..68d35f8b3 100644 --- a/dev-docs/technical-implementation.md +++ b/dev-docs/technical-implementation.md @@ -34,9 +34,9 @@ await client.session.shell({ sessionID, command }) await client.session.instructions.entry.put({ sessionID, key, value }) ``` -Shell mode and conversation instructions are upstream features and remain separate from native V2 PTYs. None requires a CodeNomad plugin. +Shell mode and conversation instructions are upstream features and remain separate from native background Shells and interactive V2 PTYs. None requires a CodeNomad plugin. -Native PTYs are location-scoped and listed in the Status panel. `packages/ui/src/stores/pty-store.ts` refreshes the list on native PTY events and reconnect, exposes native metadata, and supports title updates and removal. The proxy verifies PTY `cwd` ownership before ID-scoped operations. Current installed declarations have no PTY output/read/stream API and no separate stop endpoint: output is not displayed, and removing a running PTY is the only native stop action. +Native background Shells are location-scoped and listed in the Status panel. `packages/ui/src/stores/shell-store.ts` refreshes the list on native Shell events and reconnect, exposes native metadata, and supports removal. The proxy verifies Shell `cwd` ownership before ID-scoped operations and forwards native output cursors unchanged. Interactive `pty.*` terminals remain separate. ## Routing And Security @@ -57,7 +57,7 @@ Yolo also remains CodeNomad-owned. `AutoAcceptManager` persists policy state, ob `InstanceEventBridge` consumes the one shared `client.event.subscribe()` iterable. It maps location-scoped events to workspace IDs and publishes `instance.event` through the CodeNomad `EventBus`. This stream is volatile: reconnection does not replay a guaranteed history, so UI stores refetch sessions and pending requests and other consumers must re-read authoritative file/config state. -Use current protocol names. Session events include `session.created`, `session.renamed`, `session.moved`, `session.status`, `session.idle`, `session.execution.*`, `session.compaction.*`, `session.text.*`, `session.reasoning.*`, and `session.tool.*`; PTY refresh events include `pty.created`, `pty.updated`, `pty.exited`, and `pty.deleted`; file and config invalidations are `filesystem.changed` and `config.updated`. +Use current protocol names. Session events include `session.created`, `session.renamed`, `session.moved`, `session.status`, `session.idle`, `session.execution.*`, `session.compaction.*`, `session.text.*`, `session.reasoning.*`, and `session.tool.*`; background-process refresh events include `shell.created`, `shell.exited`, and `shell.deleted`; file and config invalidations are `filesystem.changed` and `config.updated`. ## Current Structure @@ -77,7 +77,7 @@ packages/ui/src/ stores/opencode-client.ts root client authority stores/session-api.ts session queries/lifecycle stores/session-actions.ts prompt, Shell, instructions - stores/pty-store.ts location-scoped native PTY state/actions + stores/shell-store.ts location-scoped native background Shell state/actions ``` Deleted `packages/opencode-plugin`, server plugin/background-process, and per-workspace runtime files are not architectural extension points and must not be restored. diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index 9259e2997..db6dd6ee5 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/server/__tests__/instance-proxy.test.ts b/packages/server/src/server/__tests__/instance-proxy.test.ts index 0a98d3161..b6b95d408 100644 --- a/packages/server/src/server/__tests__/instance-proxy.test.ts +++ b/packages/server/src/server/__tests__/instance-proxy.test.ts @@ -24,6 +24,7 @@ async function harness( serviceDirectory = workspacePath, pathMappings: Record = {}, ptyDirectories: Record = {}, + shellDirectories: Record = {}, ) { const upstream = Fastify() apps.push(upstream) @@ -70,6 +71,19 @@ async function harness( return { data: { id: ptyID, title: ptyID, command: "npm", args: ["run", "dev"], cwd, status: "running", pid: 42 } } }, }, + shell: { + list: async () => ({ + location: { directory: serviceDirectory, project: { id: "project", directory: serviceDirectory, canonical: serviceDirectory } }, + data: Object.entries(shellDirectories).filter((entry): entry is [string, string] => typeof entry[1] === "string").map(([id, cwd]) => ({ + id, command: "npm run dev", cwd, shell: "sh", file: "/tmp/output", status: "running" as const, pid: 42, metadata: {}, time: { started: 1 }, + })), + }), + get: async ({ id }: { id: string }) => { + const cwd = shellDirectories[id] ?? sessionDirectory + if (cwd instanceof Error) throw cwd + return { data: { id, command: "npm run dev", cwd, shell: "sh", file: "/tmp/output", status: "running", pid: 42, metadata: {}, time: { started: 1 } } } + }, + }, } as OpenCodeClient const manager: InstanceProxyWorkspaceManager = { get: () => ({ id: "workspace", path: workspacePath }) as never, @@ -128,6 +142,13 @@ describe("instance proxy location enforcement", () => { assert.doesNotMatch(bodyResponse.body, /internal-secret/) }) + it("allows the scoped native location bootstrap", async () => { + const { app } = await harness() + const response = await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/location" }) + assert.equal(response.statusCode, 200) + assert.match(JSON.parse(response.body).url, /\/api\/location\?location%5Bdirectory%5D=%2Frepo/) + }) + it("authorizes project-only session lists without adding a directory", async () => { const { app } = await harness() const response = await app.inject({ @@ -191,7 +212,7 @@ describe("instance proxy location enforcement", () => { assert.equal((await app.inject({ method: "GET", - url: "/workspaces/workspace/instance/api/pty/foreign?location%5Bdirectory%5D=%2Frepo%2Fworktree", + url: "/workspaces/workspace/instance/api/pty/foreign/?location%5Bdirectory%5D=%2Frepo%2Fworktree", })).statusCode, 403) assert.equal(requestCount(), 0) }) @@ -232,6 +253,27 @@ describe("instance proxy location enforcement", () => { assert.doesNotMatch(response.body, /internal-secret/) }) + it("lists owned shells and rejects foreign shell access", async () => { + const { app, requestCount } = await harness("/repo/worktree", {}, {}, "/repo", "/repo", {}, {}, { + owned: "/repo/worktree", + foreign: "/other", + }) + + const listed = await app.inject({ + method: "GET", + url: "/workspaces/workspace/instance/api/shell?location%5Bdirectory%5D=%2Frepo%2Fworktree", + }) + assert.equal(listed.statusCode, 200) + assert.deepEqual(JSON.parse(listed.body).data.map((shell: { id: string }) => shell.id), ["owned"]) + for (const [method, path] of [["DELETE", "foreign/"], ["GET", "foreign/output/"]] as const) { + assert.equal((await app.inject({ + method, + url: `/workspaces/workspace/instance/api/shell/${path}?location%5Bdirectory%5D=%2Frepo%2Fworktree`, + })).statusCode, 403) + } + assert.equal(requestCount(), 0) + }) + it("permits only native global Forms actions without session hydration", async () => { const { app, sessionGets, requestCount } = await harness("/other") for (const action of ["reply", "cancel"]) { diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 2b73f4a61..6248037ed 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -9,7 +9,7 @@ import { connect as connectTls, type TLSSocket } from "tls" import { fetch, type Headers } from "undici" import type { Logger } from "../logger" import { WorkspaceManager } from "../workspaces/manager" -import { isPtyNotFoundError, isSessionNotFoundError, type OpenCodeClient } from "@opencode-ai/client" +import { isPtyNotFoundError, isSessionNotFoundError, isShellNotFoundError, type OpenCodeClient } from "@opencode-ai/client" import type { SettingsService } from "../settings/service" import { FileSystemBrowser } from "../filesystem/browser" @@ -665,9 +665,9 @@ async function proxyWorkspaceRequest(args: { const promptBody = replacePromptFileUris(serviceBody, translatedPromptPaths) const requestedDirectory = requestLocations.directories[0] - const ptyLocation = { directory: requestedDirectory ? translatedDirectories.get(requestedDirectory) ?? serviceDirectory : serviceDirectory } + const runtimeLocation = { directory: requestedDirectory ? translatedDirectories.get(requestedDirectory) ?? serviceDirectory : serviceDirectory } if (pathname.replace(/\/+$/, "") === "/api/pty" && request.method === "GET") { - const result = await (await workspaceManager.getSharedServiceClient()).pty.list({ location: ptyLocation }) + const result = await (await workspaceManager.getSharedServiceClient()).pty.list({ location: runtimeLocation }) const ownership = await Promise.all(result.data.map((pty) => workspaceManager.ownsDirectory(workspaceId, pty.cwd))) reply.send({ ...result, data: result.data.filter((_, index) => ownership[index]) }) return @@ -676,7 +676,7 @@ async function proxyWorkspaceRequest(args: { const ptyId = getPtyRouteId(pathname) if (ptyId) { try { - const pty = await (await workspaceManager.getSharedServiceClient()).pty.get({ ptyID: ptyId, location: ptyLocation }) + const pty = await (await workspaceManager.getSharedServiceClient()).pty.get({ ptyID: ptyId, location: runtimeLocation }) if (!(await workspaceManager.ownsDirectory(workspaceId, pty.data.cwd))) { reply.code(403).send({ error: "PTY does not belong to workspace" }) return @@ -690,6 +690,30 @@ async function proxyWorkspaceRequest(args: { } } + if (pathname.replace(/\/+$/, "") === "/api/shell" && request.method === "GET") { + const result = await (await workspaceManager.getSharedServiceClient()).shell.list({ location: runtimeLocation }) + const ownership = await Promise.all(result.data.map((shell) => workspaceManager.ownsDirectory(workspaceId, shell.cwd))) + reply.send({ ...result, data: result.data.filter((_, index) => ownership[index]) }) + return + } + + const shellId = getShellRouteId(pathname) + if (shellId) { + try { + const shell = await (await workspaceManager.getSharedServiceClient()).shell.get({ id: shellId, location: runtimeLocation }) + if (!(await workspaceManager.ownsDirectory(workspaceId, shell.data.cwd))) { + reply.code(403).send({ error: "Shell does not belong to workspace" }) + return + } + } catch (error) { + if (isShellNotFoundError(error)) { + reply.code(404).send({ error: "Shell not found" }) + return + } + throw error + } + } + const sessionId = getSessionRouteId(pathname) if (sessionId && !isGlobalFormAction(pathname, request.method)) { let session @@ -943,7 +967,11 @@ async function ownsSessionListScope( } function getPtyRouteId(pathname: string): string | null { - return pathname.match(/^\/api\/pty\/([^/]+)$/)?.[1] ?? null + return pathname.replace(/\/+$/, "").match(/^\/api\/pty\/([^/]+)$/)?.[1] ?? null +} + +function getShellRouteId(pathname: string): string | null { + return pathname.replace(/\/+$/, "").match(/^\/api\/shell\/([^/]+)(?:\/output|\/timeout)?$/)?.[1] ?? null } function buildInstanceTargetUrl(endpoint: string, pathSuffix: string | undefined): URL | null { @@ -977,7 +1005,7 @@ function hasDotSegment(value: string): boolean { function isAllowedInstanceApiRoute(method: string, pathname: string): boolean { const route = pathname.replace(/\/+$/, "") const allowed: Array<[string, RegExp]> = [ - ["GET", /^\/api\/(?:agent|command|config|integration|mcp|model|plugin|provider)$/], + ["GET", /^\/api\/(?:agent|command|config|integration|location|mcp|model|plugin|provider)$/], ["GET", /^\/api\/agent\/[^/]+$/], ["GET", /^\/api\/model\/default$/], ["GET", /^\/api\/(?:permission|question)\/request$/], @@ -986,10 +1014,12 @@ function isAllowedInstanceApiRoute(method: string, pathname: string): boolean { ["GET", /^\/api\/project$/], ["GET", /^\/api\/vcs\/status$/], ["GET", /^\/api\/fs\/(?:list|read\/.+)$/], - ["GET", /^\/api\/pty(?:\/[^/]+)?$/], + ["GET", /^\/api\/(?:pty|shell)(?:\/[^/]+(?:\/output)?)?$/], ["POST", /^\/api\/(?:pty|shell)$/], ["PUT", /^\/api\/pty\/[^/]+$/], ["DELETE", /^\/api\/pty\/[^/]+$/], + ["DELETE", /^\/api\/shell\/[^/]+$/], + ["PATCH", /^\/api\/shell\/[^/]+\/timeout$/], ["POST", /^\/api\/mcp\/[^/]+\/(?:connect|disconnect)$/], ["DELETE", /^\/api\/credential\/[^/]+$/], ["POST", /^\/api\/integration\/[^/]+\/connect\/(?:key|oauth|command)$/], diff --git a/packages/server/src/server/routes/worktrees.test.ts b/packages/server/src/server/routes/worktrees.test.ts index 4ed3381dc..a8351986a 100644 --- a/packages/server/src/server/routes/worktrees.test.ts +++ b/packages/server/src/server/routes/worktrees.test.ts @@ -1,17 +1,105 @@ 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" describe("worktree routes", () => { - it("fails a direct delete call closed when session evacuation fails", async () => { +it("reserves the physical worktree and rejects a HEAD change immediately 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: linkedWorkspacePath, workspaceID: "native-feature" }, + } + let lists = 0 + const client = { + 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", linked, "-c", "user.name=CodeNomad", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "replace head"], { stdio: "ignore" }) + } + 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, + getServiceDirectory: () => workspacePath, + getServiceDirectoryForPath: async (_id: string, directory: string) => { + assert.notEqual(path.resolve(directory), path.resolve(linked), "OpenCode must receive the mirrored workspace path") + return directory + }, + } as unknown as WorkspaceManager + registerWorktreeRoutes(app, { workspaceManager: manager }) + + 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)) + 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,8 +113,11 @@ 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 const client = { - project: { - list: async () => [{ id: "project", canonical: temp, sandboxes: [target], time: { created: 1, updated: 1 } }], + location: { + get: async ({ location }: { location?: { directory?: string } }) => ({ + directory: location?.directory ?? temp, + project: { id: "project", canonical: temp, directory: temp }, + }), }, session: { list: async () => ({ data: [nativeSession], cursor: {} }), @@ -34,6 +125,7 @@ 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 = { @@ -41,17 +133,18 @@ describe("worktree routes", () => { getSharedServiceClient: async () => client, getServiceDirectory: () => temp, getServiceDirectoryForPath: async (_id: string, directory: string) => directory, + reserveWorktreeDeletion: async () => () => undefined, } as unknown as WorkspaceManager registerWorktreeRoutes(app, { workspaceManager: manager }) 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 a49615c6f..2937c7e82 100644 --- a/packages/server/src/server/routes/worktrees.ts +++ b/packages/server/src/server/routes/worktrees.ts @@ -8,10 +8,18 @@ import { createManagedWorktree, removeWorktree, } from "../../workspaces/git-worktrees" -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 } from "../../workspaces/worktree-session-evacuation" +import { invalidateWorktreeDirectoryCache } from "../../workspaces/worktree-directory" +import { + moveProjectSessionFamily, + ProjectSessionError, + removeProjectWorktree, +} from "../../workspaces/project-session-families" interface RouteDeps { workspaceManager: WorkspaceManager @@ -22,6 +30,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) @@ -31,7 +43,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 }) @@ -75,6 +91,7 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { slug, logger: request.log, }) + invalidateWorktreeDirectoryCache(workspace.id) reply.code(201) return created @@ -83,6 +100,55 @@ 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 projectDirectory = deps.workspaceManager.getServiceDirectory(workspace.id) + const targetDirectory = await deps.workspaceManager.getServiceDirectoryForPath(workspace.id, target.directory) + if (!projectDirectory || !targetDirectory) throw new ProjectSessionError("Unable to resolve OpenCode worktree paths", 409) + const moved = await moveProjectSessionFamily({ + client: await deps.workspaceManager.getSharedServiceClient(), + projectDirectory, + 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) + }, + }) + 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) => { @@ -107,28 +173,73 @@ 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") + let releaseDeletion: () => void + try { + releaseDeletion = await deps.workspaceManager.reserveWorktreeDeletion(match.registeredDirectory ?? match.directory) + } catch (error) { + throw new ProjectSessionError(error instanceof Error ? error.message : "Unable to reserve worktree deletion", 409) + } + try { + const client = await deps.workspaceManager.getSharedServiceClient() + const projectDirectory = deps.workspaceManager.getServiceDirectory(workspace.id) + const targetHostDirectory = match.registeredDirectory ?? match.directory + 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 (!projectDirectory || !targetDirectory || !rootDirectory) { + throw new ProjectSessionError("Unable to resolve OpenCode worktree paths", 409) + } + const isTargetRegistered = async () => { + const refreshed = await strictWorktrees({ + repoRoot, + workspaceFolder: workspace.path, + logger: request.log, + failClosed: true, + }) + return refreshed.some((worktree) => worktree.slug === slug + && worktree.kind === "worktree" + && worktree.registeredDirectory === match.registeredDirectory + && worktree.head === match.head) + } + await removeProjectWorktree({ + client, + projectDirectory, + targetDirectory, + rootDirectory, + remove: async () => { + if (!await isTargetRegistered()) { + throw new ProjectSessionError("Worktree changed before deletion", 409) + } + 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, + }) + invalidateWorktreeDirectoryCache(workspace.id) + } finally { + releaseDeletion() } - await evacuateWorktreeSessions({ - client, - projectDirectory, - targetDirectory, - rootDirectory: projectDirectory, - remove: () => removeWorktree({ workspaceFolder: workspace.path, directory: match.directory, force, logger: request.log }), - }) reply.code(204) } catch (error) { @@ -138,7 +249,13 @@ 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) + }) +} + 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 05a3b8ace..8ef612e1f 100644 --- a/packages/server/src/workspaces/instance-events.test.ts +++ b/packages/server/src/workspaces/instance-events.test.ts @@ -172,22 +172,26 @@ describe("InstanceEventBridge", () => { 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 === 4) + await waitFor(() => received.length === 6) assert.equal(received[0].instanceId, "a") assert.deepEqual(received[0].event.location, { directory: "/repo-a" }) assert.deepEqual(received[0].event.data, { id: "p1" }) assert.equal(received[0].event.properties, undefined) assert.equal(received[1].event.data.sessionID, "session-1") assert.equal(received[1].event.properties, undefined) - assert.equal(received[2].instanceId, "a") - assert.deepEqual(received[2].event.data, { + assert.deepEqual(received.slice(2, 4).map((event) => [event.instanceId, event.event.type]), [ + ["a", "server.connected"], + ["b", "server.connected"], + ]) + assert.equal(received[4].instanceId, "a") + assert.deepEqual(received[4].event.data, { sessionID: "session-2", assistantMessageID: "message-1", ordinal: 0, delta: "hello", }) - assert.equal(received[3].instanceId, "a") - assert.equal(received[3].event.data.delta, " again") + assert.equal(received[5].instanceId, "a") + assert.equal(received[5].event.data.delta, " again") assert.equal(ownerLookups.get("/repo-a/.worktrees/feature"), 2) } finally { bridge.shutdown() @@ -280,6 +284,29 @@ describe("InstanceEventBridge", () => { } }) + it("routes locationless shell events by cwd without broadcasting ownership", async () => { + const events = [ + { type: "shell.created", data: { info: { id: "shell-1", command: "npm run dev", cwd: "/repo-b", shell: "sh", file: "/tmp/output", status: "running", metadata: {}, time: { started: 1 } } } }, + { type: "shell.exited", data: { id: "shell-1", status: "exited", exit: 0 } }, + { type: "shell.deleted", data: { id: "shell-1" } }, + ] as OpenCodeEvent[] + const workspaces = [{ id: "a", path: "/repo-a" }, { id: "b", path: "/repo-b" }] + const { manager } = locationlessManager(events, {}, workspaces) + const bus = new EventBus() + const received: any[] = [] + bus.on("instance.event", (event) => 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 === 3) + assert.deepEqual(received.map((event) => event.instanceId), ["b", "b", "b"]) + assert.deepEqual(received.map((event) => event.event.type), ["shell.created", "shell.exited", "shell.deleted"]) + } finally { + bridge.shutdown() + } + }) + it("broadcasts an unresolvable locationless deletion", async () => { const events = [{ type: "session.deleted", data: { sessionID: "deleted" } }] as OpenCodeEvent[] const workspaces = [{ id: "a", path: "/repo-a" }, { id: "b", path: "/repo-b" }] diff --git a/packages/server/src/workspaces/instance-events.ts b/packages/server/src/workspaces/instance-events.ts index 5cd752b46..c5078ed64 100644 --- a/packages/server/src/workspaces/instance-events.ts +++ b/packages/server/src/workspaces/instance-events.ts @@ -19,6 +19,7 @@ const GLOBAL_EVENT_TYPES = new Set([ "mcp.resources.changed", "mcp.status.changed", "models-dev.refreshed", + "server.connected", ]) interface InstanceEventBridgeOptions { @@ -35,6 +36,7 @@ export class InstanceEventBridge { private readonly directoryOwners = new Map }>() private readonly sessionDirectories = new Map }>() private readonly ptyDirectories = new Map() + private readonly shellDirectories = new Map() private readonly onWorkspaceStarted = (event: { workspace: { id: string } }) => { this.clearLocationCaches() if (!this.task) this.task = this.run() @@ -96,11 +98,14 @@ export class InstanceEventBridge { private async publishEvent(event: OpenCodeEvent) { const sessionId = this.sessionId(event) const ptyId = this.ptyId(event) + const shellId = this.shellId(event) if (event.type === "session.moved" && sessionId) this.sessionDirectories.delete(sessionId) const directory = event.location?.directory ?? this.ptyInfoDirectory(event) ?? (ptyId ? this.ptyDirectories.get(ptyId) : undefined) + ?? this.shellInfoDirectory(event) + ?? (shellId ? this.shellDirectories.get(shellId) : undefined) ?? (sessionId ? await this.resolveSessionDirectory(sessionId) : undefined) if (!directory) { if (GLOBAL_EVENT_TYPES.has(event.type)) { @@ -122,11 +127,13 @@ export class InstanceEventBridge { }) } if (ptyId) this.ptyDirectories.set(ptyId, directory) + if (shellId) this.shellDirectories.set(shellId, directory) const instanceIds = await this.resolveDirectoryOwners(directory) if (instanceIds.length === 0) { if (event.type === "session.deleted" && sessionId) this.sessionDirectories.delete(sessionId) if (event.type === "pty.deleted" && ptyId) this.ptyDirectories.delete(ptyId) + if (event.type === "shell.deleted" && shellId) this.shellDirectories.delete(shellId) return } @@ -135,6 +142,7 @@ export class InstanceEventBridge { } if (event.type === "session.deleted" && sessionId) this.sessionDirectories.delete(sessionId) if (event.type === "pty.deleted" && ptyId) this.ptyDirectories.delete(ptyId) + if (event.type === "shell.deleted" && shellId) this.shellDirectories.delete(shellId) } private sessionId(event: OpenCodeEvent): string | undefined { @@ -156,6 +164,19 @@ export class InstanceEventBridge { return typeof cwd === "string" && cwd ? cwd : undefined } + private shellId(event: OpenCodeEvent): string | undefined { + if (!event.type.startsWith("shell.")) return undefined + const data = event.data as { id?: unknown; info?: { id?: unknown } } + const id = data.id ?? data.info?.id + return typeof id === "string" && id ? id : undefined + } + + private shellInfoDirectory(event: OpenCodeEvent): string | undefined { + if (event.type !== "shell.created") return undefined + const cwd = (event.data as { info?: { cwd?: unknown } }).info?.cwd + return typeof cwd === "string" && cwd ? cwd : undefined + } + private broadcastEvent(event: OpenCodeEvent): void { for (const workspace of this.options.workspaceManager.list()) { this.options.eventBus.publish({ type: "instance.event", instanceId: workspace.id, event }) @@ -200,6 +221,7 @@ export class InstanceEventBridge { this.directoryOwners.clear() this.sessionDirectories.clear() this.ptyDirectories.clear() + this.shellDirectories.clear() } private updateStatus(status: InstanceStreamStatus, reason?: string) { diff --git a/packages/server/src/workspaces/manager.test.ts b/packages/server/src/workspaces/manager.test.ts index 875c75eb1..6719a85b6 100644 --- a/packages/server/src/workspaces/manager.test.ts +++ b/packages/server/src/workspaces/manager.test.ts @@ -13,7 +13,7 @@ import type { OpenCodeEnsureOptions } from "./opencode-service" import path from "node:path" import os from "node:os" import { execFileSync } from "node:child_process" -import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" function deferred() { let resolve!: (value: T) => void @@ -171,6 +171,21 @@ describe("workspace manager shared service lifecycle", () => { assert.deepEqual(harness.service.evictions, [{ directory: process.cwd() }]) }) + 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("keeps a failed eviction retryable and reports shutdown failures", async () => { const harness = createHarness() const { workspace } = await harness.manager.create(process.cwd()) diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 00590d9f4..9c0ad0c5d 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -138,6 +138,7 @@ type WorkspaceCreationOwnership = Map export class WorkspaceManager { private readonly workspaces = new Map() private readonly pendingWorkspaceCreations = new Map() + private readonly deletingWorktreeRoots = new Set() private readonly cancelledCreationRequests = new Set() private shuttingDown = false private readonly sharedService: SharedService @@ -181,6 +182,18 @@ export class WorkspaceManager { return this.sharedService.client() } + async reserveWorktreeDeletion(directory: string): Promise<() => void> { + const target = (await resolveWorkspaceIdentity(directory, this.options.rootDir)).workspacePath + 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, workspace.path))) { + throw new Error("Worktree is open as another workspace") + } + this.deletingWorktreeRoots.add(target) + return () => this.deletingWorktreeRoots.delete(target) + } + async ownsDirectory(id: string, directory: string): Promise { const record = this.workspaces.get(id) if (!record?.[WORKSPACE_STATE].published) return false @@ -327,6 +340,9 @@ export class WorkspaceManager { launchDeadlineAt, launchTimeoutMs, ) + if (Array.from(this.deletingWorktreeRoots).some((root) => pathContains(root, workspacePath))) { + 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`) } @@ -790,3 +806,12 @@ export class WorkspaceManager { return candidates[0] ?? "" } } + +function pathContains(parent: string, child: string): boolean { + 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) +} 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..8c3631c7e --- /dev/null +++ b/packages/server/src/workspaces/project-session-families.test.ts @@ -0,0 +1,231 @@ +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): 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 }, + } +} + +function clientHarness(initial: SessionInfo[], options: { + active?: string[] | (() => string[]) + failMove?: (sessionId: string, call: number) => boolean + visibilityDelayGets?: number +} = {}) { + 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, + project: { id: "project", directory: ROOT, canonical: ROOT }, + }), + }, + session: { + list: async () => ({ data: Array.from(sessions.values()).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; cursor?: string }> = [] + const client = { + session: { + list: async (input: { project?: string; cursor?: string }) => { + calls.push(input) + return input.cursor + ? { data: [session("child", "root")], cursor: {} } + : { data: [session("root")], cursor: { next: "next" } } + }, + }, + } as unknown as OpenCodeClient + + assert.deepEqual((await listCompleteProjectSessions(client, "project")).map(({ id }) => id), ["root", "child"]) + assert.ok(calls.every(({ project }) => project === "project")) + assert.equal(calls[1]?.cursor, "next") + }) + + 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("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, + projectDirectory: ROOT, + sessionId: "child", + targetDirectory: WORKTREE, + }) + assert.deepEqual(moved.sessionIds, ["root", "child"]) + assert.ok([...harness.sessions.values()].every(({ location }) => location.directory === WORKTREE)) + }) + + it("waits for delayed move visibility", async () => { + const harness = clientHarness([session("root")], { visibilityDelayGets: 2 }) + await moveProjectSessionFamily({ client: harness.client, projectDirectory: 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, + projectDirectory: 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, + projectDirectory: 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("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, + projectDirectory: 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, + projectDirectory: 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, + projectDirectory: 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, + projectDirectory: 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("rolls back only while the original worktree identity remains", async () => { + const original = clientHarness([session("original", undefined, WORKTREE)]) + await assert.rejects(() => removeProjectWorktree({ + client: original.client, + projectDirectory: 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, + projectDirectory: 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..fe175489b --- /dev/null +++ b/packages/server/src/workspaces/project-session-families.ts @@ -0,0 +1,373 @@ +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"] +} + +export async function listCompleteProjectSessions( + client: OpenCodeClient, + projectID: string, +): Promise { + const sessions: SessionInfo[] = [] + 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({ project: projectID, limit: SESSION_PAGE_LIMIT, cursor }) + 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) { + throw new ProjectSessionError("OpenCode returned a session outside the requested project", 409) + } + sessions.push(session) + } + + const rawNext = response.cursor.next + if (rawNext !== undefined && (typeof rawNext !== "string" || !rawNext.trim())) { + throw new ProjectSessionError("OpenCode returned an invalid session inventory cursor", 502) + } + const next = rawNext + 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 + projectDirectory: string + sessionId: string + targetDirectory: string + validateTarget?: () => Promise +}): Promise { + return withProject(params.client, params.projectDirectory, 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) + await assertInactive(context.client, family[1]) + if (params.validateTarget && !await params.validateTarget()) { + throw new ProjectSessionError("Worktree changed before the session move", 409) + } + const target = await resolveProjectLocation(context, params.targetDirectory) + await moveWithRollback(context, family[1], target) + return { rootSessionId: family[0], sessionIds: family[1].map(({ id }) => id) } + }) +} + +export async function removeProjectWorktree(params: { + client: OpenCodeClient + projectDirectory: string + targetDirectory: string + rootDirectory: string + remove: () => Promise + isTargetRegistered: () => Promise +}): Promise { + await withProject(params.client, params.projectDirectory, async (context) => { + if (!await params.isTargetRegistered()) { + throw new ProjectSessionError("Worktree changed before deletion", 409) + } + const inventory = await listCompleteProjectSessions(context.client, context.project.id) + const families = Array.from(resolveSessionFamilies(inventory).values()) + .filter((family) => family.some((session) => directoryContains(params.targetDirectory, session.location.directory))) + await assertInactive(context.client, families.flat()) + const original = new Map(families.flat().map((session) => [session.id, session.location])) + const moved: string[] = [] + let root: LocationRef | undefined + + try { + if (families.length) { + const destination = await resolveProjectLocation(context, params.rootDirectory) + 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 (refreshed.some((session) => directoryContains(params.targetDirectory, session.location.directory))) { + throw new ProjectSessionError("Sessions remain attached to the worktree after evacuation", 409) + } + } + await assertInactive(context.client, families.flat()) + 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") + } + }) +} + +async function withProject( + client: OpenCodeClient, + directory: string, + operation: (context: ProjectContext) => Promise, +): Promise { + let location: LocationGetOutput + try { + location = await client.location.get({ location: { directory } }) + } 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 previous = projectLocks.get(location.project.id) ?? Promise.resolve() + const run = previous.catch(() => undefined).then(async () => { + try { + return await operation({ client, project: location.project }) + } catch (error) { + throw asProjectError(error, "Project session operation failed") + } + }) + const tail = run.then(() => undefined, () => undefined) + projectLocks.set(location.project.id, tail) + try { + return await run + } finally { + if (projectLocks.get(location.project.id) === tail) projectLocks.delete(location.project.id) + } +} + +async function resolveProjectLocation(context: ProjectContext, directory: string): Promise { + const location = await context.client.location.get({ location: { directory } }) + 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) + 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.ts b/packages/server/src/workspaces/worktree-directory.ts index bff0a0f3c..9f52e2a60 100644 --- a/packages/server/src/workspaces/worktree-directory.ts +++ b/packages/server/src/workspaces/worktree-directory.ts @@ -12,6 +12,10 @@ type WorktreeCacheEntry = { const WORKTREE_CACHE_TTL_MS = 2000 const worktreeCache = new Map() +export function invalidateWorktreeDirectoryCache(workspaceId: string): void { + worktreeCache.delete(workspaceId) +} + async function normalizeDirectoryPath(directory: string): Promise { const trimmed = (directory ?? "").trim() if (!trimmed) return "" diff --git a/packages/server/src/workspaces/worktree-session-evacuation.test.ts b/packages/server/src/workspaces/worktree-session-evacuation.test.ts deleted file mode 100644 index 227cfb0a2..000000000 --- a/packages/server/src/workspaces/worktree-session-evacuation.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import assert from "node:assert/strict" -import { describe, it } from "node:test" -import type { OpenCodeClient, SessionInfo } from "@opencode-ai/client" -import { evacuateWorktreeSessions } 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", () => { - 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.project === "project" && input.directory === undefined)) - }) - - 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") - }) -}) diff --git a/packages/server/src/workspaces/worktree-session-evacuation.ts b/packages/server/src/workspaces/worktree-session-evacuation.ts deleted file mode 100644 index ae1f3f0bd..000000000 --- a/packages/server/src/workspaces/worktree-session-evacuation.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { OpenCodeClient, SessionInfo } from "@opencode-ai/client" - -const PAGE_SIZE = 200 -const MAX_PAGES = 10_000 -const MAX_SESSIONS = 1_000_000 - -function normalizeDirectory(directory: string): string { - const normalized = directory.trim().replace(/\\/g, "/").replace(/\/+$/, "") || "/" - return /^[A-Za-z]:\//.test(normalized) || normalized.startsWith("//") ? normalized.toLowerCase() : normalized -} - -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({ project, limit: PAGE_SIZE, order: "asc", cursor }) - 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 { - for (let attempt = 0; attempt < 100; attempt += 1) { - if (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 - remove: () => Promise -}): Promise { - const target = normalizeDirectory(params.targetDirectory) - const project = (await params.client.project.list()).find((candidate) => ( - normalizeDirectory(candidate.canonical) === normalizeDirectory(params.projectDirectory) - || candidate.sandboxes.some((directory) => normalizeDirectory(directory) === target) - )) - if (!project) throw new Error("Unable to resolve the OpenCode project before deleting worktree") - const sessions = await inventorySessions(params.client, project.id) - const affected = sessions.filter((session) => normalizeDirectory(session.location.directory) === target) - const assertInactive = async () => { - const active = await params.client.session.active() - const blockers = affected.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, (current) => ( - current.every((session) => normalizeDirectory(session.location.directory) !== target) - )) - await assertInactive() - 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, (current) => { - const restored = current.filter((session) => expected.has(session.id)) - return restored.length === expected.size - && restored.every((session) => normalizeDirectory(session.location.directory) === target) - }) - } 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/components/instance/shell/right-panel/tabs/StatusTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx index 867b9acb4..03c3cfa51 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx @@ -12,7 +12,7 @@ import { Accordion } from "@kobalte/core" import { Tooltip } from "@kobalte/core/tooltip" import Switch from "@suid/material/Switch" -import { ChevronDown, GripVertical, Info, Pencil, Trash2, XOctagon } from "lucide-solid" +import { ChevronDown, GripVertical, Info, Trash2, XOctagon } from "lucide-solid" import type { Instance } from "../../../../../types/instance" import type { Session } from "../../../../../types/session" @@ -25,8 +25,8 @@ import { togglePermissionAutoAcceptForSession } from "../../../../../stores/inst import { isPermissionAutoAcceptEnabled } from "../../../../../stores/permission-auto-accept" import { applyRightPanelItemCustomization, type RightPanelCustomization, type RightPanelSectionModule } from "../registry" import { createCoreStatusSectionManifest } from "../core-plugin" -import { ptyStore } from "../../../../../stores/ptys" -import { showConfirmDialog, showPromptDialog } from "../../../../../stores/alerts" +import { shellStore } from "../../../../../stores/shells" +import { showConfirmDialog } from "../../../../../stores/alerts" interface StatusTabProps { t: (key: string, vars?: Record) => string @@ -82,12 +82,12 @@ const SortableStatusSection: Component = (props) => const StatusTab: Component = (props) => { const isSectionExpanded = (id: string) => props.expandedItems().includes(id) - const ptyDirectory = createMemo(() => props.activeSession()?.location.directory ?? props.instance.folder) - const ptyState = createMemo(() => ptyStore.getState(props.instanceId, ptyDirectory())) + const shellDirectory = createMemo(() => props.activeSession()?.location.directory ?? props.instance.folder) + const shellState = createMemo(() => shellStore.getState(props.instanceId, shellDirectory())) createEffect(on( - () => [props.instanceId, ptyDirectory()] as const, - ([instanceId, directory]) => void ptyStore.load(instanceId, directory), + () => [props.instanceId, shellDirectory()] as const, + ([instanceId, directory]) => void shellStore.load(instanceId, directory), )) const renderYoloModeSection = () => { @@ -136,22 +136,9 @@ const StatusTab: Component = (props) => { return } - const renamePty = async (ptyId: string, currentTitle: string) => { - const title = await showPromptDialog(props.t("instanceShell.backgroundProcesses.rename.message"), { - title: props.t("instanceShell.backgroundProcesses.rename.title"), - inputLabel: props.t("instanceShell.backgroundProcesses.rename.inputLabel"), - inputDefaultValue: currentTitle, - confirmLabel: props.t("instanceShell.backgroundProcesses.actions.rename"), - }) - const trimmed = title?.trim() - if (trimmed && trimmed !== currentTitle) { - await ptyStore.updateTitle(props.instanceId, ptyDirectory(), ptyId, trimmed) - } - } - - const removePty = async (ptyId: string, title: string, running: boolean) => { + const removeShell = async (shellId: string, command: string, running: boolean) => { const confirmed = await showConfirmDialog( - props.t("instanceShell.backgroundProcesses.remove.message", { title }), + props.t("instanceShell.backgroundProcesses.remove.message", { title: command }), { title: props.t("instanceShell.backgroundProcesses.remove.title"), confirmLabel: props.t(running @@ -159,51 +146,39 @@ const StatusTab: Component = (props) => { : "instanceShell.backgroundProcesses.actions.remove"), }, ) - if (confirmed) await ptyStore.remove(props.instanceId, ptyDirectory(), ptyId) + if (confirmed) await shellStore.remove(props.instanceId, shellDirectory(), shellId) } const renderBackgroundProcesses = () => ( {props.t("instanceShell.backgroundProcesses.error")}} > 0} + when={!shellState().loading || shellState().items.length > 0} fallback={
{props.t("instanceShell.backgroundProcesses.loading")}
} > 0} + when={shellState().items.length > 0} fallback={
{props.t("instanceShell.backgroundProcesses.empty")}
} >
- - {(pty) => { - const running = () => pty.status === "running" + + {(shell) => { + const running = () => shell.status === "running" return (
-

{pty.title}

- - {[pty.command, ...pty.args].join(" ")} - +

{shell.command}

+ {shell.shell}
-
- {props.t(`instanceShell.backgroundProcesses.status.${pty.status}`)} - {props.t("instanceShell.backgroundProcesses.pid", { pid: pty.pid })} - - {props.t("instanceShell.backgroundProcesses.exitCode", { code: pty.exitCode })} + {props.t(`instanceShell.backgroundProcesses.status.${shell.status}`)} + + {props.t("instanceShell.backgroundProcesses.pid", { pid: shell.pid })} + + + {props.t("instanceShell.backgroundProcesses.exitCode", { code: shell.exit })}
-
{pty.cwd}
+
{shell.cwd}
) }} diff --git a/packages/ui/src/components/session-list.tsx b/packages/ui/src/components/session-list.tsx index 38ca8ce6d..ed4632157 100644 --- a/packages/ui/src/components/session-list.tsx +++ b/packages/ui/src/components/session-list.tsx @@ -32,8 +32,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,6 +67,8 @@ 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() : "")) const [selectedSessionIds, setSelectedSessionIds] = createSignal>(new Set()) @@ -186,32 +189,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 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 } - - const result: SessionThread[] = [] - for (const thread of props.threads) { - const filtered = filterThreadTree(thread, query) - if (filtered !== null) result.push(filtered) - } - 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 +247,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 +427,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 +531,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" + return Boolean(worktreeSlug()) }) const isActive = () => props.activeSessionId === sessionId() @@ -691,7 +694,7 @@ const SessionList: Component = (props) => { - + @@ -824,6 +827,30 @@ const SessionList: Component = (props) => {
+
+ + +
+ 0}>
+