diff --git a/.opencode/skills/codenomad-architecture-guide/SKILL.md b/.opencode/skills/codenomad-architecture-guide/SKILL.md index 3fafd7f1..465ab4e4 100644 --- a/.opencode/skills/codenomad-architecture-guide/SKILL.md +++ b/.opencode/skills/codenomad-architecture-guide/SKILL.md @@ -18,7 +18,7 @@ description: | - The only OpenCode client dependency is the experimental `@opencode-ai/client` protocol. Server, UI, and the selected runtime CLI must stay on the exact version pinned by the server package. Current public `@opencode-ai/sdk` docs describe a different contract. - Do not use `@opencode-ai/sdk`, `@opencode-ai/sdk/v2/client`, or `createOpencodeClient()`; follow installed `@opencode-ai/client` declarations. - 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 custom lease-locked discovery, launcher, process-proof, and authenticated-stop lifecycle. Production does not call `Service.ensure` or `Service.stop` directly. Workspaces are native OpenCode `Location`/directory scopes, not separate OpenCode processes. +- 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. - CodeNomad owns workspace lifecycle, directory authorization, Git status/diff/stage/unstage/commit, Yolo persistence/auto-replies, and `/api/events`. diff --git a/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md b/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md index 1c43f7dc..bd903918 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md +++ b/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md @@ -10,7 +10,7 @@ Electron/Tauri -> CodeNomad Fastify server -> one shared OpenCode service SolidJS UI <- /api/events <- event bridge ``` -The server uses `packages/server/src/workspaces/opencode-service.ts` for a custom lease-locked discovery, launcher, process-proof, and authenticated-stop lifecycle; production does not call `Service.ensure` or `Service.stop` directly. Transferable lease proof binds the registration and endpoint credentials to the daemon PID/process-start identity, host or WSL namespace, and launch signature. `WorkspaceManager` validates each selected directory with `client.location.get()` and stores its `LocationRef`; a workspace is a logical location owner, not an OpenCode child process. +The server uses `packages/server/src/workspaces/opencode-service.ts` for a lease-locked discovery, launcher, process-proof, and authenticated-stop lifecycle. Transferable lease proof binds the registration and endpoint credentials to the daemon PID/process-start identity, host or WSL namespace, and launch signature. Proven host shutdown delegates to native `Service.stop`; WSL uses native authenticated health stop to avoid the client's cross-namespace PID fallback. `WorkspaceManager` validates each selected directory with `client.location.get()` and stores its `LocationRef`; a workspace is a logical location owner, not an OpenCode child process. ## Boundaries 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 62c2ebca..0ad30b34 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md @@ -16,7 +16,7 @@ ## Shared Lifecycle -- There is one shared service, client and upstream event subscription. Production uses CodeNomad's custom lease-locked launcher and authenticated stop, not direct `Service.ensure`/`Service.stop`. +- There is one shared service, client and upstream event subscription. CodeNomad keeps lease and process-identity proof around lifecycle operations, delegates proven host shutdown to native `Service.stop`, and uses native authenticated health stop for WSL. - A workspace stop removes location ownership; it does not stop a dedicated OpenCode process. - Transferable proof records registration/credentials, daemon PID and process-start identity, host/WSL namespace, and launch signature. Shutdown stops only after no live peer remains and the proof still identifies the exact daemon. - V2 forces `OPENCODE_DB` to `~/.local/share/opencode2/opencode.db`; V1/V2 schemas must not share a database. 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 b66e1e7e..ac0f99b5 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md @@ -2,7 +2,7 @@ ## Shared Service -`WorkspaceManager` owns one `OpenCodeSharedService`. Production discovers an existing endpoint or launches one with CodeNomad's own detached launcher; it does not call direct `Service.ensure`/`Service.stop`. The wrapper creates one server-side Promise client, performs health checks, and invalidates failed connections. +`WorkspaceManager` owns one `OpenCodeSharedService`. Production discovers an existing endpoint or launches one with CodeNomad's detached launcher. The wrapper creates one server-side Promise client, performs health checks, invalidates failed connections, and calls native `Service.stop` for a proven host daemon. WSL uses native authenticated health stop so no Windows PID fallback can run. Lifecycle leases serialize processes and carry transferable proof: registration and endpoint credentials, daemon PID/process-start identity, host/WSL namespace, and launch signature. A peer can inherit proof, but only the final verified process may send the authenticated stop and wait for that daemon to exit. diff --git a/MIGRATION_V2.md b/MIGRATION_V2.md index 2736a40c..94143bb2 100644 --- a/MIGRATION_V2.md +++ b/MIGRATION_V2.md @@ -17,7 +17,7 @@ The migration removes the V1 compatibility layer rather than maintaining both in - Use browser `EventSource` as the single desktop and web event transport; the duplicate Rust-native Tauri transport was removed. - Treat the native event stream as volatile. Reconnect has no replay guarantee, so clients must reconcile authoritative session and pending-request state after reconnect; file/config consumers must also refetch rather than assume every `filesystem.changed` or `config.updated` event was observed. - Route events from owned Git worktrees to their corresponding logical CodeNomad workspace. -- Query native sessions for every known root and worktree directory instead of relying on an unsupported project-scope parameter. +- Query native sessions by validated project scope, then traverse every descendant depth with native cursors. - Resolve locationless session events through native session ownership so prompt status and output reach the correct logical workspace. ## Removed Legacy Components @@ -46,7 +46,7 @@ The migration removes the V1 compatibility layer rather than maintaining both in - Expose only an explicit method/path allowlist through the OpenCode proxy. New upstream APIs require an intentional proxy and ownership review; future OpenCode functionality is not automatic. - Share a consistent service registration location between Windows and WSL. - Probe the selected `opencode2` CLI and discover/ensure only the exact version pinned by `packages/server/package.json`. Each dependency upgrade must review OpenCode release notes, current documentation, installed client declarations, and proxy/API parity. -- Wrap native `Service.ensure`/`Service.stop` with CodeNomad's ownership checks. A lifecycle lease records the registration, authenticated endpoint, daemon PID plus process-start identity and host/WSL namespace, and a hash of the launch command/environment. Proof can transfer between live CodeNomad processes through peer leases; the final process calls `Service.stop` only after proving there are no live peers and every recorded identity still matches. +- Wrap native `Service.ensure`/`Service.stop` with CodeNomad's ownership checks. A lifecycle lease records the registration, authenticated endpoint, daemon PID plus process-start identity and host/WSL namespace, and a hash of the launch command/environment. Proof can transfer between live CodeNomad processes through peer leases; the final host process calls `Service.stop` only after proving there are no live peers and every recorded identity still matches. WSL uses the authenticated native health stop endpoint and never allows the client's Windows `process.kill` fallback to target a Linux PID. - Queue location eviction when its final logical owner is removed, then perform it only during proven final shared-service shutdown so another CodeNomad process cannot lose active upstream state. - Force the V2 service database to `~/.local/share/opencode2/opencode.db`. V1 and V2 must never point at the same database because their schemas are incompatible. - Isolate V2 restore state under `~/.codenomad/client-state/v2` and copy V1 state non-destructively on first launch, preserving downgrade history. @@ -54,13 +54,15 @@ The migration removes the V1 compatibility layer rather than maintaining both in ## Current Status - Server, UI, and the selected `opencode2` CLI are exact-version-gated to `0.0.0-beta-17595`. -- Shared-service shutdown delegates to native `Service.stop` after CodeNomad proves ownership and excludes live peer leases. -- The UI uses native Forms instead of the removed Question API and `@opencode-ai/client/solid` `createData` for live message, tool, permission, and form reduction. -- Session inventory uses native project/subpath/parent/order cursor pagination; internal OpenCode stream generations trigger authoritative UI reconciliation even when browser SSE remains connected. -- A worktree is not deleted if session evacuation fails, and one canonical folder maps to one logical workspace instead of creating non-isolated duplicates. +- The updater advertises and installs only that pinned startup-compatible version; both repository lockfiles resolve the same client, protocol, and schema release. +- Shared-service shutdown delegates host daemons to native `Service.stop` after CodeNomad proves ownership and excludes live peer leases; WSL uses native authenticated health stop. +- The UI uses native Forms instead of the removed Question API and `@opencode-ai/client/solid` `createData` for live message, tool, permission, and form reduction. Live projections merge into REST-loaded history instead of replacing it. +- Session inventory uses native project/subpath/parent/order cursor pagination across all descendant depths; later pages receive native active status, and internal OpenCode stream generations trigger authoritative UI reconciliation even when browser SSE remains connected. +- 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. -- Local validation passes server/UI/Electron typechecks, the CI UI partitions, Electron native tests, server tests (with three platform skips), UI/server/Electron builds, Tauri `cargo check --locked`, and `git diff --check`. +- 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 diff --git a/dev-docs/SUMMARY.md b/dev-docs/SUMMARY.md index 68586120..50586d6d 100644 --- a/dev-docs/SUMMARY.md +++ b/dev-docs/SUMMARY.md @@ -94,7 +94,7 @@ dev-docs/ Development documentation ### 2. Shared Service Management -- CodeNomad server discovers or launches one service through its hardened lifecycle; production does not call `Service.ensure`/`Service.stop` directly +- CodeNomad server discovers or launches one service through its hardened lifecycle; proven host shutdown delegates to native `Service.stop`, while WSL uses native authenticated health stop - Workspace folders become validated native locations - UI traffic stays behind the CodeNomad proxy - Shutdown transfers proof to a live peer or stops only the exact proven daemon when no peer remains diff --git a/dev-docs/technical-implementation.md b/dev-docs/technical-implementation.md index d5ef75cb..97b9f3bd 100644 --- a/dev-docs/technical-implementation.md +++ b/dev-docs/technical-implementation.md @@ -8,7 +8,7 @@ Do not add `@opencode-ai/sdk`, old `{ data, error }` SDK wrappers, `createOpenco ## Server Integration -`OpenCodeSharedService` is the sole service adapter. Production uses `Service.discover` and `Service.headers`, then a custom launcher and authenticated stop request; direct `Service.ensure` and `Service.stop` are not the production lifecycle. +`OpenCodeSharedService` is the sole service adapter. Production uses `Service.discover` and `Service.headers`, then a custom launcher with lease and process-identity proof. Proven host shutdown delegates to native `Service.stop`; WSL uses native authenticated health stop to avoid the client's Windows PID fallback. Startup and shutdown are serialized by filesystem leases. Each CodeNomad process proves its own PID/start identity and launch signature; service proof contains the registration contents, endpoint credentials, daemon PID/start identity, and host/WSL namespace. On exit, an owner transfers that proof to an elected live peer and releases its lease; a replacement can also inherit matching proof from a stale peer under the lifecycle lock. The final process stops only after all peers are proven stale/absent and the registration, endpoint, process identity, and launch signature still match; uncertainty retains the lease and leaks safely rather than signaling a PID. diff --git a/packages/server/package-lock.json b/packages/server/package-lock.json index b7e7496a..a8419682 100644 --- a/packages/server/package-lock.json +++ b/packages/server/package-lock.json @@ -12,7 +12,7 @@ "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", "@fastify/static": "^7.0.4", - "@opencode-ai/client": "0.0.0-next-17444", + "@opencode-ai/client": "0.0.0-beta-17595", "commander": "^12.1.0", "fastify": "^4.28.1", "fuzzysort": "^2.0.4", @@ -741,41 +741,45 @@ ] }, "node_modules/@opencode-ai/client": { - "version": "0.0.0-next-17444", - "resolved": "https://registry.npmjs.org/@opencode-ai/client/-/client-0.0.0-next-17444.tgz", - "integrity": "sha512-o5nNtHTSqId6eQWJXdMQ132f8anOgm1q6YrB2JgiVGDfhu8EA8TLl2nrP1789CA7Tvkfr855sT/QiZaPynVFcQ==", + "version": "0.0.0-beta-17595", + "resolved": "https://registry.npmjs.org/@opencode-ai/client/-/client-0.0.0-beta-17595.tgz", + "integrity": "sha512-uDM6jztQiyXulYQL9G7kx283cv+0WhgQ98AzfClKQnV5mOB5wVBTGIjD1FGRwyxhUNGJBrfaP+NqOwWFjZhoeg==", "license": "MIT", "dependencies": { - "@opencode-ai/protocol": "0.0.0-next-17444", - "@opencode-ai/schema": "0.0.0-next-17444" + "@opencode-ai/protocol": "0.0.0-beta-17595", + "@opencode-ai/schema": "0.0.0-beta-17595" }, "peerDependencies": { - "effect": "4.0.0-beta.101" + "effect": "4.0.0-beta.107", + "solid-js": ">=1.9.0" }, "peerDependenciesMeta": { "effect": { "optional": true + }, + "solid-js": { + "optional": true } } }, "node_modules/@opencode-ai/protocol": { - "version": "0.0.0-next-17444", - "resolved": "https://registry.npmjs.org/@opencode-ai/protocol/-/protocol-0.0.0-next-17444.tgz", - "integrity": "sha512-IHCsPp3Tu0BlCqIziANPL04spnAO1NaN91eUeuwYPP1SGF1BhQltilG5jjzcTNssd+I7SbOEcpYs5TIrFKwAjQ==", + "version": "0.0.0-beta-17595", + "resolved": "https://registry.npmjs.org/@opencode-ai/protocol/-/protocol-0.0.0-beta-17595.tgz", + "integrity": "sha512-du77bQXIvOvqzyqS/I5gJMY3FZhXH2Oi3gs3ywAOnHCGRkwmVE9ofvFwR4G/Rzx/oVUOuGkyN28si29AluXGWw==", "license": "MIT", "dependencies": { - "@opencode-ai/schema": "0.0.0-next-17444", - "effect": "4.0.0-beta.101" + "@opencode-ai/schema": "0.0.0-beta-17595", + "effect": "4.0.0-beta.107" } }, "node_modules/@opencode-ai/schema": { - "version": "0.0.0-next-17444", - "resolved": "https://registry.npmjs.org/@opencode-ai/schema/-/schema-0.0.0-next-17444.tgz", - "integrity": "sha512-HhiKwCFDqOABWbjQgmTQcltRnoRaLxkK6TyXbQpCY1+QnvHu7hxJIwgL0z8YFhYNxyTCTgy85UccEvaBgfwwvg==", + "version": "0.0.0-beta-17595", + "resolved": "https://registry.npmjs.org/@opencode-ai/schema/-/schema-0.0.0-beta-17595.tgz", + "integrity": "sha512-448/fW6HIHd2JT+TSgm4izcAWl51f17GR6WcfGbS9SyWmXFNnX1Fi03xNRgf7dnul9yyceXEsMbreUvteKxAYg==", "license": "MIT", "dependencies": { "@standard-schema/spec": "1.1.0", - "effect": "4.0.0-beta.101" + "effect": "4.0.0-beta.107" } }, "node_modules/@pinojs/redact": { @@ -1138,21 +1142,16 @@ "license": "MIT" }, "node_modules/effect": { - "version": "4.0.0-beta.101", - "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.101.tgz", - "integrity": "sha512-HjowumlIo+orthn4jMlEJPuzIYPBV+uq/XiciHWhiedLsXQpWHdNJHO5d59BVDP5s1LPuvERcktwFqRXnJqnhA==", + "version": "4.0.0-beta.107", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.107.tgz", + "integrity": "sha512-OoBAv8eF+yanc+C6xhgEUnWeXUSHA6ynnscYqpkAY9GSnzZWystsIjBowVqCkLpHGlnRtdIqYT3wHwpOY6JDnQ==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", - "find-my-way-ts": "^0.1.6", - "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", - "multipasta": "^0.2.8", - "toml": "^4.1.2", - "uuid": "^14.0.1", - "yaml": "^2.9.0" + "uuid": "^14.0.1" } }, "node_modules/emoji-regex": { @@ -1383,12 +1382,6 @@ "node": ">=14" } }, - "node_modules/find-my-way-ts": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", - "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", - "license": "MIT" - }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -1491,15 +1484,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ini": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz", - "integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==", - "license": "ISC", - "engines": { - "node": "^22.22.2 || ^24.15.0 || >=26.0.0" - } - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1666,12 +1650,6 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" } }, - "node_modules/multipasta": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz", - "integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==", - "license": "MIT" - }, "node_modules/node-forge": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", @@ -2170,15 +2148,6 @@ "node": ">=0.6" } }, - "node_modules/toml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz", - "integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -2275,9 +2244,9 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", - "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", + "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" diff --git a/packages/server/src/opencode-update/service.test.ts b/packages/server/src/opencode-update/service.test.ts index 6c8de74e..59174728 100644 --- a/packages/server/src/opencode-update/service.test.ts +++ b/packages/server/src/opencode-update/service.test.ts @@ -3,6 +3,7 @@ import test from "node:test" import { OpenCodeUpdateError, OpenCodeUpdateService, + TARGET_OPENCODE_VERSION, buildOpenCodeUpgradeCommand, compareOpenCodeVersionStrings, detectOpenCodePackageManager, @@ -10,12 +11,11 @@ import { } from "./service" function createDeps(overrides: Partial = {}): OpenCodeUpdateServiceDeps { - let currentVersion = "1.0.0" + let currentVersion = "0.0.0-beta-1" return { resolveBinary: () => ({ path: "opencode", label: "OpenCode" }), probeBinary: () => ({ valid: true, version: currentVersion }), canUpgradeBinary: () => true, - fetchLatestVersion: async () => "1.1.0", upgradeBinary: async (_binary, target) => { currentVersion = target return { success: true, version: target } @@ -24,55 +24,31 @@ function createDeps(overrides: Partial = {}): OpenCod } } -test("reports an available update and caches the latest version", async () => { - let checks = 0 - const service = new OpenCodeUpdateService(createDeps({ - fetchLatestVersion: async () => { - checks += 1 - return "1.1.0" - }, - })) +test("reports only the startup-compatible pinned version", async () => { + const service = new OpenCodeUpdateService(createDeps()) assert.deepEqual(await service.getStatus(), { - currentVersion: "1.0.0", - latestVersion: "1.1.0", + currentVersion: "0.0.0-beta-1", + latestVersion: TARGET_OPENCODE_VERSION, updateAvailable: true, canUpgrade: true, }) - await service.getStatus() - assert.equal(checks, 1) }) test("keeps the update visible for a custom binary", async () => { const service = new OpenCodeUpdateService(createDeps({ canUpgradeBinary: () => false })) assert.deepEqual(await service.getStatus(), { - currentVersion: "1.0.0", - latestVersion: "1.1.0", + currentVersion: "0.0.0-beta-1", + latestVersion: TARGET_OPENCODE_VERSION, updateAvailable: true, canUpgrade: false, }) }) -test("preserves the installed version when the registry check fails", async () => { - const service = new OpenCodeUpdateService(createDeps({ - fetchLatestVersion: async () => { - throw new Error("registry unavailable") - }, - })) - - assert.deepEqual(await service.getStatus(), { - currentVersion: "1.0.0", - latestVersion: null, - updateAvailable: null, - canUpgrade: false, - checkError: "update_check_failed", - }) -}) - test("upgrades the managed OpenCode binary to the advertised version", async () => { const calls: Array<{ path: string; target: string }> = [] - let currentVersion = "1.0.0" + let currentVersion = "0.0.0-beta-1" const service = new OpenCodeUpdateService(createDeps({ probeBinary: () => ({ valid: true, version: currentVersion }), upgradeBinary: async (binary, target) => { @@ -82,13 +58,13 @@ test("upgrades the managed OpenCode binary to the advertised version", async () }, })) - assert.deepEqual(await service.upgrade(), { success: true, version: "1.1.0" }) - assert.deepEqual(calls, [{ path: "opencode", target: "1.1.0" }]) + assert.deepEqual(await service.upgrade(), { success: true, version: TARGET_OPENCODE_VERSION }) + assert.deepEqual(calls, [{ path: "opencode", target: TARGET_OPENCODE_VERSION }]) }) test("rejects success when the configured binary was not updated", async () => { const service = new OpenCodeUpdateService(createDeps({ - probeBinary: () => ({ valid: true, version: "1.0.0" }), + probeBinary: () => ({ valid: true, version: "0.0.0-beta-1" }), upgradeBinary: async (_binary, target) => ({ success: true, version: target }), })) @@ -99,7 +75,7 @@ test("rejects success when the configured binary was not updated", async () => { }) test("joins concurrent upgrades for the same binary", async () => { - let currentVersion = "1.0.0" + let currentVersion = "0.0.0-beta-1" let upgrades = 0 let finishUpgrade: (() => void) | undefined const gate = new Promise((resolve) => { @@ -120,8 +96,8 @@ test("joins concurrent upgrades for the same binary", async () => { finishUpgrade?.() assert.deepEqual(await Promise.all([first, second]), [ - { success: true, version: "1.1.0" }, - { success: true, version: "1.1.0" }, + { success: true, version: TARGET_OPENCODE_VERSION }, + { success: true, version: TARGET_OPENCODE_VERSION }, ]) assert.equal(upgrades, 1) }) diff --git a/packages/server/src/opencode-update/service.ts b/packages/server/src/opencode-update/service.ts index 34cf7c4f..4fb9c8bb 100644 --- a/packages/server/src/opencode-update/service.ts +++ b/packages/server/src/opencode-update/service.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process" -import { fetch } from "undici" +import { createRequire } from "node:module" import type { OpenCodeUpdateResponse, OpenCodeUpdateStatus } from "../api-types" import type { SettingsService } from "../settings/service" import { BinaryResolver, type ResolvedBinary } from "../settings/binaries" @@ -7,9 +7,10 @@ import type { WorkspaceManager } from "../workspaces/manager" import { probeBinaryVersion } from "../workspaces/spawn" import { compareVersionStrings, stripTagPrefix } from "../releases/release-monitor" -const OPENCODE_LATEST_URL = "https://registry.npmjs.org/@opencode-ai%2fcli/beta" const OPENCODE_PACKAGE_NAME = "@opencode-ai/cli" -const LATEST_VERSION_CACHE_MS = 5 * 60_000 +const require = createRequire(import.meta.url) +const packageJson = require("../../package.json") as { dependencies: { "@opencode-ai/client": string } } +export const TARGET_OPENCODE_VERSION = packageJson.dependencies["@opencode-ai/client"] const inFlightUpgrades = new Map>() type UpgradeResult = { success: true; version: string } | { success: false; error: string } @@ -19,8 +20,6 @@ export interface OpenCodeUpdateServiceDeps { probeBinary: typeof probeBinaryVersion canUpgradeBinary: (binary: ResolvedBinary) => boolean upgradeBinary: (binary: ResolvedBinary, target: string) => Promise - fetchLatestVersion: () => Promise - now?: () => number } export class OpenCodeUpdateError extends Error { @@ -39,26 +38,12 @@ export class OpenCodeUpdateError extends Error { } export class OpenCodeUpdateService { - private latestVersionCache: { version: string; expiresAt: number } | null = null - constructor(private readonly deps: OpenCodeUpdateServiceDeps) {} async getStatus(): Promise { const binary = this.deps.resolveBinary() const currentVersion = this.readCurrentVersion(binary.path) - let latestVersion: string - try { - latestVersion = await this.readLatestVersion() - } catch (error) { - if (!(error instanceof OpenCodeUpdateError) || error.code !== "update_check_failed") throw error - return { - currentVersion, - latestVersion: null, - updateAvailable: null, - canUpgrade: false, - checkError: "update_check_failed", - } - } + const latestVersion = TARGET_OPENCODE_VERSION const updateAvailable = compareOpenCodeVersionStrings(latestVersion, currentVersion) > 0 const canUpgrade = this.deps.canUpgradeBinary(binary) @@ -84,7 +69,7 @@ export class OpenCodeUpdateService { private async performUpgrade(binary: ResolvedBinary): Promise { const currentVersion = this.readCurrentVersion(binary.path) - const latestVersion = await this.readLatestVersion() + const latestVersion = TARGET_OPENCODE_VERSION if (compareOpenCodeVersionStrings(latestVersion, currentVersion) <= 0) { return { success: true, version: currentVersion } @@ -130,42 +115,6 @@ export class OpenCodeUpdateService { } return version } - - private async readLatestVersion(): Promise { - const now = (this.deps.now ?? Date.now)() - if (this.latestVersionCache && this.latestVersionCache.expiresAt > now) { - return this.latestVersionCache.version - } - - try { - const version = stripTagPrefix(await this.deps.fetchLatestVersion()) - if (!version || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) { - throw new Error("OpenCode registry returned an invalid version") - } - this.latestVersionCache = { version, expiresAt: now + LATEST_VERSION_CACHE_MS } - return version - } catch (error) { - throw new OpenCodeUpdateError( - "update_check_failed", - error instanceof Error ? error.message : "Unable to check the latest OpenCode version", - ) - } - } -} - -export async function fetchLatestOpenCodeVersion(): Promise { - const response = await fetch(OPENCODE_LATEST_URL, { - headers: { Accept: "application/json", "User-Agent": "CodeNomad-CLI" }, - signal: AbortSignal.timeout(10_000), - }) - if (!response.ok) { - throw new Error(`OpenCode registry responded with ${response.status}`) - } - const payload = (await response.json()) as { version?: unknown } - if (typeof payload.version !== "string") { - throw new Error("OpenCode registry response did not include a version") - } - return payload.version } export type OpenCodePackageManager = "npm" | "pnpm" | "bun" | "yarn" @@ -250,7 +199,6 @@ export function createOpenCodeUpdateService( }, probeBinary: probeBinaryVersion, canUpgradeBinary: () => binaryResolver.resolveDefault().path === "opencode2", - fetchLatestVersion: fetchLatestOpenCodeVersion, upgradeBinary: installOpenCodeCli, }) } diff --git a/packages/server/src/server/__tests__/instance-proxy.test.ts b/packages/server/src/server/__tests__/instance-proxy.test.ts index 2d142092..e8cb55cb 100644 --- a/packages/server/src/server/__tests__/instance-proxy.test.ts +++ b/packages/server/src/server/__tests__/instance-proxy.test.ts @@ -9,6 +9,8 @@ import { redactSecrets, registerInstanceProxyRoutes, type InstanceProxyWorkspace const apps: FastifyInstance[] = [] afterEach(async () => Promise.all(apps.splice(0).map((app) => app.close()))) +const cursor = (value: object) => Buffer.from(JSON.stringify(value)).toString("base64url") + function logger(): Logger { const value = { debug() {}, trace() {}, error() {}, isLevelEnabled() { return false } } return value as unknown as Logger @@ -126,6 +128,50 @@ describe("instance proxy location enforcement", () => { assert.doesNotMatch(bodyResponse.body, /internal-secret/) }) + it("authorizes project-only session lists without adding a directory", async () => { + const { app } = await harness() + const response = await app.inject({ + method: "GET", + url: "/workspaces/workspace/instance/api/session?project=owned-project", + }) + assert.equal(response.statusCode, 200) + assert.equal(JSON.parse(response.body).url, "/api/session?project=owned-project") + + const foreign = await app.inject({ + method: "GET", + url: "/workspaces/workspace/instance/api/session?project=foreign-project", + }) + assert.equal(foreign.statusCode, 403) + }) + + it("uses the cursor scope and rejects malformed or forged session-list cursors", async () => { + const { app, requestCount } = await harness() + const ownedCursor = cursor({ directory: "/repo/worktree", anchor: { id: "session-1", time: 1, direction: "next" } }) + const response = await app.inject({ + method: "GET", + url: `/workspaces/workspace/instance/api/session?cursor=${ownedCursor}&directory=%2Fother`, + }) + assert.equal(response.statusCode, 200) + const upstreamUrl = JSON.parse(response.body).url as string + assert.match(upstreamUrl, new RegExp(`cursor=${ownedCursor}`)) + assert.doesNotMatch(upstreamUrl, /directory=/) + + const forgedCursor = cursor({ directory: "/other", anchor: { id: "session-1", time: 1, direction: "next" } }) + assert.equal((await app.inject({ + method: "GET", + url: `/workspaces/workspace/instance/api/session?cursor=${forgedCursor}&directory=%2Frepo`, + })).statusCode, 403) + assert.equal((await app.inject({ + method: "GET", + url: "/workspaces/workspace/instance/api/session?cursor=not-json", + })).statusCode, 400) + assert.equal((await app.inject({ + method: "GET", + url: `/workspaces/workspace/instance/api/session?cursor=${cursor({ directory: "/repo" })}`, + })).statusCode, 400) + assert.equal(requestCount(), 1) + }) + it("filters PTYs and rejects foreign PTY access", async () => { const { app, requestCount } = await harness("/repo/worktree", {}, {}, "/repo", "/repo", {}, { owned: "/repo/worktree", @@ -186,6 +232,27 @@ describe("instance proxy location enforcement", () => { assert.doesNotMatch(response.body, /internal-secret/) }) + it("permits only native global Forms actions without session hydration", async () => { + const { app, sessionGets, requestCount } = await harness("/other") + for (const action of ["reply", "cancel"]) { + const response = await app.inject({ + method: "POST", + url: `/workspaces/workspace/instance/api/session/global/form/form-1/${action}`, + payload: action === "reply" ? { answers: {} } : {}, + }) + assert.equal(response.statusCode, 200) + } + assert.deepEqual(sessionGets, []) + assert.equal(requestCount(), 2) + + assert.equal((await app.inject({ + method: "POST", + url: "/workspaces/workspace/instance/api/session/global/prompt", + payload: { text: "no" }, + })).statusCode, 403) + assert.deepEqual(sessionGets, ["global"]) + }) + it("rejects deletion through a double-encoded alias of a foreign session", async () => { const { app, sessionGets, requestCount } = await harness("/repo/worktree", {}, { "foreign%25session": "/other", diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 524a22b8..4f814405 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -611,6 +611,14 @@ async function proxyWorkspaceRequest(args: { reply.send(ownedProjects.filter((project): project is NonNullable => project !== null)) return } + const sessionListHasScope = request.method === "GET" + && pathname.replace(/\/+$/, "") === "/api/session" + && (targetUrl.searchParams.has("cursor") || targetUrl.searchParams.has("project")) + const sessionListScope = await authorizeSessionList(targetUrl, request.method, workspaceManager, workspaceId) + if (sessionListScope !== "allowed") { + reply.code(sessionListScope === "invalid" ? 400 : 403).send({ error: "Session list does not belong to workspace" }) + return + } const serviceDirectory = workspaceManager.getServiceDirectory?.(workspaceId) ?? workspace.path const imported = prepareSessionImport( pathname, @@ -683,7 +691,7 @@ async function proxyWorkspaceRequest(args: { } const sessionId = getSessionRouteId(pathname) - if (sessionId) { + if (sessionId && !isGlobalFormAction(pathname, request.method)) { let session try { session = await (await workspaceManager.getSharedServiceClient()).session.get({ sessionID: sessionId }) @@ -700,7 +708,7 @@ async function proxyWorkspaceRequest(args: { } } - const body = applyDefaultWorkspaceLocation(targetUrl, promptBody, request.method, serviceDirectory, requestLocations.directories.length > 0, Boolean(sessionId)) + const body = applyDefaultWorkspaceLocation(targetUrl, promptBody, request.method, serviceDirectory, requestLocations.directories.length > 0 || sessionListHasScope, Boolean(sessionId)) const instanceAuthHeader = workspaceManager.getInstanceAuthorizationHeader(workspaceId) logger.debug({ workspaceId, method: request.method, targetUrl: targetUrl.toString() }, "Proxying request to instance") @@ -858,6 +866,82 @@ function getSessionRouteId(pathname: string): string | null { return match[1] } +function isGlobalFormAction(pathname: string, method: string): boolean { + return method === "POST" && /^\/api\/session\/global\/form\/[^/]+\/(?:reply|cancel)\/?$/.test(pathname) +} + +async function authorizeSessionList( + targetUrl: URL, + method: string, + manager: InstanceProxyWorkspaceManager, + workspaceId: string, +): Promise<"allowed" | "invalid" | "foreign"> { + if (method !== "GET" || targetUrl.pathname.replace(/\/+$/, "") !== "/api/session") return "allowed" + const cursors = targetUrl.searchParams.getAll("cursor") + if (cursors.length > 1) return "invalid" + if (cursors.length === 1) { + const scope = decodeSessionListCursor(cursors[0]) + if (!scope) return "invalid" + for (const key of ["directory", "location[directory]", "project", "subpath"]) targetUrl.searchParams.delete(key) + return ownsSessionListScope(manager, workspaceId, scope) + } + + const projects = targetUrl.searchParams.getAll("project") + const subpaths = targetUrl.searchParams.getAll("subpath") + if (projects.length > 1 || subpaths.length > 1 || (subpaths.length && !projects.length)) return "invalid" + if (!projects.length) return "allowed" + const project = projects[0] + const subpath = subpaths[0] + if (!project || (subpath !== undefined && !isSafeRelativePath(subpath))) return "invalid" + return ownsSessionListScope(manager, workspaceId, { project, subpath }) +} + +function decodeSessionListCursor(cursor: string): { directory: string } | { project: string; subpath?: string } | null { + if (!cursor || !/^[A-Za-z0-9_-]+$/.test(cursor)) return null + try { + const value = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as Record + if (!value || typeof value !== "object" || Array.isArray(value)) return null + const anchor = value.anchor as Record | undefined + if (!anchor || typeof anchor !== "object" || Array.isArray(anchor) + || typeof anchor.id !== "string" || !anchor.id + || typeof anchor.time !== "number" || !Number.isFinite(anchor.time) + || (anchor.direction !== "previous" && anchor.direction !== "next")) return null + if (value.workspace !== undefined && typeof value.workspace !== "string") return null + if (value.search !== undefined && typeof value.search !== "string") return null + if (value.order !== undefined && value.order !== "asc" && value.order !== "desc") return null + if (typeof value.directory === "string" && value.directory.trim() && value.project === undefined && value.subpath === undefined) { + return { directory: value.directory } + } + if (typeof value.project === "string" && value.project.trim() && value.directory === undefined) { + if (value.subpath === undefined) return { project: value.project } + if (typeof value.subpath === "string" && isSafeRelativePath(value.subpath)) return { project: value.project, subpath: value.subpath } + } + return null + } catch { + return null + } +} + +function isSafeRelativePath(value: string): boolean { + return value === "" || (!path.posix.isAbsolute(value) && !path.win32.isAbsolute(value) && !value.split(/[\\/]/).includes("..")) +} + +async function ownsSessionListScope( + manager: InstanceProxyWorkspaceManager, + workspaceId: string, + scope: { directory: string } | { project: string; subpath?: string }, +): Promise<"allowed" | "foreign"> { + if ("directory" in scope) return await manager.ownsDirectory(workspaceId, scope.directory) ? "allowed" : "foreign" + const project = (await (await manager.getSharedServiceClient()).project.list()).find((candidate) => candidate.id === scope.project) + if (!project) return "foreign" + const directory = scope.subpath === undefined + ? project.canonical + : /^[A-Za-z]:[\\/]|^\\\\/.test(project.canonical) + ? path.win32.resolve(project.canonical, scope.subpath) + : path.posix.resolve(project.canonical, scope.subpath) + return await manager.ownsDirectory(workspaceId, directory) ? "allowed" : "foreign" +} + function getPtyRouteId(pathname: string): string | null { return pathname.match(/^\/api\/pty\/([^/]+)$/)?.[1] ?? null } diff --git a/packages/server/src/server/routes/worktrees.test.ts b/packages/server/src/server/routes/worktrees.test.ts new file mode 100644 index 00000000..413965de --- /dev/null +++ b/packages/server/src/server/routes/worktrees.test.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" +import { 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 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 () => { + const temp = mkdtempSync(path.join(tmpdir(), "codenomad-delete-worktree-")) + const target = path.join(temp, "doomed") + const app = Fastify({ logger: false }) + try { + execFileSync("git", ["init", "--initial-branch=main", temp]) + writeFileSync(path.join(temp, "README.md"), "test\n") + execFileSync("git", ["-C", temp, "add", "README.md"]) + execFileSync("git", ["-C", temp, "-c", "user.name=CodeNomad Test", "-c", "user.email=test@codenomad.local", "commit", "-m", "test"]) + execFileSync("git", ["-C", temp, "worktree", "add", "-b", "doomed", target]) + + 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 } }], + }, + session: { + list: async () => ({ data: [nativeSession], cursor: {} }), + move: async (input: { directory: string }) => { + if (input.directory === temp) throw new Error("native move failed") + }, + }, + } as unknown as OpenCodeClient + const manager = { + get: () => workspace, + getSharedServiceClient: async () => client, + getServiceDirectory: () => temp, + getServiceDirectoryForPath: async (_id: string, directory: string) => directory, + } 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.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 bf237d4b..42557377 100644 --- a/packages/server/src/server/routes/worktrees.ts +++ b/packages/server/src/server/routes/worktrees.ts @@ -10,6 +10,8 @@ import { } from "../../workspaces/git-worktrees" import type { WorktreeListResponse } from "../../api-types" import { ensureCodenomadGitExclude } from "../../workspaces/worktree-map" +import { createInstanceClient } from "../../workspaces/instance-client" +import { evacuateWorktreeSessions } from "../../workspaces/worktree-session-evacuation" interface RouteDeps { workspaceManager: WorkspaceManager @@ -112,6 +114,21 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { 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") + } + await evacuateWorktreeSessions({ + client, + projectDirectory, + targetDirectory, + rootDirectory: projectDirectory, + }) + await removeWorktree({ workspaceFolder: workspace.path, directory: match.directory, force, logger: request.log }) reply.code(204) diff --git a/packages/server/src/workspaces/opencode-service.test.ts b/packages/server/src/workspaces/opencode-service.test.ts index 9b05f2de..1d119fa6 100644 --- a/packages/server/src/workspaces/opencode-service.test.ts +++ b/packages/server/src/workspaces/opencode-service.test.ts @@ -4,7 +4,7 @@ import os from "node:os" import path from "node:path" import { describe, it } from "node:test" import { createHash } from "node:crypto" -import type { OpenCodeClient } from "@opencode-ai/client" +import { OpenCode, type OpenCodeClient } from "@opencode-ai/client" import type { Endpoint, Info } from "@opencode-ai/client/service" import { OpenCodeSharedService, type OpenCodeEnsureOptions } from "./opencode-service" @@ -387,14 +387,13 @@ describe("OpenCodeSharedService", () => { const service = new OpenCodeSharedService({ discover: async () => endpoint, ensure: async (options) => { options?.onStart?.("missing"); return endpoint }, - stop: async () => { sdkStops += 1 }, headers: () => undefined, isProcessAlive: () => true, getProcessIdentity: async (pid, _timeoutMs, namespace = { kind: "host" }) => processIdentity(pid, undefined, namespace), probeProcessIdentity: async (pid, _timeoutMs, namespace) => wslPidExists ? { status: "found", identity: processIdentity(pid, undefined, namespace) } : { status: "missing" }, - makeClient: () => ({} as OpenCodeClient), + makeClient: () => ({ health: { stop: async () => { sdkStops += 1; return { accepted: true } } } }) as unknown as OpenCodeClient, }) try { await service.endpoint({ ...state.options("owner", true), nativePid: false, wslDistro: "Ubuntu" }) @@ -413,6 +412,55 @@ describe("OpenCodeSharedService", () => { } }) + it("never lets an unsupported WSL health stop fall back to a host PID signal", async () => { + const state = await serviceState("codenomad-service-wsl-no-host-signal-") + const originalKill = process.kill + const signals: Array<{ pid: number; signal?: NodeJS.Signals | number }> = [] + let healthStops = 0 + const server = (await import("node:http")).createServer((request, response) => { + response.setHeader("content-type", "application/json") + if (request.method === "GET" && request.url === "/api/health") { + response.end(JSON.stringify({ healthy: true, version: "test", pid: state.info.pid })) + return + } + if (request.method === "POST") healthStops += 1 + response.statusCode = 404 + response.end(JSON.stringify({ error: "unsupported" })) + }) + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) + const address = server.address() + assert.ok(address && typeof address === "object") + state.info.url = `http://127.0.0.1:${address.port}` + await writeFile(state.file, JSON.stringify(state.info)) + const endpoint = { url: state.info.url, auth: { type: "basic" as const, username: "opencode", password: state.info.password } } + const service = new OpenCodeSharedService({ + discover: async () => endpoint, + ensure: async (options) => { options?.onStart?.("missing"); return endpoint }, + headers: () => undefined, + getProcessIdentity: async (pid, _timeoutMs, namespace = { kind: "host" }) => processIdentity(pid, undefined, namespace), + probeProcessIdentity: async (pid, _timeoutMs, namespace) => ({ + status: "found", + identity: processIdentity(pid, undefined, namespace), + }), + makeClient: OpenCode.make, + }) + process.kill = ((pid: number, signal?: NodeJS.Signals | number) => { + signals.push({ pid, signal }) + return true + }) as typeof process.kill + try { + await service.endpoint({ ...state.options("owner", true), nativePid: false, wslDistro: "Ubuntu" }) + await assert.rejects(service.shutdown({ timeoutMs: 500 })) + assert.equal(healthStops, 1) + assert.deepEqual(signals, []) + assert.equal(JSON.parse(await readFile(state.lease("owner"), "utf8")).state, "stopping") + } finally { + process.kill = originalKill + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) + await rm(state.root, { recursive: true, force: true }) + } + }) + it("reports a failed stop and keeps an active retryable lease", async () => { const state = await serviceState("codenomad-service-stop-failure-") let stops = 0 diff --git a/packages/server/src/workspaces/opencode-service.ts b/packages/server/src/workspaces/opencode-service.ts index 962b5959..59019971 100644 --- a/packages/server/src/workspaces/opencode-service.ts +++ b/packages/server/src/workspaces/opencode-service.ts @@ -308,7 +308,7 @@ export class OpenCodeSharedService { const stopped = await this.withDeadline( this.dependencies.requestStop ? this.dependencies.requestStop(owned.info, owned.endpoint, remaining) - : this.requestStop(owned), + : this.requestStop(owned, remaining), remaining, "OpenCode service stop", ) @@ -1191,7 +1191,18 @@ export class OpenCodeSharedService { } } - private requestStop = async (owned: OwnedService): Promise => { + private requestStop = async (owned: OwnedService, timeoutMs: number): Promise => { + if (owned.processIdentity?.namespace.kind === "wsl") { + if (!owned.info.id) return false + const client = this.dependencies.makeClient({ + baseUrl: owned.endpoint.url, + headers: this.dependencies.headers(owned.endpoint), + }) + return (await client.health.stop( + { instanceID: owned.info.id }, + { signal: AbortSignal.timeout(timeoutMs) }, + )).accepted + } await (this.dependencies.stop ?? Service.stop)(owned.stopOptions) return true } diff --git a/packages/server/src/workspaces/worktree-session-evacuation.test.ts b/packages/server/src/workspaces/worktree-session-evacuation.test.ts new file mode 100644 index 00000000..5a2e447c --- /dev/null +++ b/packages/server/src/workspaces/worktree-session-evacuation.test.ts @@ -0,0 +1,40 @@ +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 an unloaded family on a later page and moves its root", 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 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"), session(root.id, "/repo"), session(child.id, "/repo", root.id), session(grandchild.id, "/repo", child.id)], cursor: {} } + }, + move: async (input: { sessionID: string; directory: string }) => { moves.push(input) }, + }, + } as unknown as OpenCodeClient + + await evacuateWorktreeSessions({ client, projectDirectory: "/repo", targetDirectory: "/repo/worktree", rootDirectory: "/repo" }) + + assert.deepEqual(moves, [{ sessionID: root.id, directory: "/repo" }]) + assert.equal(listCall, 3) + assert.ok(lists.every((input: any) => input.project === "project" && input.directory === undefined)) + }) +}) diff --git a/packages/server/src/workspaces/worktree-session-evacuation.ts b/packages/server/src/workspaces/worktree-session-evacuation.ts new file mode 100644 index 00000000..aff0632d --- /dev/null +++ b/packages/server/src/workspaces/worktree-session-evacuation.ts @@ -0,0 +1,92 @@ +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") +} + +function familyRoot(session: SessionInfo, sessions: Map): SessionInfo { + const seen = new Set([session.id]) + let current = session + while (current.parentID) { + if (seen.has(current.parentID)) throw new Error(`Invalid session ancestry for ${session.id}`) + seen.add(current.parentID) + const parent = sessions.get(current.parentID) + if (!parent) throw new Error(`Session inventory is missing ancestor ${current.parentID}`) + current = parent + } + return current +} + +export async function evacuateWorktreeSessions(params: { + client: OpenCodeClient + projectDirectory: string + targetDirectory: string + rootDirectory: string +}): 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 byId = new Map(sessions.map((session) => [session.id, session])) + const roots = new Map() + + for (const session of sessions) { + if (normalizeDirectory(session.location.directory) !== target) continue + const root = familyRoot(session, byId) + roots.set(root.id, root) + } + + const moved: SessionInfo[] = [] + try { + for (const root of roots.values()) { + moved.push(root) + await params.client.session.move({ sessionID: root.id, directory: params.rootDirectory }) + } + + const remaining = (await inventorySessions(params.client, project.id)) + .filter((session) => normalizeDirectory(session.location.directory) === target) + if (remaining.length) throw new Error(`Worktree still contains ${remaining.length} session(s) after evacuation`) + } catch (error) { + const rollbackErrors: unknown[] = [] + for (const root of moved.reverse()) { + try { + await params.client.session.move({ sessionID: root.id, directory: root.location.directory }) + } 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/stores/instances.ts b/packages/ui/src/stores/instances.ts index 97841bb4..82853921 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -10,6 +10,7 @@ import type { WorkspaceDescriptor, WorkspaceEventPayload, WorkspaceLogEntry } fr import { ensureInstanceConfigLoaded } from "./instance-config" import { fetchSessions, + loadMessages, fetchAgents, fetchProviders, getActiveCatalogLocation, @@ -34,6 +35,7 @@ import { ConnectionResyncGate } from "./connection-resync-gate" import { serverSettings } from "./preferences" import { reconcileSessionPendingState, + messagesLoaded, sessions, setSessionPendingForm, setSessionPendingPermission, @@ -41,7 +43,7 @@ import { import { setHasInstances } from "./ui" import { messageStoreBus } from "./message-v2/bus" import { applyOpenCodeDataEvent, destroyOpenCodeData, projectOpenCodeMessages } from "./opencode-data" -import { upsertPermissionV2, removePermissionV2 } from "./message-v2/bridge" +import { upsertPermissionV2, removePermissionV2, removeMessageV2 } from "./message-v2/bridge" import { clearRepliedPermissions, hasRepliedPermission, @@ -258,6 +260,8 @@ const connectionResyncs = new TrailingResyncCoordinator( syncPendingRequests(instanceId), refreshVolatileInstanceState(instanceId), ]) + await Promise.all(Array.from(messagesLoaded().get(instanceId) ?? [], (sessionId) => + loadMessages(instanceId, sessionId, { force: true }))) reconcilePendingSessionIndicators(instanceId) }, (instanceId, error) => { @@ -308,6 +312,7 @@ function refreshVolatileInstanceState( serverEvents.on("instance.eventStatus", (event) => { if (event.type !== "instance.eventStatus") return + if (event.status === "connecting") destroyOpenCodeData(event.instanceId) const shouldResync = connectionResyncGate.observe(event.instanceId, event.status, event.reason) if (event.status !== "connected") return if (disconnectedInstance()?.id === event.instanceId) { @@ -1704,27 +1709,29 @@ function handleInstanceInvalidation(instanceId: string, event: Parameters= event.data.to) removeMessageV2(instanceId, messageId, sessionId) + } + } if (sessionId && (event.type === "permission.asked" || event.type === "permission.replied")) { const remote = (data.session.permission.list(sessionId) ?? []).filter((permission) => !hasRepliedPermission(instanceId, permission.id)) - const ids = new Set(remote.map((permission) => permission.id)) - for (const permission of getPermissionQueue(instanceId)) { - if (permission.sessionID === sessionId && !ids.has(permission.id)) { - removePermissionFromQueue(instanceId, permission.id) - removePermissionV2(instanceId, permission.id) - } - } for (const permission of remote) { const queued = addPermissionToQueue(instanceId, permission) ?? permission upsertPermissionV2(instanceId, queued) } + if (event.type === "permission.replied") { + removePermissionFromQueue(instanceId, event.data.requestID) + removePermissionV2(instanceId, event.data.requestID) + } } if (sessionId && event.type.startsWith("form.")) { const remote = data.session.form.list(sessionId) ?? [] - const ids = new Set(remote.map((form) => form.id)) - for (const form of getFormQueue(instanceId)) { - if (form.sessionID === sessionId && !ids.has(form.id)) removePendingForm(instanceId, form.id) - } for (const form of remote) addPendingForm(instanceId, form) + if (event.type === "form.replied" || event.type === "form.cancelled") removePendingForm(instanceId, event.data.id) } const targets = getInstanceRefreshTargets(event.type) if (targets.length) void refreshVolatileInstanceState(instanceId, targets) diff --git a/packages/ui/src/stores/opencode-data.test.ts b/packages/ui/src/stores/opencode-data.test.ts index f9eb0233..4e5624e5 100644 --- a/packages/ui/src/stores/opencode-data.test.ts +++ b/packages/ui/src/stores/opencode-data.test.ts @@ -1,6 +1,8 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" import { messageStoreBus } from "./message-v2/bus.ts" +import { seedSessionMessagesV2 } from "./message-v2/bridge.ts" +import { normalizeSessionMessage } from "./message-v2/normalizers.ts" import { applyOpenCodeDataEvent, destroyOpenCodeData, projectOpenCodeMessages } from "./opencode-data.ts" describe("OpenCode data projection", () => { @@ -36,4 +38,61 @@ describe("OpenCode data projection", () => { if (messageStoreBus.getInstance(instanceId)) messageStoreBus.unregisterInstance(instanceId) } }) + + it("merges the first live event into REST-loaded history", () => { + const instanceId = "opencode-data-rest-history" + const sessionId = "session" + const infos = new Map() + const history = Array.from({ length: 100 }, (_, index) => { + const item = { + id: `history-${index}`, + type: "assistant", + agent: "build", + model: { providerID: "provider", id: "model" }, + time: { created: index + 1, completed: index + 1 }, + content: [], + } as any + const normalized = normalizeSessionMessage(sessionId, item) + infos.set(normalized.info.id, normalized.info) + return normalized.message + }) + + try { + seedSessionMessagesV2(instanceId, { id: sessionId }, history, infos) + const data = applyOpenCodeDataEvent(instanceId, "/work", { + id: "live", type: "session.step.started", created: 101, + data: { + sessionID: sessionId, + assistantMessageID: "live", + agent: "build", + model: { providerID: "provider", id: "model" }, + }, + } as any) + projectOpenCodeMessages(instanceId, sessionId, data) + + const ids = messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(sessionId) + assert.equal(ids.length, 101) + assert.equal(ids.includes("history-0"), true) + assert.equal(ids.includes("live"), true) + } finally { + destroyOpenCodeData(instanceId) + if (messageStoreBus.getInstance(instanceId)) messageStoreBus.unregisterInstance(instanceId) + } + }) + + it("drops stale live state before a reconnect generation", () => { + const instanceId = "opencode-data-reconnect" + const event = (id: string) => ({ + id, type: "session.step.started", created: 1, + data: { sessionID: "session", assistantMessageID: id, agent: "build", model: { providerID: "provider", id: "model" } }, + } as any) + try { + applyOpenCodeDataEvent(instanceId, "/work", event("old")) + destroyOpenCodeData(instanceId) + const data = applyOpenCodeDataEvent(instanceId, "/work", event("new")) + assert.deepEqual(data.session.message.list("session").map((message) => message.id), ["new"]) + } finally { + destroyOpenCodeData(instanceId) + } + }) }) diff --git a/packages/ui/src/stores/opencode-data.ts b/packages/ui/src/stores/opencode-data.ts index 9c74d93d..4ecbc312 100644 --- a/packages/ui/src/stores/opencode-data.ts +++ b/packages/ui/src/stores/opencode-data.ts @@ -1,10 +1,10 @@ import type { OpenCodeEvent } from "@opencode-ai/client" import { createData, type Data } from "@opencode-ai/client/solid" import { createRoot } from "solid-js" -import type { MessageInfo } from "../types/message" import { getRootClient } from "./opencode-client" -import { seedSessionMessagesV2 } from "./message-v2/bridge" +import { applyPartUpdateV2, upsertMessageInfoV2 } from "./message-v2/bridge" import { normalizeSessionMessage } from "./message-v2/normalizers" +import { sseManager } from "../lib/sse-manager" const entries = new Map void; dispose: () => void }>() @@ -26,7 +26,14 @@ function ensureData(instanceId: string, directory: string) { }, } return { - data: createData({ api: () => getRootClient(instanceId), directory, event: event as any }), + data: createData({ + api: () => getRootClient(instanceId), + directory, + event: event as any, + connection: { + status: () => sseManager.getStatuses().get(instanceId) === "connected" ? "connected" : "reconnecting", + }, + }), emit(details: OpenCodeEvent) { for (const listener of listeners) listener({ name: details.type, details }) }, @@ -38,6 +45,7 @@ function ensureData(instanceId: string, directory: string) { } export function applyOpenCodeDataEvent(instanceId: string, directory: string, event: OpenCodeEvent): Data { + if (event.type === "server.connected") destroyOpenCodeData(instanceId) const entry = ensureData(instanceId, directory) entry.emit(event) return entry.data @@ -46,13 +54,16 @@ export function applyOpenCodeDataEvent(instanceId: string, directory: string, ev export function projectOpenCodeMessages(instanceId: string, sessionId: string, data: Data): void { const source = data.session.message.list(sessionId) if (!source.length) return - const infos = new Map() - const messages = source.map((item) => { + for (const item of source) { const normalized = normalizeSessionMessage(sessionId, item) - infos.set(normalized.info.id, normalized.info) - return normalized.message - }) - seedSessionMessagesV2(instanceId, { id: sessionId }, messages, infos) + const status = normalized.message.status + upsertMessageInfoV2(instanceId, normalized.info, { + status: status === "sending" || status === "sent" || status === "streaming" || status === "error" + ? status + : "complete", + }) + for (const part of normalized.message.parts) applyPartUpdateV2(instanceId, part) + } } export function destroyOpenCodeData(instanceId: string): void { diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 0e34aaf1..02cf7e5a 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -82,6 +82,7 @@ const catalogRefreshes = new Map } const agentRequestIds = new Map() const providerRequestIds = new Map() const sessionPageRequests = new Map>() +const MAX_DESCENDANT_SESSION_REQUESTS = 1_000 let nextSessionListRequestId = 0 function catalogLocationKey(location: LocationRef): string { @@ -180,27 +181,38 @@ async function fetchV2Sessions(instanceId: string, options: V2SessionListOptions const client = getRootClient(instanceId) const project = options.project ?? getInstanceMetadata(instanceId)?.project?.id const listOptions = { ...options, project, order: options.order ?? "desc" as const } + if (project) delete listOptions.directory const response = await client.session.list(buildProjectSessionListOptions(listOptions)) const sessionsById = new Map(response.data.map((session) => [session.id, session])) if (options.parentID === null && !options.search) { - await Promise.all(response.data.map(async (root) => { + const pending = response.data.map((session) => session.id) + const visited = new Set() + let requests = 0 + while (pending.length) { + const parentID = pending.shift()! + if (visited.has(parentID)) continue + visited.add(parentID) let cursor: string | undefined const seenCursors = new Set() do { + if (++requests > MAX_DESCENDANT_SESSION_REQUESTS) throw new Error("Descendant session traversal limit exceeded") const children = await client.session.list(buildProjectSessionListOptions({ - project: project ?? root.projectID, - subpath: root.subpath, - parentID: root.id, + project: project ?? sessionsById.get(parentID)?.projectID, + subpath: sessionsById.get(parentID)?.subpath, + parentID, order: "asc", cursor, })) - for (const child of children.data) sessionsById.set(child.id, child) + for (const child of children.data) { + sessionsById.set(child.id, child) + if (!visited.has(child.id)) pending.push(child.id) + } cursor = children.cursor?.next ?? undefined if (cursor && seenCursors.has(cursor)) throw new Error(`Repeated child session cursor: ${cursor}`) if (cursor) seenCursors.add(cursor) } while (cursor) - })) + } } return { @@ -214,6 +226,29 @@ function getV2SessionItems(response: ProjectSessionListResponse): SDKSession[] { return response.data } +function withActiveSessionState( + instanceId: string, + apiSession: SDKSession, + existingSession: Session | undefined, + activeSessions: Record | null, +): Session { + const existingStatus = existingSession?.status + const active = activeSessions && Object.prototype.hasOwnProperty.call(activeSessions, apiSession.id) + const status = activeSessions === null + ? existingStatus ?? "idle" + : active && existingStatus === "compacting" ? "compacting" : active ? "working" : "idle" + return { + ...toClientSessionV2(instanceId, apiSession, existingSession), + status, + retry: activeSessions === null ? existingSession?.retry ?? null : null, + idleSince: getIdleSinceForStatusTransition(existingStatus, status, existingSession?.idleSince), + runtimeStatusKnown: activeSessions === null ? existingSession?.runtimeStatusKnown ?? false : true, + generationRecovery: activeSessions === null + ? existingSession?.generationRecovery ?? null + : resolveAuthoritativeGenerationRecovery(existingSession?.generationRecovery, status), + } +} + async function hydrateRestoredSessionChain( instanceId: string, requestedIds: Array, @@ -326,24 +361,7 @@ async function fetchSessions(instanceId: string, options?: { for (const apiSession of getV2SessionItems(response)) { const existingSession = existingSessions?.get(apiSession.id) - const existingStatus = existingSession?.status - const active = activeSessions && Object.prototype.hasOwnProperty.call(activeSessions, apiSession.id) - const status = activeSessions === null - ? existingStatus ?? "idle" - : active && existingStatus === "compacting" ? "compacting" : active ? "working" : "idle" - const runtimeStatusKnown = activeSessions === null ? existingSession?.runtimeStatusKnown ?? false : true - sessionMap.set(apiSession.id, { - ...toClientSessionV2(instanceId, apiSession, existingSession), - status, - retry: activeSessions === null ? existingSession?.retry ?? null : null, - idleSince: getIdleSinceForStatusTransition(existingStatus, status, existingSession?.idleSince), - runtimeStatusKnown, - generationRecovery: activeSessions === null - ? existingSession?.generationRecovery ?? null - : runtimeStatusKnown - ? resolveAuthoritativeGenerationRecovery(existingSession?.generationRecovery, status) - : existingSession?.generationRecovery ?? null, - }) + sessionMap.set(apiSession.id, withActiveSessionState(instanceId, apiSession, existingSession, activeSessions)) } setSessions((prev) => { @@ -442,19 +460,25 @@ async function loadNextSessionPage(instanceId: string): Promise { if (!cursor) return const instance = instances().get(instanceId) if (!instance?.client) throw new Error("Instance not ready") - const response = await fetchV2Sessions(instanceId, { - directory: instance.folder, - parentID: null, - order: "desc", - cursor, - }) + const [response, activeSessions] = await Promise.all([ + fetchV2Sessions(instanceId, { directory: instance.folder, parentID: null, order: "desc", cursor }), + getRootClient(instanceId).session.active().catch((error) => { + log.warn("Failed to refresh active sessions", { instanceId, error }) + return null + }), + ]) if (getSessionNextCursor(instanceId) !== cursor) return const deleted = getAuthoritativelyDeletedSessionIdsForInstance(instanceId) setSessions((previous) => { const next = new Map(previous) const current = new Map(next.get(instanceId) ?? []) for (const item of response.data) { - if (!deleted.has(item.id)) current.set(item.id, toClientSessionV2(instanceId, item, current.get(item.id))) + if (!deleted.has(item.id)) { + const existing = current.get(item.id) + const fetched = withActiveSessionState(instanceId, item, existing, activeSessions) + const merged = mergeFetchedSessionRuntimeState(fetched, existing, existing, false) + if (merged) current.set(item.id, merged) + } } next.set(instanceId, current) return next diff --git a/packages/ui/src/stores/session-request-authority.test.ts b/packages/ui/src/stores/session-request-authority.test.ts index 1e183964..66663663 100644 --- a/packages/ui/src/stores/session-request-authority.test.ts +++ b/packages/ui/src/stores/session-request-authority.test.ts @@ -5,7 +5,8 @@ import { sdkManager } from "../lib/sdk-manager.ts" import type { Session } from "../types/session.ts" import { addInstance, removeInstance } from "./instances.ts" import { messageStoreBus } from "./message-v2/bus.ts" -import { fetchSessions, loadMessages, removeSessionRuntimeState, searchSessions } from "./session-api.ts" +import { fetchSessions, loadMessages, loadMoreSessions, removeSessionRuntimeState, searchSessions } from "./session-api.ts" +import { setInstanceMetadata } from "./instance-metadata.ts" import { clearInstanceDeletedSessionAuthority, getSessionSearchResultIds, @@ -183,4 +184,35 @@ describe("session request authority", () => { cleanup() } }) + + it("loads every descendant depth by project and applies active state to later pages", async () => { + const instanceId = "project-descendants" + const { client, cleanup } = setup(instanceId) + const requests: any[] = [] + let active: Record = {} + setInstanceMetadata(instanceId, { project: { id: "project", directory: "/work", canonical: "/work" } as any }) + ;(client.session as any).active = async () => active + ;(client.session as any).list = async (input: any) => { + requests.push(input) + if (input.cursor === "root-page-2") return { data: [apiSession("later")], cursor: {} } + if (input.parentID === "root" && !input.cursor) return { data: [apiSession("child", "root")], cursor: { next: "child-page-2" } } + if (input.parentID === "child") return { data: [apiSession("grandchild", "child")], cursor: {} } + if (input.parentID) return { data: [], cursor: {} } + return { data: [apiSession("root")], cursor: { next: "root-page-2" } } + } + + try { + await fetchSessions(instanceId) + assert.equal(sessions().get(instanceId)?.has("grandchild"), true) + assert.equal(requests[0].project, "project") + assert.equal("directory" in requests[0], false) + + active = { later: {} } + await loadMoreSessions(instanceId) + assert.equal(sessions().get(instanceId)?.get("later")?.status, "working") + assert.equal(sessions().get(instanceId)?.get("later")?.runtimeStatusKnown, true) + } finally { + cleanup() + } + }) })