diff --git a/AGENTS.md b/AGENTS.md index daab59c..2de3647 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,8 +42,8 @@ Host bundle (`lib/index.mjs`), a thin `apply` barrel over small modules: - `src/index.ts` · `src/identity.ts` · `src/lifecycle.ts` — the public barrel, the identity constants, and the `apply()` composition (one installer per side effect, in load-bearing order). The barrel is the host's contract and nothing more: `apply`, `Config`, `inject`, `name` and the config types. Everything else is internal, and `test/public-api.test.ts` pins the list, so widening it is a reviewed change. - `test/support/src.ts` — the one door tests use into `src`: each name re-exported from the module that DEFINES it, never through the barrel, so a module move edits one file. `test/architecture.test.ts` enforces both halves — every name is declared in its target, and no test outside the named exemptions (`vi.mock` targets, fresh re-imports after `vi.resetModules()`, the architecture and public-API tests, the jsdom client tests, the `reset-process-state.ts` setup file) imports a `src` path, and no jsdom test loads the door. -- `test/support/composition.ts` — the plugin mounted on a real cordis `Context` the way the Loader mounts it (`mountPlugin`: `ctx.plugin({ apply, Config, inject, name }, config)`, so the schema resolves the row config; a turn through `ctx.waterfall("llm/stream", …)`, teardown through `fiber.dispose()`), for a unit test that means "the plugin, applied". The mount passes the module's exports unchanged, so repeated activations on one root share one cordis runtime, as the Loader's do, and `activated()` is read off the fiber. `degradedHost` is the one stand-in for the plugin's COMPOSITION, for a composition a real `Context` cannot be, because a real one registers what it is asked to and always has `plugin`: (1) a `plugin` that registers nothing, with and without `get`, which drives the three unreachable-service diagnostics; (2) no `plugin`, or neither `plugin` nor `provide`. Its `effect` runs and keeps the disposer, never a no-op. Its two context-only helpers, `until` and `logLines`, come from `test/support/cordis-wait.ts` (which `host.ts` re-exports), so a bare-`Context` file loads no host package. -- `test/support/route-claim-stand-in.ts` — the route claim's own stand-in host, kept on purpose for the fiber states a real host cannot be put in on demand (a LOADING or FAILED pi-ai, a null uid, a refusing registry), on either cordis core: `piFiber` and `oldRun` for the OLD one, `newPiFiber` and the stand-in's own `waterfall` and `emit`, which dispatch as the NEW one does, for the other; it drives `test/route-claim*.test.ts`, and `test/host-planes*.test.ts` covers the claim on the real host. The discovery, Settings-graft and reload tests likewise keep the `llm` object their seam wraps. The client entry's tests (`test/settings-page*.test.tsx`, `test/client-bundle.test.ts`) keep a hand-built settings host of their own: the web runtime has no in-process tier, and the jsdom files must not load the door. The go-discovery and usage-service files serve their loader rows and credentials on `new Context()` (`test/support/loader-rows.ts`), so the reader takes the `ctx.get` arm a real host takes. +- `test/support/composition.ts` — the plugin mounted on a real cordis `Context` the way the Loader mounts it (`mountPlugin`: `ctx.plugin({ apply, Config, inject, name }, config)`, so the schema resolves the row config; a turn through `ctx.waterfall("llm/stream", …)`, teardown through `fiber.dispose()`), for a unit test that means "the plugin, applied". The mount passes the module's exports unchanged, so repeated activations on one root share one cordis runtime, as the Loader's do, and `activated()` is read off the fiber. `degradedHost` is the one stand-in for the plugin's COMPOSITION, for a composition a real `Context` cannot be, because a real one registers what it is asked to and always has `plugin`: (1) a `plugin` that registers nothing or throws, with and without `get`, which drives the three unreachable-service diagnostics and a refused mount; (2) a `plugin` whose fiber the case settles, for `apply` waiting on the services' reports; (3) no `plugin` (with or without `provide`, which is never called). Its `effect` runs and keeps the disposer, never a no-op. Its two context-only helpers, `until` and `logLines`, come from `test/support/cordis-wait.ts` (which `host.ts` re-exports), so a bare-`Context` file loads no host package. +- `test/support/route-claim-stand-in.ts` — the route claim's own stand-in host, kept on purpose for the fiber states a real host cannot be put in on demand (a LOADING or FAILED pi-ai, a null uid, a refusing registry), on either cordis core: `piFiber` and `oldRun` for the OLD one, `newPiFiber` and the stand-in's own `waterfall` and `emit`, which dispatch as the NEW one does, for the other; it drives `test/route-claim*.test.ts`, and `test/host-planes*.test.ts` covers the claim on the real host. `test/remote-service.test.ts` keeps stand-in compositions of its own for `installRemoteService`'s clauses (a `plugin` that throws, answers no fiber, or answers a fiber whose settling and dispose the case controls; no `plugin`; no `effect`), beside its real-`Context` cases. The discovery, Settings-graft and reload tests likewise keep the `llm` object their seam wraps. The client entry's tests (`test/settings-page*.test.tsx`, `test/client-bundle.test.ts`) keep a hand-built settings host of their own: the web runtime has no in-process tier, and the jsdom files must not load the door. The go-discovery and usage-service files serve their loader rows and credentials on `new Context()` (`test/support/loader-rows.ts`), so the reader takes the `ctx.get` arm a real host takes. - `test/support/e2e-env.ts` — the live suites' environment, read in one place (`readE2eEnvironment`, and `e2e()` for the process's own, read on first call so loading the module reads nothing): whether the live suite runs, the key tier and the key it arms per plane, a mirror's bases, the record directory, the CI run and the host-binary cell. No e2e file reads `process.env` for these itself. `test/e2e-env.test.ts` drives it with environments of its own: the `live` gate, the key each plane arms per tier, blank bases, an unknown tier, the host-binary cell. - `test/support/host-versions.ts` — the hosts the plugin supports, named once: one record per harness channel (`rc` = the newest rc build, `dsh@next` ahead of `dsh@latest`; `alpha` = `dsh@alpha`), each naming the dist-tag that carries it, each with its `@deepseek-ai` cluster, the cordis family (`cordisFamily`: cordis, the loader, include, group, timer, cosmokit, schemastery, each at the floor of the `~` range the published cluster declares, which is what the harness vendors at the tag and moves only with `dsh`), the bridge's pi RANGE, the newest pi that range resolves (the channel lockfile's) and the range's floor (the harness's own pin, which only the desktop app ships), and `floorOverrides`, the family and the floor pi pair the `e2e-host` floor cell forces, plus which one the install under test is (`DSH_CHANNEL`, else the marker `scripts/use-channel.ts` leaves, else `alpha`). `test/host-versions.test.ts` checks the installed tree against the SELECTED record; `test/harness-channel.test.ts` checks the declarations and holds each committed lockfile (`pnpm-lock.yaml` for alpha, `channels/rc/pnpm-lock.yaml` for rc) to its record, including that `@deepseek-ai/schemastery` resolves to one copy inside the cluster's range and that each family member the cluster peers on is the floor of the declared range. - `test/support/host.ts` — the real host, in-process, in two tiers: **A** (`mountTierA`: a bare cordis `Context`, `LlmRuntime` and `dsh-llm-pi-ai` mounted with `{ providers }`) for the `internal/config` waterfall and pi-ai's credential paths, and **B** (`bootTierB`: dsh-app-boot's `boot()` with a temporary HOME and a test bundle) for the config editor, `settings.describe`, the volatile commit and the characterization of the plugin as it ships. Its fakes (`fakeCredentials`, `fakeLaunchEnvironment`, `recordingFetch`) keep every case offline and its credentials out of the environment (a boot blanks the two keys the plugin reads directly, should the shell export them, and restores them on dispose); `S0_PROFILE` is the contract fixture, `CHARACTERIZED_PROFILE` (S0 plus a declared catalog-unknown id) the characterization's, and `S0_TODAY_BY_CELL` the measured per-model rows, tagged per facet, one table per host cell (`newest`, either channel's default install, and `rc-floor`/`alpha-floor`, the pis the desktop app ships; since the route claim every cell takes its channel's table (`s0PluginFor`), and the two channels' tables differ only in pi-ai's own defaults for the catalog-unknown id (`PI_AI_DEFAULTS`); `S0_TODAY` is the installed cell's, and `forHost`/`forChannel` pick any other per-cell or per-channel expectation outside the table, such as `PI_GAPS`), that the binary e2e shares (it reads its cell's `rows` directly; `todayRowsFor`, which only the characterization calls, answers the same rows for either apply order since the route claim). `logLines` captures every logger line at every level, because cordis's own buffer stops at `info` and tier B prints nothing to the console. Tier A has no loader, so the plugin's default host-module source cannot find pi-ai there; the harness swaps in `hostModulesForTests` through the seam in `src/cordis-context.ts`, and the route claim serves the planes in tier A as it does on the real host. Tier B's `pluginRow` files the plugin's row under another id and package (the rename notice's case), and its two failure paths (an invalid first row, in each apply order) are `host-planes`' cases. Tier B's rows carry PACKAGE names through a stub module loader, never `cordis:` builtins, because the plugin's default host-module source finds pi-ai's entry by `options.name` (`piAiEntry` in `src/host/host-modules.ts`) and a builtin-named row would leave the route claim without pi-ai's module. It also fixes which of pi-ai and the plugin applies first (`order`), because the loader starts rows in parallel and the published binary applies the plugin first; the held row is released when the first one applies or its fiber settles, so an invalid first row fails instead of hanging the boot. Its `realm` option puts pi-ai's row in a `credentials` realm the plugin's row does not share, beside a row of the test's own `realmCredentialsPlugin` that provides `credentials` there, the shape a vendor's setup writes (`test/host-planes-foreign.test.ts`). @@ -112,6 +112,7 @@ It is the same trick `test/architecture.test.ts` already uses for the client bun - `scripts/session-band.ts` — `verify-session-band.ts`'s pure half: the vendor's id construction, ported, the four band facts with the answer each must have (`bandMismatches`), and the verdict the script prints (`bandVerdict`): "CONFIRMED" only when there is no mismatch, otherwise one `MISMATCH:` line each and exit 1. The unit run makes the verdict fail on a planted derivation and spawns the script in its passing state. - `scripts/freshness-scope.ts` — which of CI's three freshness checks (`catalog:shim`, `limits:shim`, `cli:version`) a pull request is GATED on: the ones whose inputs (the check's script and the local modules it loads, walked from the source as Node loads it — types erased by Node's own stripper, specifiers read by `es-module-lexer` — plus `src/identity.ts` for the CLI version; never the generated files) its own diff touches. The rest only warn, because vendor drift is not the pull request's regression: for the two generated tables the nightly run regenerates them and proposes the refresh, and for the CLI version it warns and the bump is a human pull request, which then gates. The gates fail closed: each runs unless the scope step answered `report`. The CLI version's offline half — every copy in the tree agrees with the pin, `cli:version -- --copies` — gates every pull request, since it skips the generated tables and only the repository can break it. `test/freshness-scope.test.ts` walks the real generators, runs the scope step's own shell and the CLI it calls, and evaluates the workflow's step conditions. - `src/host/route-claim.ts` · `src/host/plane-provider.ts` · `src/host/describe-graft.ts` · `src/host/host-modules.ts` — the plugin owns the plane routes (D13). The route claim appends ONE `internal/config` listener that reads either cordis core's call shape (`src/utils/config-run.ts`: `function (this: Fiber, raw, next)` on the OLD core, `(fiber, next)` with `this` null on the NEW one), calls `next()` exactly once, re-appends itself whenever pi-ai reaches `internal/status` ACTIVE so it stays inner to pi-ai's own checking listener (the new registration first, the old one released only once the host accepted it, so a refusal while the plugin's fiber is going away throws nothing into pi-ai's emit), and hands `llm-pi-ai`'s fiber a copy of what `next()` answered without the `opencode`/`opencode-go` entries it TAKES — an entry another plugin owns (D27: pi-ai's row resolving `credentials` in a realm the plugin's context does not, compared by the key each context reads off cordis's realm map so a group's `isolate` counts, or an entry carrying the `x-opencode-plugin` header, `src/utils/plane-ownership.ts`) stays pi-ai's, is logged once, and is never counted by a restart, a handback or the reconcile; a commit run releases at once a plane the plugin registered that its candidate no longer lets it take, so pi-ai's registration of it meets no duplicate; and the claim's read-only `steppedAside()` accessor makes the stream hook run such a turn undecorated under a `foreign` turn state (`turn-store.ts`), which the fetch patch passes through before its URL claim, as it does any marked request, and keeps the 1.x discovery wrapper off its Fetch; a commit run (the fiber's raw config, the loader's stored row config, or any run while the fiber is not ACTIVE) never throws, and every fiber config is read through the feature-detected accessors, validates a Settings edit to a plane with pi-ai's own checks, and registers the plane adapter for exactly the declared planes through `registerAdapter`/`handle.replace`; on dispose it hands the planes back by restarting pi-ai (never on a reload). `plane-provider.ts` is `PlaneAdapter`, a subclass of the host's `PiAiAdapter` over pi's own `createProvider` and four protocol implementations, with the credential seams that mirror pi-ai's (D20); its `prepareCall` stream and `stream` finish the gateway's typed refusals as `FREE_TIER` or `MODEL_UNAVAILABLE` with the gateway's words, and its status except on Google-protocol models, whose failure text carries none (`src/utils/gateway-refusal.ts`), because the conversation replaces an AUTH failure's message with "API key is invalid". Its stand-in (`test/support/route-claim-stand-in.ts`, driving `test/route-claim*.test.ts`; the new core's call-shape cases are in `test/route-claim-listener.test.ts`) builds fibers of both cores — `piFiber` (the OLD core's `_config` and resolved `config`, driven through `oldRun`) and `newPiFiber` (the NEW core's raw `config`, `rawConfig` and `effectiveConfig`, no `_config`, driven through the stand-in's `waterfall`, whose per-listener `next` throws on a second call, and its `internal/status` emit) — and `test/host-planes.test.ts` runs the claim on the installed host, whichever core it is. `describe-graft.ts` is the one read-only `settings.describe` wrapper that keeps Settings showing the planes (D18). `host-modules.ts` is where the host's own modules are found — pi-ai's on its loader entry, pi, dsh-llm and the loader from the wrapper's own tree — behind the replaceable seam in `src/cordis-context.ts` (`replaceHostModuleSource`), which is how tier A runs the claim. What a protocol sends, Google's thinking control included, is the host's pi's own (D26): where a host's pi lacks something the gateway needs, the suite records a `[HOST_LIMITATION]` row and skips the cases that would exercise it by pi's own exported predicate, never a version compare. +- `src/host/remote.ts` — `installRemoteService`, the one installer for the usage, purge and store-inspection services. One service per root, alive while a live activation holds it, reading the latest live activation's options and disposed with the last; checked against the client contract's namespace after the mount settles and reported through the activation's logger; no `ctx.provide` fallback, because a provided value is invisible to the Gateway's discovery. - `src/purge-service.ts` · `src/purge-contract.ts` — the card's "purge deprecated models" action, and the shape it answers with. It **rewrites the profile**, deleting the declared model entries the live catalog has retired, through `configEditor` (the same service the settings UI writes with). That is deliberate: a read-time filter would leave the declaration in place, and then the only way to explain a missing row is to find the filter. One source of truth, and it is the configuration. It never rewrites a plane entry another plugin owns (D27: the `x-opencode-plugin` marker, or a row whose credentials resolve in a realm the service's context does not), nor a reference to a route only such entries declare. - `src/session-cost.ts` · `src/cordis-context.ts` · `src/debug.ts` — per-turn accounting, the typed host interfaces, and stream debug logging. - `src/host/patch-method.ts` — `patchMethod`, the one way to wrap a method on a host object. A dispose restores only while its wrapper is still the current value (otherwise it becomes a pass-through under whatever wrapped on top), and deletes the own property when the original was inherited rather than assigning it back; a second install of the same site wraps the unpatched method instead of the first install. Identity is read from own descriptors, never from a property read, because cordis's traceable proxy answers a fresh proxy for every method read (`docs/engineering-notes.md`). `rewrapOnAdaptersUpdated` reinstalls a site on `llm/adapters-updated`, for a site that must follow the registry; none uses it yet. Every instance patch in `src/` goes through this module — today two sites: model discovery (`models-discovery.ts`) and the `settings.describe` graft (`host/describe-graft.ts`) — and `test/reload.test.ts` runs apply A, apply B, dispose A, dispose B on each. Two tripwires hold the rule. `test/architecture.test.ts` is textual: anywhere `apply()` reaches but the helper, it fails on a `Reflect.set`/`defineProperty`/`deleteProperty`/`setPrototypeOf`, an `Object.assign`/`defineProperty`/`defineProperties`/`setPrototypeOf` onto `llm`, an adapter, `settings` or `globalThis` (bare or through a property path such as `registration?.adapter`; a prototype swap shadows a method without leaving an own property), a dotted or bracketed assignment (through `?.` or `!` too) onto the first three, and `globalThis.x =` (the global `fetch` patch is the one named exemption, until P6); a write through a local alias or a cast receiver escapes it. The `apply()`-twice reload case is behavioural: it requires the helper's record on every own function `apply()` leaves on the stand-in's `llm` and `settings`. @@ -124,7 +125,7 @@ It is the same trick `test/architecture.test.ts` already uses for the client bun - `src/opencode-store-contract.ts` · `src/opencode-store-service.ts` — the inspection seam: the sanitized summary type both sides share, and the host service that produces it. The contract is pure data (no `node:`, no context), so the client bundle may import it; the service is the half that touches `node:sqlite` and the filesystem, and it rewrites the store path to `~`-relative before answering — a home directory is a fact about the machine, not about the plugin. `inspect` is declared through the `Object.defineProperty` marker rather than `@Remote`, for the reason `usage.ts` documents. - **The login stack is not in this tree.** It lives on the `v2` bookmark, and the map used to describe it here for years after it left — nine modules a reader could not find. What this tree carries is the API-key path only; a login grant is the other half, and reviving it is a days-scale port rather than a rebase (`v2` is 163 commits behind, and its `src/index.ts` exports a module the tree no longer has). The settings copy still holds its strings, and `credentialOrg` is the one remnant with behaviour: a configured org is sent as `x-opencode-org-id`. -- A timing dependency every remote shares, worth knowing before debugging a row that will not appear: the mount is an async `$mount` started in an effect, and the card reads `ctx.remote.` SYNCHRONOUSLY when its injector builds the props. If the injector runs before the mount resolves, the service is not there yet and the surface renders nothing. More often the cause is a namespace with no client descriptor at all — the purge row and the store-inspection row each shipped that way — which `test/remote-contract.test.ts` now catches per method. Each caller reports an empty lookup (`no opencodeStore service on the remote`, `no opencodeGoUsage service on the remote`, with the keys the remote does expose), so the symptom is a console line rather than a blank. +- A timing dependency every remote shares, worth knowing before debugging a row that will not appear: the mount is an async `$mount` started in an effect, and the card reads `ctx.remote.` SYNCHRONOUSLY when its injector builds the props. If the injector runs before the mount resolves, the service is not there yet and the surface renders nothing. More often the cause is a namespace with no client descriptor at all — the purge row and the store-inspection row each shipped that way — which `test/remote-contract.test.ts` now catches per method. Each caller reports an empty lookup (`no opencodeStore service on the remote`, `no opencodeGoUsage service on the remote`, with the keys the remote does expose), so the symptom is a console line rather than a blank. And since P5 the host services go with the plugin's last activation: a reload unloads before it applies again, which leaves a short window in which a call finds no active Remote method (the meter's poll retries; a purge click there reports the host's refusal), and disabling the plugin withdraws all three, including the `opencodeGoUsage` instance the sibling `dsh-opencode-go` account page reads. Web client bundle (`lib/client.js`): `src/settings-page.tsx` (entry, wiring only), the `settings-*` card and its controls — including the store-inspection row, `src/settings-store-inspect.tsx` — and the meter — `src/usage-pill.tsx` (state), `src/usage-panel.tsx` (pure views), `src/usage-ui.ts` (logic and stylesheet). `test/architecture.test.ts` walks this graph from the entry and fails on a node built-in, the cordis runtime, pi, or `process.env`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 15167f3..c74eaaa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -146,7 +146,7 @@ The web profile wires this checkout with `link:`, so builds are picked up like t - **No order dependence.** `test/support/reset-process-state.ts` clears the plugin's module-level stores (captured keys, session spend, the live catalog) after every test. A new module-level store gets a reset next to its siblings. `pnpm run test:shuffle` is how to check, and CI runs it. - **Tests reach `src` through `test/support/src.ts`.** It re-exports, from each defining module, everything a test needs, so a module move or a barrel change edits one file instead of thirty. A new test imports from `./support/src.ts` (`../support/src.ts` under `test/mock` and `test/e2e`); add a missing name there rather than importing a `src` path. The exceptions are `vi.mock("../src/x.ts")` targets (and their `importOriginal` type), which must name the module; fresh-module re-imports after `vi.resetModules()`, whose subject is module identity; the architecture and public-API tests, whose subject is the `src` layout and the barrel; the jsdom (client) tests, which import the defining module directly, because `src.ts` re-exports the host graph (`node:sqlite`, pi, `lifecycle.ts`) and the next rule keeps that out of client tests; and the setup file `test/support/reset-process-state.ts`, which runs before the jsdom tests too. `test/architecture.test.ts` fails on a static or side-effect `src` import anywhere else, on a dynamic `import("../src/…")` in a file that never calls `vi.resetModules()`, and on a jsdom test that value-imports the door. Importing the door loads the whole `src` graph, host and client, so a module that fails to load fails every test that comes through it. - **Host-only fixtures stay out of shared helpers.** `test/test-helpers.ts` is imported by the jsdom (client) tests, so anything that needs `node:sqlite` or the filesystem belongs in `test/support/`, as `opencode-store-fixture.ts` does. It carries no contexts either. -- **A unit test that means "the plugin, applied" mounts it on a real cordis `Context`.** `test/support/composition.ts`'s `mountPlugin` does what the Loader does — `ctx.plugin({ apply, Config, inject, name }, config)`, the module's own exports and the row's config, which makes the schema resolve the config and the fiber carry the plugin's name — so cordis decides when each part runs, a turn goes through `ctx.waterfall("llm/stream", …)` as `LlmRuntime` sends it, and `disposeMounted()` (in `afterEach`) undoes the plugin's effects through `fiber.dispose()`. A service under test gets `new Context()` with what it reads provided on it (one context per service: a cordis Service registers itself on the context it is built on). The mount passes those exports unchanged, so repeated activations on one root share one cordis runtime, as the Loader's do. The go-discovery and usage-service files serve their loader rows and credentials on `new Context()` too (`test/support/loader-rows.ts`). The one stand-in for the plugin's composition, `degradedHost`, is for a composition a real `Context` cannot be, because a real one registers what it is asked to and always has `plugin`: a `plugin` that registers nothing, with and without `get` (the three unreachable-service diagnostics), and a composition with no `plugin`, or with neither `plugin` nor `provide`. Module-level clause tests keep the object their function reads where a real host cannot be put in the state they need on demand: the route claim's stand-in host (`test/support/route-claim-stand-in.ts`: a LOADING or FAILED pi-ai, a null uid, a refusing registry), with `test/host-planes*.test.ts` covering the claim on the real host, and the `llm` objects the discovery, Settings-graft and reload seams wrap. The client entry's tests (`test/settings-page*.test.tsx`, `test/client-bundle.test.ts`) keep a hand-built settings host: the web runtime has no in-process tier, and the jsdom files must not load the door. `until` and `logLines` live in `test/support/cordis-wait.ts` (re-exported by `host.ts`), so a bare-`Context` unit file loads no host package through them. +- **A unit test that means "the plugin, applied" mounts it on a real cordis `Context`.** `test/support/composition.ts`'s `mountPlugin` does what the Loader does — `ctx.plugin({ apply, Config, inject, name }, config)`, the module's own exports and the row's config, which makes the schema resolve the config and the fiber carry the plugin's name — so cordis decides when each part runs, a turn goes through `ctx.waterfall("llm/stream", …)` as `LlmRuntime` sends it, and `disposeMounted()` (in `afterEach`) undoes the plugin's effects through `fiber.dispose()`. A service under test gets `new Context()` with what it reads provided on it (one context per service: a cordis Service registers itself on the context it is built on). The mount passes those exports unchanged, so repeated activations on one root share one cordis runtime, as the Loader's do. The go-discovery and usage-service files serve their loader rows and credentials on `new Context()` too (`test/support/loader-rows.ts`). The one stand-in for the plugin's composition, `degradedHost`, is for a composition a real `Context` cannot be, because a real one registers what it is asked to and always has `plugin`: a `plugin` that registers nothing or throws, with and without `get` (the three unreachable-service diagnostics and a refused mount), a `plugin` whose fiber the case settles (`apply` waiting on the services' reports), and a composition with no `plugin` (with or without `provide`, which is never called). Module-level clause tests keep the object their function reads where a real host cannot be put in the state they need on demand: the route claim's stand-in host (`test/support/route-claim-stand-in.ts`: a LOADING or FAILED pi-ai, a null uid, a refusing registry), with `test/host-planes*.test.ts` covering the claim on the real host, `test/remote-service.test.ts`'s stand-in compositions for `installRemoteService`'s clauses (a `plugin` that throws, answers no fiber or answers a fiber the case settles; no `plugin`; no `effect`), and the `llm` objects the discovery, Settings-graft and reload seams wrap. The client entry's tests (`test/settings-page*.test.tsx`, `test/client-bundle.test.ts`) keep a hand-built settings host: the web runtime has no in-process tier, and the jsdom files must not load the door. `until` and `logLines` live in `test/support/cordis-wait.ts` (re-exported by `host.ts`), so a bare-`Context` unit file loads no host package through them. - **A test for a fix fails without it.** Before committing a regression test, revert the fix locally and watch it fail. - **Meaningful coverage.** Every config field in the `SPECS` register (`src/settings-fields.ts`) needs both the on and off path where it has one; every passthrough claim needs a non-OpenCode URL test proving headers are untouched; helper functions exported from `usage-ui.ts` are asserted directly, not re-derived in the test. - **A component with hooks needs a real mount.** Calling `Component(props)` returns an element and runs none of its state, so a hook-bearing component can have twenty passing cases and still be untested — `usage-pill.tsx` sat at 9% exactly that way, with the poll loop, retry and dismissal all uncovered. `test/usage-pill-mount.test.tsx` is the answer: a `// @vitest-environment jsdom` pragma scoped to that one file, so the rest of the suite keeps the fast node environment and the zero-dependency element-tree style. @@ -158,7 +158,7 @@ The web profile wires this checkout with `link:`, so builds are picked up like t - **Raise them when you genuinely add coverage** — a PR that lifts statements by a few points should lift the threshold with it, or the next contributor inherits a gate nobody re-measured. - **Never set one above the current measurement.** That does not make the suite better; it makes every subsequent PR fail until someone deletes tests. -`src/index.ts` is excluded from the report — it is a pure re-export barrel, and the coverage tools attribute an untaken re-export line to whichever file re-exports it, so leaving it in reports a hole nobody can fill while hiding real ones. Load-bearing modules carry their own per-file floors, so a well-covered average cannot hide one that stopped being exercised: `host/patch-method.ts` and the four pure plane modules (`utils/protocols.ts`, `utils/model-metadata.ts`, `utils/plane-model.ts`, `utils/plane-profile.ts`) and `utils/gateway-refusal.ts` at 100%, because each branch is a reload case, a protocol, a precedence, one of pi-ai's checks, a refusal type or a text shape; `host/route-claim.ts`, `host/plane-provider.ts`, `host/describe-graft.ts` and `host/host-modules.ts` just below what they measure; and the three modules that install the plugin's effects on the host, `lifecycle.ts`, `stream-hook.ts` and `fetch-patch.ts`, just below what they measure too, with the branches no composition reaches named beside the floor. `test/architecture.test.ts` fails the build on a floor whose file no longer exists, which would otherwise be a floor on nothing. +`src/index.ts` is excluded from the report — it is a pure re-export barrel, and the coverage tools attribute an untaken re-export line to whichever file re-exports it, so leaving it in reports a hole nobody can fill while hiding real ones. Load-bearing modules carry their own per-file floors, so a well-covered average cannot hide one that stopped being exercised: `host/patch-method.ts`, `host/remote.ts`, the four pure plane modules (`utils/protocols.ts`, `utils/model-metadata.ts`, `utils/plane-model.ts`, `utils/plane-profile.ts`) and `utils/gateway-refusal.ts` at 100%, because each branch is a reload case, a mount outcome, a protocol, a precedence, one of pi-ai's checks, a refusal type or a text shape; `host/route-claim.ts`, `host/plane-provider.ts`, `host/describe-graft.ts` and `host/host-modules.ts` just below what they measure; and the three modules that install the plugin's effects on the host, `lifecycle.ts`, `stream-hook.ts` and `fetch-patch.ts`, just below what they measure too, with the branches no composition reaches named beside the floor. `test/architecture.test.ts` fails the build on a floor whose file no longer exists, which would otherwise be a floor on nothing. ## The freshness checks in CI diff --git a/README.md b/README.md index 34573d1..d05a5f9 100644 --- a/README.md +++ b/README.md @@ -447,6 +447,7 @@ OpenCode's own Console plugin (`npx @opencode/console setup deepseek-harness`) c - **`@viztor/dsh-opencode` is deprecated, and still published.** It is a thin wrapper that re-exports `dsh-opencode-patch`, so an install on the old name keeps resolving; the registry marks it deprecated and points at the canonical name. New installs should use `dsh-opencode-patch` or `@viztor/dsh-opencode-patch`. - **A route another plugin owns is left to it.** A plane entry in `llm-pi-ai`'s row is served by `llm-pi-ai`, not by this plugin, when that row resolves credentials in a realm this plugin does not or the entry carries the `x-opencode-plugin` header — the shape OpenCode Console's setup writes — and no header, key or meter of this plugin's touches its traffic. In the released 1.x, a turn on such a route that the plugin moved onto one of its hidden Responses, Messages or Google routes was resolved with this plugin's credentials and failed for want of the Console's token, and every other turn on it carried this plugin's headers and had its key recorded for the meter; see [With OpenCode Console installed](#with-opencode-console-installed). - **The gateway's refusals are reported as what they are.** On the planes this plugin serves, a `FreeTierError` (403) or `FreeUsageLimitError` (429) now finishes the turn as `FREE_TIER`, and a `RegionError`, `ModelDeprecated` or `ModelProtocolUnsupported` as `MODEL_UNAVAILABLE`, each with the gateway's words, and its status except on Google-protocol models, whose failure text carries none. The conversation used to show "API key is invalid" for the 403s and "Request quota exhausted." for the usage cap, with the gateway's message dropped. Like the codes they replace, neither new code is in the host's retryable set, so a refused turn is still not resent. A free Chat Completions turn with no tools of its own now carries `tool_choice: none` beside the free tier's `read` and `bash`, and a `read` or `bash` declared with an empty schema is replaced by the full one. +- **Usage settings apply when the plugin is applied again, and disabling the plugin withdraws its services.** A change to `usageBaseURL`, `keySource` or `readOpencodeStore`, or turning `usageEnabled` off, takes effect the next time the plugin is applied: when it is disabled and enabled again, or when `dsh` restarts. The usage service used to keep the values it started with until `dsh` restarted, however often the plugin was disabled and enabled in between. Disabling the plugin now withdraws its usage, purge and store-inspection services instead of leaving them answering. ## 📜 Compatibility & Verification diff --git a/README.zh-CN.md b/README.zh-CN.md index e1de0dc..334161b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -441,6 +441,7 @@ OpenCode 官方的 Console 插件(`npx @opencode/console setup deepseek-harnes - **`@viztor/dsh-opencode` 已弃用,但仍在发布。** 它是一个转导出 `dsh-opencode-patch` 的薄包装,所以用旧名的安装照常解析;registry 上标记为弃用并指向正式名称。新安装请用 `dsh-opencode-patch` 或 `@viztor/dsh-opencode-patch`。 - **其他插件拥有的路由留给它自己。** 当 `llm-pi-ai` 的配置行在本插件不解析凭据的凭据域中解析凭据,或者平面条目带有 `x-opencode-plugin` 请求头时(OpenCode Console 安装步骤写入的正是这种形状),该平面由 `llm-pi-ai` 而不是本插件服务,本插件的请求头、密钥与计量都不碰它的流量。在已发布的 1.x 中,这类路由上被本插件转到其隐藏的 Responses、Messages 或 Google 路由的轮次用本插件的凭据解析,因拿不到 Console 的令牌而失败,其余轮次则带上本插件的请求头,其密钥也被记录下来供计量使用;见[与 OpenCode Console 同时安装](#与-opencode-console-同时安装)。 - **网关的拒绝如实报告。** 在本插件服务的平面上,`FreeTierError`(403)或 `FreeUsageLimitError`(429)现在以 `FREE_TIER` 结束回合,`RegionError`、`ModelDeprecated` 或 `ModelProtocolUnsupported` 以 `MODEL_UNAVAILABLE` 结束,各自带有网关的原话,以及它的状态码(Google 协议的模型除外,其失败文本不带状态码)。过去对话对这些 403 显示“API 密钥无效”(API key is invalid),对用量上限显示“当前请求的额度已用尽”(Request quota exhausted.),网关的消息被丢弃。与被替换的代码一样,这两个新代码都不在宿主的可重试集合中,所以被拒绝的回合仍不会被重发。没有自带工具的免费 Chat Completions 回合现在在免费层的 `read` 与 `bash` 之外带上 `tool_choice: none`,以空 schema 声明的 `read` 或 `bash` 会被完整的定义替换。 +- **用量设置在插件再次应用时生效,停用插件会撤下它的服务。** 修改 `usageBaseURL`、`keySource` 或 `readOpencodeStore`,或关闭 `usageEnabled`,在插件下一次被应用时生效:即停用后再启用,或 `dsh` 重启时。以前用量服务会一直沿用启动时的值,直到 `dsh` 重启,期间无论停用再启用多少次都不变。停用插件现在会撤下它的用量、清理与存储检查服务,而不是让它们继续应答。 ## 📜 兼容性与验证 diff --git a/docs/improvement-plan.md b/docs/improvement-plan.md index 36bb37a..b0536d3 100644 --- a/docs/improvement-plan.md +++ b/docs/improvement-plan.md @@ -34,7 +34,7 @@ Phases land as **stacked pull requests**. The open ones are rebased, each onto i | P-T2 | #116 | `test:` suite structure | merged (9be88d6) | **rebased onto P3** as its own change; every test `main` and the lower restack brought is in this layout (the split files, the one door, and no socket in the unit run, which `test/architecture.test.ts` now enforces); the catalog tests assert invariants, and the cases this phase and `main` brought, `tool-fallback`'s free model and `models-discovery`'s catalog rows pick rows by shape or seed them, while earlier cases that name a vendor id, as a declared profile entry, a fixture string or a lookup in the shipped tables (`go-limits`' allowance coverage, `thinking-levels`' Gemini rows, `plane-model`'s `specOf`), keep it (converting those is a follow-up); `test/e2e/verdict.ts` folds into `test/support/live-probe.ts`, `main`'s mock-coverage cases join `protocol-endpoints.mock.ts`, the host-drift CLI case moves to the mock suite, `main`'s plane-lookup fallback gains its first assertions, and `docs/testing.md` describes this layout | | P-T3 | | `ci:` CI and scripts | in review | **on `main` (9be88d6)**, which P-T2's merge left with this phase's base tree; shrank to what `main` had not done (one unit run, the build before `lib/`, tsx type-checked, one setup action, `check.ts` trimmed, `verify-*` exit codes, `.github/` and `*.sh` scanned) and gained the dist-tags and 2.x User-Agent read (L6) and the nightly bump proposal (an open proposal left to its reviewer); the checkout stays in each job, since a local action cannot bring its own, and the `check` job keeps its required-check name and the default install's coverage run | | P-R3 | | `fix:` honest refusals | in review | **new, on P-T3**, after P-R2's re-measure (run 43542273415): the gateway's typed refusals finish as `FREE_TIER` or `MODEL_UNAVAILABLE` on the planes, with the gateway's words and, except on Google, its status (L2, the UI claim verified on both channels' chat UI); L7's `tool_choice: none` and schema check; the OC6, OC8 and OC9 probe rows and OC10's README bullets. L9 and L10 land no code: the run answered no keyed 429, and left L10 unmeasured (the reasoning model was refused with and without the field, so the field was never read) | -| P5 | | `refactor:` installers | planned | | +| P5 | | `fix:` the remote services follow the plugin's latest activation and go with its last | in review | **on P-R3**; `installRemoteService` (`src/host/remote.ts`) mounts the three services (usage, purge, store inspection), one per root while a live activation holds it, reading the latest live activation's options and disposed with the last activation, so the usage settings apply the next time the plugin is applied (a disable and re-enable, or a restart) and a disable withdraws all three; each mount is reported through the activation's logger once it has settled (no false "did not register" on a healthy boot, a refused mount said by name); the provide arm is dropped (`KNOWN_DEFECT P5` flipped); L1's warn-once, which treats any live patch of the plugin's as in place. Retitled from `refactor:`, because the README announces it and the changelog must carry it (P-H's precedent; a `refactor` is hidden from it); the owner may override at landing, and keeping `refactor:` drops the README pair with it | | P6 | | `refactor:` the fetch patch's credential fill | planned | | | P7 | | `refactor:` one source per table | planned | | | P8 | | `refactor:` settings table | planned | | @@ -333,7 +333,7 @@ What stays private is pinned by contract tests against the real host packages: **How each phase opens:** - **Behaviour phases open with a red test:** P1a, P1b's fixes, P2, P-R1, P3, P6 and P10b. -- **Pinning phases open with a green pin**, which the PR shows unchanged: P-T1a/b, P-H, P-R2, P5, P7, P8, P9, P10a and P-T2. P-H's exceptions are red on the base by design: its reload test (the defect the phase removes) and the five cases in `test/models-discovery.test.ts` marked "a deliberate change in P-H" (a refused listing define, a reinstall's `registeredRoutes`, a frozen adapter, a refused adapter method, and an inherited adapter method left with no own property after dispose). P-R2 landed its pins with the change in one commit: the default's and legacy's bases (the first and third describes of `test/endpoint-family.test.ts`, written against `baseUrlFor`) were run on the base (0cceb81) with the imports of the names the phase adds removed, and passed, with a control that changes one legacy base failing there; the constant and the dispatch-set cases land with the names they test (`AUTO_ENDPOINT_FAMILY`, `LEGACY_DISPATCH`, `MATRIX_FAMILIES`), and a measured flip of the default is P-R2b. +- **Pinning phases open with a green pin**, which the PR shows unchanged: P-T1a/b, P-H, P-R2, P5, P7, P8, P9, P10a and P-T2. P-H's exceptions are red on the base by design: its reload test (the defect the phase removes) and the five cases in `test/models-discovery.test.ts` marked "a deliberate change in P-H" (a refused listing define, a reinstall's `registeredRoutes`, a frozen adapter, a refused adapter method, and an inherited adapter method left with no own property after dispose). P-R2 landed its pins with the change in one commit: the default's and legacy's bases (the first and third describes of `test/endpoint-family.test.ts`, written against `baseUrlFor`) were run on the base (0cceb81) with the imports of the names the phase adds removed, and passed, with a control that changes one legacy base failing there; the constant and the dispatch-set cases land with the names they test (`AUTO_ENDPOINT_FAMILY`, `LEGACY_DISPATCH`, `MATRIX_FAMILIES`), and a measured flip of the default is P-R2b. P5's exceptions are red on the base by design: the flipped `KNOWN_DEFECT P5` case and the red cases its test-matrix block lists. Its guard of L1's nested-patch predicate (`lifecycle`'s two live activations) and the stream hook's silent-while-in-place case pass on the base, which has no L1 warning, and land in the change commit (the `stream-hook` case calls the five-parameter `createStreamHook`, which the base's type check rejects); so does the three-activation `KNOWN_DEFECT P6` case, which is red on the base because the base logs no L1 warning. Its pin additions (the purge and store assertions in the once-per-root case, with that case's rationale reworded to be true on both trees, and "a reload keeps every remote service reachable") land first in their own `test:` commit, run green on the base (fd3a6bc), which never disposes the root services; the change commit then leaves them untouched. - **P-T3 and P12 are exempt.** **Characterization rows are tagged when they are recorded**, so a later phase flips named rows. The authoritative per-facet tables are `S0_TODAY_BY_CELL` in `test/support/host.ts`, one per host cell (`S0_TODAY` is the installed cell's); each model's info, call-config verdict and wire carry their own tag, and a phase's PR body cites the rows it flips from there, per cell. The four tags: @@ -467,7 +467,13 @@ What stays private is pinned by contract tests against the real host packages: `account` measures nothing about the model or its fields (§13), and `model-gone` is an id the gateway retired while the catalog still listed it as current: the probe's slices come from `parseModelsDevCatalog`, which already drops every `status: "deprecated"` row, so no catalog predicate could have excluded it (the keyed matrix picked the same id for Go and got the same 410). The gap is that the L10 pair, like the representative-based variant rows on Go, has no fallback when the gateway answers `model-gone` for a listed id; the follow-up, for the next run and not this phase, is for the pair to move on to the next reasoning completions candidate, as the free probe does (`freeProbeMovesOn`). Zen's L10 rows will keep reading `account` with the current CI key whatever the model. Off stays as P-R1's metadata offers it until a served reasoning completions model answers the pair. - **A correction to L9's evidence.** pi's `onResponse` fires only after a 2xx (each protocol calls it after `.withResponse()`, and Google has no hook), so a refusal's `Retry-After` is visible only at the fetch layer: when a run shows one, the mechanism belongs there (the turn store, or OC4/OC5's per-call seam in P6), not in `onResponse`. -13. **P5 `refactor:` installers.** `installRemoteService` replaces the three mount-once copies. `mountByProvide` cannot succeed on any composition today: it constructs the service first, the services need `ctx.reflect`, and only a real `Context` (which always has `plugin`) carries it. `installRemoteService` drops the provide arm or makes it publish; `lifecycle.test.ts` pins the dead arm as `KNOWN_DEFECT P5`. +13. **P5 `fix:` the remote services follow the plugin's latest activation and go with its last.** In review. What landed: `installRemoteService(ctx, Service, contract, options)` in `src/host/remote.ts`, replacing `installUsageService`, `installPurgeService`, `installStoreService` and `mountByProvide`. + - **The mount.** The contract's descriptors name one service (the Cordis key the service is read back under) and one namespace (the one the Gateway checks the binding against); a contract naming several of either is reported and mounts nothing. The service mounts on the root, once per root, while some live activation holds it; it reads the options of the most recently applied activation still live (a `Proxy` view over a fresh empty target, so a frozen or non-configurable holder breaks no invariant, which refuses a write, a define, a delete and a freeze so the target stays empty; current for what the service reads per call), and the last activation's unload disposes it and returns the dispose for cordis to await, so a reload mounts anew. `apply` awaits the services' reports beside the claim's mount; none rejects. + - **The reports.** Every outcome is reported through the activation's own `ctx.logger` after the fiber settles: reachable, never registered, no binding, a binding for another namespace, or a refused mount (whose failed fiber is disposed so a later activation retries). + - **The provide arm was dropped, not made to publish.** It could not succeed on any composition: it constructed the service first, the services need `ctx.reflect`, and only a real `Context` (which always has `plugin`) carried it; and a provided value is invisible to the Gateway's discovery anyway. + - **L1.** The stream hook warns once, on the first decorated turn that finds `globalThis.fetch` outside the plugin's live patches (`installFetchPatch` returns the predicate); a second live activation's patch wraps the first's, so it counts as in place. + - **Rows it flips, all red on the base:** `KNOWN_DEFECT P5` (a composition with no `plugin` now reports `no ctx.plugin` and never provides), a reload's `usageBaseURL`, the latest of two live activations' settings, a disable withdrawing the three services, `usageEnabled: false` taking effect on reload, a refused mount on the activation's logger rather than cordis's line or the console, and a healthy boot's false "did not register". + - **Known limitations.** (1) The usage settings follow a re-apply, not a Settings edit: every field of the row is `.volatile()`, a Settings write takes the loader's volatile-only path (no remount), and `resolveConfig` unwraps each node once at apply, so no part of the plugin follows such an edit until it is applied again (recorded under P8's restack row). (2) A reload leaves a short window between the old service's dispose and the new one's load in which a call finds no method; the meter's poll retries. (3) Two separately installed copies of the module keep two sets of slots: the second copy's mount is refused as a duplicate, and does not mount again when the first copy's row goes, until its own row is applied again (an unsupported setup that already collides on the route claim and the fetch patch). 14. **P6 `refactor:` the fetch patch's credential fill.** The adapter path is already D20. - One resolver, for an empty or placeholder wire key only. - A parity test over the deciders, with the adapter path as its own row: a rotated key takes effect, and a service failure propagates. @@ -519,10 +525,10 @@ How each phase changes now that it sits on `main` 1.8.0 and on both harness chan | P-T2 | Absorbs `main`'s tests and owns `docs/testing.md`; folds `test/e2e/verdict.ts` into the live-probe module; tests `main`'s plane lookup fallback. | | P-T3 | **Shrank, as landed.** `main` had extracted the scripts' pure halves and written `docs/testing.md`; P-T3 did the rest: one unit run per install (the default install's is the coverage run, in the required `check` job) with an explicit build before every `lib/` reader, `test/**/*.tsx` type-checked, one composite setup action after each job's own checkout (the checkout cannot move into a local action), `check.ts` trimmed to freshness, the consumer install of both halves and the size tripwire (its file checks moved to `ci-wiring`, `package-checks`, `toolchain` and `no-local-paths`), the generators' one staleness rule, `verify-*` exit codes, and `no-local-paths` over `.github/` and `*.sh`. **New, as landed:** the CLI-version script reads the registry's dist-tags and refuses an unpinned 2.x User-Agent literal (L6; the client-generation table stays P7's); the nightly host-drift report proposes the bump pull request, moving CONTRIBUTING's release-step-3 data, dispatching the floor cells (keyed tier off) when `piFloor` or a family field moved, waiting a night for a cut inside pnpm's release-age window, never editing a measured table, proposing a new prerelease tuple as a range change, and leaving an open proposal with the same target (its body's marker, every proposed record) to its reviewer, with pi moved on every record or none (D22''). (L13's newest-pi cell is no longer P-T3's: under D14'' each channel's default install already is the newest pi.) | | P-R3 | **New**, after P-R2's re-measure: L2's error mapping in `prepareCall`, L7's `tool_choice` and schema check, L9's `providerRetryAfterMs`, and L10's Off through `none` if measured. Gains the console study's probe rows: OC9 (a raw, non-`ses_` session id, per plane, keyed and free), OC6 (the harness User-Agent against the CLI one, per plane, free and paid) and, beside OC9's, OC8's recorded session-only header set. Also OC10's paired README section ("With OpenCode Console installed") on what D27 ships and tests: the routes the plugin serves beside a Console-owned entry, the workspace's policies gating every turn, and an exported `OPENCODE_API_KEY` becoming the Console's credential. **As landed:** L2's codes (`FREE_TIER`, `MODEL_UNAVAILABLE`, chosen on the verified UI evidence) in `prepareCall` and `stream`, L7, the OC6, OC8 and OC9 rows with a free baseline, and OC10's two remaining bullets; L9 lands no code (run 43542273415 answered no keyed 429) and L10 none (unmeasured: the reasoning model was refused with and without the field). | -| P5 | Unchanged, plus L1's warn-once when `globalThis.fetch` is no longer the patched function. | +| P5 | Unchanged, plus L1's warn-once when `globalThis.fetch` is no longer the patched function. **As landed:** `installRemoteService` (`src/host/remote.ts`) for the three services, with the latest live activation's options, disposal with the last activation, reports through the activation's logger after the mount settles, and no provide arm; L1's warn-once is the stream hook's, on the first decorated turn that finds `globalThis.fetch` outside the plugin's live patches (`installFetchPatch` returns the predicate, so two live activations' nested patches raise no warning). | | P6 | L8: verify before evicting a key; L4's outcome (drop `x-opencode-request`, or send it only where it is accepted). From the console study: OC3 (capture a key only from a request the plugin decorated, and close the fill path that could reuse a captured token; the marker guard, never capturing from a request carrying `x-opencode-plugin`, landed with D27), OC4 (`prepareHeaders` on the plane adapter, so the host's `llm-pi-ai/request-headers` waterfall runs on the planes), OC5 (the plane's headers set per call through that option, not the global fetch patch), OC11 (the peer range says what the header seam needs), OC2's remainder (the marker pass-through, which landed with D27 and reads the header's presence today, moves into `src/utils/request-shape.ts`; its second rule is not adopted) and OC10's last sentence (the README's coexistence section gains that the meter and headers stay off Console traffic, once OC3's discriminator lands). | | P7 | L6's client-generation table and L11's context-window overrides table; the `planeForRoute` copies now include `main`'s `isGoPlaneUrl` and `catalogPlaneForRoute` pair; the places that justify `@ai-sdk/mistral` → completions by "the plugin follows pi" (`docs/redirect-planes.md` §2, `docs/protocol-routing-and-merge.md`'s Mistral paragraph and its SDK table row, this plan's protocol row in the P-R1 design, the SDK table row in `README.md` and `README.zh-CN.md`, `test/protocols.test.ts`'s SDK-table case title and its `piKnows` assertion, which at most stays an informational pi characterization, and `test/catalog.test.ts`'s note that pi's data files the SDK as completions) are reworded to the plugin's own routing rule, since pi's catalog no longer decides a protocol (D26). | -| P8 | The health snapshot shows the catalog's `lastError` on the card. | +| P8 | The health snapshot shows the catalog's `lastError` on the card. From P5's review: every field of the row is `.volatile()`, and a Settings edit takes the loader's volatile-only path (`entry.update` → `_commitVolatile`, no remount), while `resolveConfig` unwraps each node once at apply; so no part of the plugin (the usage options, the fetch patch, the stream hook) follows a Settings edit until the plugin is applied again. P8 decides whether the settings table reads those values per call or says in the card that they apply on re-apply. | | P9 | A loopback triple guard, only if a `webServer` route appears. | | P10a | L1's per-call shaping is the first move of `fetch-patch` into `request/`. | | P10b | L12's test; optionally emit `llm/models-updated` on refresh, a new host event. | @@ -534,7 +540,7 @@ Two community plugins solve the same problem on the same host: [`FishBottle7/ope | # | Lesson | Evidence | Phase | Verdict | | :-- | :-- | :-- | :-- | :-- | -| L1 | **Shape plane traffic per call, not through the global fetch patch.** Put the derived session id, `x-opencode-client` and `x-opencode-project` into `options.headers`, and the core-tool fallback into `onPayload`, when wrapping each api in the plane provider; keep the fetch patch for URL-claimed relays, listing enrichment and P6's credential fill. Warn once when `globalThis.fetch` is no longer the patched function. | FB `packages/plugin/src/adapter/zen-adapter.ts` (`headers`, `onPayload`); FB `packages/plugin/src/pool/installer.ts` swaps `globalThis.fetch`, and our restore is identity-guarded. `docs/redirect-planes.md` claims the headers reach all four protocols; only the session header does. | P10a, P5 (the doc fix landed in P-R1, fa53d8e) | Adopt | +| L1 | **Shape plane traffic per call, not through the global fetch patch.** Put the derived session id, `x-opencode-client` and `x-opencode-project` into `options.headers`, and the core-tool fallback into `onPayload`, when wrapping each api in the plane provider; keep the fetch patch for URL-claimed relays, listing enrichment and P6's credential fill. Warn once when `globalThis.fetch` is no longer the patched function. | FB `packages/plugin/src/adapter/zen-adapter.ts` (`headers`, `onPayload`); FB `packages/plugin/src/pool/installer.ts` swaps `globalThis.fetch`, and our restore is identity-guarded. `docs/redirect-planes.md` claims the headers reach all four protocols; only the session header does. | P10a, P5 (the doc fix landed in P-R1, fa53d8e; P5's warn-once landed; per-call shaping is P10a's) | Adopt | | L2 | **Classify the gateway's refusals as non-retryable in `prepareCall`**, keeping status and body. The host maps 403 to `AUTH` and 429 to `RATE_LIMIT`, and retries a 429 five times. | SF `lib/errors.js`; FB `packages/plugin/src/adapter/events.ts`. FB's claim that `AUTH` makes the UI print "API key is invalid" is verified on both channels' `dsh-client-ui-chat` (`failureMessage`, `displayFailure`). | P-R3 | Adopted: `FREE_TIER` and `MODEL_UNAVAILABLE` on the planes | | L3 | **The free probe misreads the newer refusal.** A final 429 is always read as rate-limited; `429 FreeUsageLimitError` is the gate refusing, so it is its own verdict and is not retried. Also probe the root family `/inference/v1`. | SF `873e914` (the probe no longer counts a 429 as usable); FB `packages/plugin/src/adapter/catalog.ts` counts a 429 as a pass. | P-R2 | Adopt | | L4 | **Measure `x-opencode-request` before keeping it.** We send a minted `msg_` id, and the keyed probes omit it, so it has never been measured. | FB `packages/plugin/src/adapter/ids.ts` ("any value is refused"); SF removed it; `docs/upstream-findings.md` says 2.x dropped it. | P-R2 rows, then P6 or P10a | Measure; drop it or send it only where accepted | @@ -740,9 +746,20 @@ The vendor's own first-party DSH plugin, `@opencode/console` 0.1.10 (`anomalyco/ - `plane-refusals`: each refusal on Zen's four protocols and Go's completions finishes with its own code, the gateway's words and (but for Google) its status, after one request, outside the plane's retryable codes; a 401 and a plain 429 keep AUTH and RATE_LIMIT; - `request-shapes`: the toolless free completions turn carries `tool_choice: "none"`, an empty-schema `read` is replaced, a Responses turn gets no `tool_choice`. +**P5** + +- **Unit:** + - `remote-service` (new, a probe service on a bare `Context` and stand-in compositions, holding `src/host/remote.ts` at 100%): one activation mounts on the root and goes with it; overlapping activations share one service, reading the latest live one's options, in either disposal order; a reload mounts anew with the new options; the options view's traps; a refused mount reported, its failed fiber disposed once, the next activation retrying, and a release afterwards disposing nothing again; a throwing `plugin`, a `plugin` answering no fiber, no `plugin`, a contract naming no single service or namespace, no `effect`; every verification outcome after the mount settles, the binding checked against the contract's namespace and the service read by its key, and none for an activation gone before it did; frozen and non-configurable options read through the view, and a write, a define, a delete and a freeze refused with the reads unchanged after them; the last release pending until its dispose settles; a dispose that throws; + - `lifecycle`, each red on the base on its named assertion: a reload's `usageBaseURL`, two live activations' settings in turn, a disable withdrawing the three services (one activation and two), `usageEnabled: false` on reload, a refused mount on the activation's logger and never the console, no false "did not register" on a healthy boot, a composition with no `plugin` reported by each service and never provided (the flipped `KNOWN_DEFECT P5`), L1's one warning after `globalThis.fetch` is replaced, and `apply` still pending while a service's mount loads; + - `stream-hook`'s L1 describe, red on the base: the warning once, on decorated turns only; + - the guard of L1's nested-patch predicate, green on the base (it has no L1 warning) and red against a predicate that compares only the activation's own patch, landing in the change commit: `lifecycle`'s none for two live activations whose patches nest; + - the stream hook's own guard, `stream-hook`'s never while the patch is in place: the hook stays silent while the predicate it is handed reports the patch in place, so it goes red only for a hook that warns regardless of the predicate, never for a change to `lifecycle`'s predicate; green in behaviour on the base, but it does not type-check against the base's four-parameter `createStreamHook`, so it lands in the change commit; + - `lifecycle`'s three overlapping activations, the middle one disposed first and then the latest, whose remaining activation warns once on its next decorated turn although its patch still runs under the disposed one a stale restore put back (`KNOWN_DEFECT P6`, P6's to flip), red on the base on its warning assertion (the base logs no L1 warning; its header assertion passes there), landing in the change commit; +- **Pins kept, green on the base and unchanged:** `remote-contract` (all seven), `composition`'s four service cases, `lifecycle`'s usage cases (the once-per-root case gaining the purge and store assertions, and a reload keeping every service reachable), the services' own files, `reload` and the tier-B cases in `host-planes-foreign`. + **P6** -- **Unit:** apply A, apply B, dispose A, dispose B ends at the native `fetch`, in both orders (red on the base). +- **Unit:** apply A, apply B, dispose A, dispose B ends at the native `fetch`, in both orders (red on the base); with three activations, disposing the middle one and then the latest leaves the first one's patch current and raises no L1 warning, and disposing the first then ends at the native `fetch` (both of P5's `KNOWN_DEFECT P6` assertions in that case, flipped). ## End-to-end infrastructure diff --git a/docs/testing.md b/docs/testing.md index 797dc1d..16cad37 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -30,7 +30,7 @@ Properties the unit and mock suites keep by construction rather than by care: - **No socket in the unit run.** A case that binds a port, even on loopback, belongs to the mock suite (`test/mock/*.mock.ts`), which has the timeouts a socket needs. The offline guard admits loopback, so it cannot hold this; `test/architecture.test.ts` does, failing the build on a runtime import of a socket module (`node:http`, `node:net`, …) or a `createServer(`, `.listen(` or `startMockGateway(` call in any unit test file or any `test/` module one of them loads, with a planted row of each kind. - **Order-independent.** `test/support/reset-process-state.ts` clears the plugin's module-level stores (captured keys, session spend, the live catalog) after every test, and CI runs the suite shuffled (`pnpm run test:shuffle`; reproduce a failure with `--sequence.seed=`). A new module-level store gets a reset beside its siblings. - **Engine-checked.** The suite refuses to start below `engines.node` and names both versions (`test/support/node-engine.ts`), because below the floor `node:sqlite` and the ICU strings the tests pin differ, and the failures read like regressions. -- **Real contexts.** A service is built on `new Context()` with what it reads provided (`test/support/loader-rows.ts` serves loader rows and credentials there), and "the plugin, applied" is `mountPlugin` in `test/support/composition.ts`: the module's own `apply`, `Config`, `inject` and `name` mounted the way the Loader mounts them, a turn through `ctx.waterfall("llm/stream", …)`, teardown through `fiber.dispose()`. The one stand-in for the composition is `degradedHost`, for the compositions a real `Context` cannot be (a `plugin` that registers nothing; no `plugin` at all). The route claim keeps its own stand-in host (`test/support/route-claim-stand-in.ts`) for the fiber states a real host cannot be put in on demand. +- **Real contexts.** A service is built on `new Context()` with what it reads provided (`test/support/loader-rows.ts` serves loader rows and credentials there), and "the plugin, applied" is `mountPlugin` in `test/support/composition.ts`: the module's own `apply`, `Config`, `inject` and `name` mounted the way the Loader mounts them, a turn through `ctx.waterfall("llm/stream", …)`, teardown through `fiber.dispose()`. The one stand-in for the composition is `degradedHost`, for the compositions a real `Context` cannot be (a `plugin` that registers nothing or throws; a `plugin` whose fiber the case settles; no `plugin` at all). The route claim keeps its own stand-in host (`test/support/route-claim-stand-in.ts`) for the fiber states a real host cannot be put in on demand, and `test/remote-service.test.ts` keeps stand-in compositions for `installRemoteService`'s clauses (a `plugin` that throws, answers no fiber or answers a fiber the case settles; no `plugin`; no `effect`). - **Vendor-proof, where the helpers are used.** A catalog count or a vendor id breaks on the nightly refresh, so the catalog tests assert invariants (non-empty, unique ids, the generator's `is_free` rule, a paid row per served protocol, set differences between the planes), and a case that needs a row picks it by shape (`findCatalogRow`, `catalogRow`, `freeZenModel` in `test/support/catalogs.ts`) and reads its expected values off that row, or seeds a world of its own. This holds for the catalog tests, `tool-fallback`, `models-discovery` and the cases P-T2 and `main` brought; it is not yet true of every file. Older cases still look up a named id in the shipped tables with no skip (the Go allowance coverage case in `go-limits.test.ts`, the Gemini rows of `thinking-levels.test.ts`, `plane-model.test.ts`'s `specOf`), and converting them is an open follow-up. A case whose point IS a named id says so, and should skip by name once the catalog no longer carries it. ### Real host, in-process diff --git a/scripts/check.ts b/scripts/check.ts index f0d6ec4..993547f 100644 --- a/scripts/check.ts +++ b/scripts/check.ts @@ -168,7 +168,9 @@ try { logger: { info: () => {}, warn: () => {} }, }; m.apply(ctx, m.Config({})); - if (!calls.includes("GoUsageService")) throw new Error("GoUsageService was not registered"); + for (const service of ["GoUsageService", "ModelPurgeService", "OpencodeStoreService"]) { + if (!calls.includes(service)) throw new Error(service + " was not registered"); + } if (!calls.includes("llm/stream")) throw new Error("llm/stream was not registered"); process.stdout.write("ok"); `; diff --git a/src/host/remote.ts b/src/host/remote.ts new file mode 100644 index 0000000..f73e500 --- /dev/null +++ b/src/host/remote.ts @@ -0,0 +1,458 @@ +/** + * The one installer for the plugin's remote services: the usage meter's + * `opencodeGoUsage`, the card's `opencodeModelPurge` and its `opencodeStore` + * inspection. + * + * Three rules shape it, and each replaced a copy that got one of them wrong. + * + * - **A service mounts on the ROOT context.** The Gateway exposes a namespace + * it finds among the root's services, and the purge service reads its own + * context to decide which realm a profile belongs to (D27), so the service's + * context has to be the process's, not one activation's. + * - **One service per root, alive while a live activation holds it.** The host + * applies the plugin more than once at a time (two rows, or a reload), and a + * second `root.plugin(Service)` for a name still registered is refused as a + * duplicate; tied to the first activation, the service then went with it and + * left none. So the activations share one mount, the service reads the + * options of the most recently applied activation that is still live (a + * reload's new settings, and the remaining row's once the latest one goes), + * and the last activation to go disposes it: disabling the plugin withdraws + * it, and a reload, which unloads before it applies again, mounts it anew. + * The options reach the service as a view onto that activation's, so they + * stay current only for a value the service reads per call, as + * `GoUsageService` reads its base URL and key source; a value read at + * construction is the mounting activation's. + * - **Every outcome is reported, through the activation's own logger, after + * the mount has settled.** cordis loads a plugin after a microtask, so a + * check made right after `root.plugin` reads a service that does not exist + * yet. The check waits for the fiber, then reads the service back under its + * service key and says whether the Gateway can reach it: a `typertRemote` + * binding for the namespace the client contract calls. The key (`service`) + * and the wire namespace (`namespace`) are read from the contract apart, + * as the Gateway reads them, though all three contracts give both one name. + * + * There is no `ctx.provide` arm. A provided value is invisible to the Gateway, + * whose discovery needs a binding whose `service` is the registered original + * (`api/gateway/src/index.ts:385-396,1546-1559` in the harness), and + * constructing a `TypertRemoteService` needs `ctx.reflect`, which only a real + * `Context` has, and a real `Context` always has `plugin`. A composition + * without `plugin` is reported, service by service, and mounts nothing. + * + * Two separately installed copies of this module would keep two sets of slots; + * the second copy's mount is then refused as a duplicate, which is reported + * like any refused mount. The refused copy does not mount again when the + * other copy's row goes: disabling the row that mounted withdraws the + * service until the refused copy's own row is applied again. Two copies + * already collide on the route claim and the fetch patch, so this is a known + * limitation of an unsupported setup, not a case the installer retries. + * + * @module dsh-opencode-patch/host/remote + */ + +import type { CordisContext } from "../cordis-context.ts"; +import { isFunctionLike, isRecord } from "../utils/guards.ts"; + +/** + * The client's remote contribution, as far as the installer reads it: the + * service every descriptor calls (its Cordis key), the namespace the Gateway + * binds it under, and the methods it calls on it. `usageRemote`, + * `purgeRemote` and `storeRemote` satisfy it as they are. + */ +export interface RemoteContract { + readonly descriptors: readonly { + readonly service: string; + readonly namespace: string; + readonly method: string; + }[]; +} + +/** The one service and namespace a contract's descriptors all name. */ +interface Target { + readonly service: string; + readonly namespace: string; +} + +/** The fiber `root.plugin` answered: what waits for its load, and what unloads it. */ +interface StartedFiber { + readonly await: () => unknown; + readonly dispose: () => unknown; +} + +/** One activation holding a service. */ +interface Holder { + readonly ctx: CordisContext; + readonly options: object | undefined; + live: boolean; +} + +/** How a mount settled: loaded, or refused with its fiber's error. */ +type Outcome = + | { readonly failed: false } + | { + readonly failed: true; + readonly error: unknown; + readonly fiber: StartedFiber; + }; + +/** One `root.plugin` call, and the promise of how it settled. */ +interface Mount { + /** `undefined` when the composition's `plugin` answered no fiber. */ + readonly fiber: StartedFiber | undefined; + readonly settled: Promise; +} + +/** One service on one root: its live holders, in apply order, and its mount. */ +interface Slot { + readonly holders: Holder[]; + mount: Mount | undefined; +} + +/** + * The slots, per root and then per service key. Keyed weakly by the root, so a + * root nothing references any more takes its slots with it; a slot is deleted + * when its last holder goes. + */ +const slotsByRoot = new WeakMap>(); + +const PREFIX = "[dsh-opencode-patch]"; + +/** The distinct values one descriptor field takes across a contract. */ +const distinct = ( + contract: RemoteContract, + field: "namespace" | "service" +): string[] => [ + ...new Set(contract.descriptors.map((descriptor) => descriptor[field])), +]; + +/** + * The one service and the one namespace every descriptor names, or + * `undefined` when either is none or several. + */ +const targetOf = (contract: RemoteContract): Target | undefined => { + const [service, ...otherServices] = distinct(contract, "service"); + const [namespace, ...otherNamespaces] = distinct(contract, "namespace"); + return service === undefined || + namespace === undefined || + otherServices.length > 0 || + otherNamespaces.length > 0 + ? undefined + : { namespace, service }; +}; + +/** What the client finds missing when the service is: its calls, by name. */ +const tailOf = ({ namespace }: Target, contract: RemoteContract): string => + `the client's ${contract.descriptors + .map(({ method }) => `${namespace}.${method}`) + .join(", ")} will find nothing`; + +/** + * A fiber is an object with `await` and `dispose`; anything else is none. + * Both are called on the object they were read from. + */ +const asFiber = (value: unknown): StartedFiber | undefined => { + if (!isRecord(value)) { + return undefined; + } + const { await: settle, dispose } = value; + return isFunctionLike(settle) && isFunctionLike(dispose) + ? { + await: (): unknown => Reflect.apply(settle, value, []), + dispose: (): unknown => Reflect.apply(dispose, value, []), + } + : undefined; +}; + +/** + * The options of the latest live holder that has any, else the mounting + * activation's: a view's traps read through this on every access. + */ +const latestOptions = (slot: Slot, fallback: object): object => + slot.holders.findLast((holder) => holder.options !== undefined)?.options ?? + fallback; + +/** + * A view that reads the latest live holder's options, else `options` (the + * mounting activation's). Its target is a fresh empty object, so no Proxy + * invariant ties a read to a frozen or non-configurable holder's property. + * It reads through and refuses every write: a `set`, a define, a delete and a + * freeze each answer false (a TypeError at the write site in strict code), so + * the target stays empty and every read still answers the latest live + * holder's value. + */ +const optionsView = ( + slot: Slot, + options: Options +): Options => + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the empty target only anchors the traps; every read goes to a holder's options + new Proxy({} as Options, { + get: (_target, key) => { + const value: unknown = Reflect.get(latestOptions(slot, options), key); + return value; + }, + getOwnPropertyDescriptor: (_target, key) => { + const descriptor = Reflect.getOwnPropertyDescriptor( + latestOptions(slot, options), + key + ); + // Reported configurable: the empty target lacks every key, and a key + // the target lacks may only be reported as one. + return descriptor === undefined + ? undefined + : { ...descriptor, configurable: true }; + }, + has: (_target, key) => Reflect.has(latestOptions(slot, options), key), + ownKeys: () => Reflect.ownKeys(latestOptions(slot, options)), + // Refused, never forwarded: a write that reached the empty target would + // define a key the other traps then contradict. + defineProperty: () => false, + deleteProperty: () => false, + preventExtensions: () => false, + set: () => false, + }); + +/** Dispose a mount's fiber, reporting a dispose that throws rather than raising it. */ +const disposeFiber = async ( + fiber: StartedFiber, + holder: Holder, + service: string +): Promise => { + try { + await fiber.dispose(); + } catch (error) { + holder.ctx.logger?.warn?.( + `${PREFIX} ${service} did not dispose cleanly`, + error + ); + } +}; + +/** How a fiber settles, as a value: the promise never rejects. */ +const settle = async (fiber: StartedFiber): Promise => { + try { + await fiber.await(); + return { failed: false }; + } catch (error) { + return { error, failed: true, fiber }; + } +}; + +/** + * Start the service on the root. A synchronous throw is reported and leaves + * no mount, so a later activation tries again. + */ +const mount = ( + plugin: (...args: unknown[]) => unknown, + root: CordisContext, + args: readonly unknown[], + holder: Holder, + service: string +): Mount | undefined => { + let started: unknown; + try { + started = Reflect.apply(plugin, root, args); + } catch (error) { + holder.ctx.logger?.warn?.(`${PREFIX} ${service} did not mount`, error); + return undefined; + } + const fiber = asFiber(started); + if (fiber === undefined) { + // A composition whose `plugin` answers no fiber: nothing to wait for or + // to dispose, and the read-back below says what it registered. + return { fiber, settled: Promise.resolve({ failed: false }) }; + } + return { fiber, settled: settle(fiber) }; +}; + +/** + * Read the service back under its key and say whether the Gateway can reach + * it: a binding for the namespace the client calls, as the Gateway checks it. + */ +const verify = ( + root: CordisContext, + holder: Holder, + target: Target, + contract: RemoteContract +): void => { + const { logger } = holder.ctx; + const { namespace, service } = target; + let resolved: unknown; + try { + resolved = typeof root.get === "function" ? root.get(service) : undefined; + } catch { + // An unregistered name answers undefined on some hosts and throws on others. + resolved = undefined; + } + if (!isRecord(resolved)) { + logger?.warn?.( + `${PREFIX} ${service} did not register: ctx.get('${service}') answered nothing`, + tailOf(target, contract) + ); + return; + } + const binding: unknown = resolved.typertRemote; + if (!isRecord(binding)) { + logger?.warn?.( + `${PREFIX} ${service} registered but carries no typertRemote binding`, + "the Gateway's source-mode discovery will not find it; ctx.provide() publishes a value, not a Service", + tailOf(target, contract) + ); + return; + } + if (binding.namespace !== namespace) { + logger?.warn?.( + `${PREFIX} ${service} is bound for namespace ${String(binding.namespace)}, not ${namespace}, the one the client calls` + ); + return; + } + logger?.info?.( + `${PREFIX} ${service} is reachable with a typertRemote binding for namespace %s`, + namespace + ); +}; + +/** + * Once the mount has settled: say nothing for an activation that has gone, + * report a refused mount (and dispose its failed fiber, so a later activation + * mounts anew), and otherwise verify the service. + */ +const report = async ( + root: CordisContext, + slot: Slot, + current: Mount, + holder: Holder, + target: Target, + contract: RemoteContract +): Promise => { + const { service } = target; + const outcome = await current.settled; + if (!holder.live) { + return; + } + if (outcome.failed) { + holder.ctx.logger?.warn?.( + `${PREFIX} ${service} did not mount`, + outcome.error + ); + if (slot.mount === current) { + slot.mount = undefined; + await disposeFiber(outcome.fiber, holder, service); + } + return; + } + verify(root, holder, target, contract); +}; + +/** + * Hold the service for one activation: mount it on the root unless a live + * activation already has, and answer the report and the release. + */ +const acquire = ( + plugin: (...args: unknown[]) => unknown, + root: CordisContext, + Service: unknown, + holder: Holder, + target: Target, + contract: RemoteContract +): { + readonly reported: Promise; + readonly release: () => Promise; +} => { + const { service } = target; + let slots = slotsByRoot.get(root); + if (slots === undefined) { + slots = new Map(); + slotsByRoot.set(root, slots); + } + let slot = slots.get(service); + if (slot === undefined) { + slot = { holders: [], mount: undefined }; + slots.set(service, slot); + } + slot.holders.push(holder); + slot.mount ??= mount( + plugin, + root, + holder.options === undefined + ? [Service] + : [Service, optionsView(slot, holder.options)], + holder, + service + ); + const held = slot; + const owned = slots; + return { + // Synchronous up to the dispose, and returned, so cordis awaits the + // dispose in the activation's unload: a reload then finds no slot and + // mounts anew. + release: async () => { + holder.live = false; + held.holders.splice(held.holders.indexOf(holder), 1); + if (held.holders.length > 0) { + return; + } + owned.delete(service); + const fiber = held.mount?.fiber; + held.mount = undefined; + if (fiber !== undefined) { + await disposeFiber(fiber, holder, service); + } + }, + reported: + held.mount === undefined + ? Promise.resolve() + : report(root, held, held.mount, holder, target, contract), + }; +}; + +/** + * Mount a remote service on the root for this activation, as described in the + * module doc. + * + * @param ctx - the activation's context; its logger carries every report. + * @param Service - the service class (a `TypertRemoteService`). + * @param contract - the client's contribution: names the service and its + * namespace, and the calls a report says will find nothing. + * @param options - the service's constructor options, if it takes any. + * @returns a promise that settles once the mount has been checked and + * reported; it never rejects. + */ +export const installRemoteService = ( + ctx: CordisContext, + Service: new (ctx: never, options?: Options) => unknown, + contract: RemoteContract, + options?: Options +): Promise => { + const target = targetOf(contract); + if (target === undefined) { + ctx.logger?.warn?.( + `${PREFIX} a remote contract naming no single service and namespace was not mounted`, + distinct(contract, "service"), + distinct(contract, "namespace") + ); + return Promise.resolve(); + } + const { service } = target; + const root: CordisContext = ctx.root ?? ctx; + const { plugin } = root; + if (!isFunctionLike(plugin)) { + ctx.logger?.warn?.( + `${PREFIX} ${service} did not mount: this composition has no ctx.plugin`, + tailOf(target, contract) + ); + return Promise.resolve(); + } + const holder = { ctx, live: true, options }; + let reported: Promise = Promise.resolve(); + const hold = (): (() => unknown) => { + const held = acquire(plugin, root, Service, holder, target, contract); + ({ reported } = held); + return held.release; + }; + if (isFunctionLike(ctx.effect)) { + ctx.effect(hold, `dsh-opencode-patch.remote.${service}`); + } else { + // A composition with no effects has no unload either: the hold is for good. + hold(); + } + return reported; +}; diff --git a/src/lifecycle.ts b/src/lifecycle.ts index 8e4b1fe..b07adb4 100644 --- a/src/lifecycle.ts +++ b/src/lifecycle.ts @@ -9,9 +9,9 @@ * | {@link installRouteClaim} | own the `opencode`/`opencode-go` routes (D13) | * | {@link installDescribeGraft} | keep Settings showing the planes (D18) | * | {@link logRenameNotice} | one-time notice for legacy rows | - * | {@link installUsageService} | register the Go quota service + remotes | - * | {@link installFetchPatch} | wrap `globalThis.fetch` (returns `false` if none) | - * | {@link installStreamHook} | capture turn state on `llm/stream` | + * | {@link installRemoteServices} | the usage, purge and store-inspection services, through {@link installRemoteService} | + * | {@link installFetchPatch} | wrap `globalThis.fetch` (returns `undefined` if none) | + * | {@link installStreamHook} | capture turn state on `llm/stream`; warn once if fetch was replaced (L1) | * | {@link installDiscovery} | complement Settings' model discovery with the catalog (D3) | * * Order is load-bearing and documented at the call site in {@link apply}. @@ -31,20 +31,34 @@ import { recordDebug } from "./debug.ts"; import { patchFetch } from "./fetch-patch.ts"; import { resolveGoBaseURL } from "./go-discovery.ts"; import { installDescribeGraft } from "./host/describe-graft.ts"; +import { installRemoteService } from "./host/remote.ts"; import { installRouteClaim } from "./host/route-claim.ts"; import { LEGACY_NAME, LEGACY_PKG } from "./identity.ts"; import { decorateModelDiscovery, type DiscoveryClaim, } from "./models-discovery.ts"; -import { STORE_NAMESPACE } from "./opencode-store-contract.ts"; +import { storeRemote } from "./opencode-store-contract.ts"; import { OpencodeStoreService } from "./opencode-store-service.ts"; +import { purgeRemote } from "./purge-contract.ts"; import { ModelPurgeService } from "./purge-service.ts"; import { createStreamHook } from "./stream-hook.ts"; import type { ActiveTurnState } from "./turn-store.ts"; +import { usageRemote } from "./usage-contract.ts"; import { GoUsageService } from "./usage.ts"; import { isFetchFunction, isFunctionLike, isRecord } from "./utils/guards.ts"; +/** + * The fetch patches of this module's live activations. Two live activations + * nest (the later one wraps the earlier one's patch), so an activation's own + * patch still runs while `globalThis.fetch` is ANY of these; a function outside + * the set reads as a replacement (L1). One such function still calls through: + * a disposed patch that a later activation's restore put back (P6's stale + * restore, which takes three overlapping activations on one root, the middle + * one disposed first), so that case warns although the patch runs. + */ +const livePatches = new Set(); + /** Tell a row still configured under the pre-rename id/package to update. */ const logRenameNotice = (ctx: CordisContext): void => { const entryOptions = readEntryOptions(ctx); @@ -56,257 +70,63 @@ const logRenameNotice = (ctx: CordisContext): void => { }; /** - * Publish a service through `ctx.provide`, when `ctx.plugin` is not available. - * - * The fallback exists because a service the client cannot reach has no way to say - * so from the host: the installers report through `ctx.logger`, and a host that - * writes no log file turns every one of those into a silence. `provide` reaches - * the same lookup under the same name, which is the half that matters. - * - * @param ctx - the host context. - * @param name - the service name the client looks for. - * @param Service - the service class. - * @param options - its constructor options. - */ -const mountByProvide = ( - ctx: CordisContext, - serviceName: string, - Service: new (ctx: never, options: never) => unknown, - options?: unknown -): void => { - const { provide } = ctx as { - provide?: (key: string, value: unknown) => void; - }; - if (typeof provide !== "function") { - ctx.logger?.warn?.( - `[dsh-opencode-patch] ${serviceName} did not mount: this composition has neither ctx.plugin nor ctx.provide` - ); - return; - } - try { - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the constructors take cordis `Context`, whose types ship only from the Harness installation. - provide.call(ctx, serviceName, new Service(ctx as never, options as never)); - } catch (error) { - ctx.logger?.warn?.( - `[dsh-opencode-patch] ${serviceName} did not mount`, - error - ); - } -}; - -/** - * Roots that already carry the usage service. - * - * Keyed on the ROOT context rather than the activation, so a live reload neither - * registers a duplicate nor loses the one it has. See {@link installUsageService} - * for the measured failure this prevents. - */ -const usageMountedOn = new WeakSet(); - -/** - * Register the Go quota service and its Typert remote face. + * Mount the usage meter's, the purge row's and the store inspection's remote + * services on the root (see {@link installRemoteService}), in that order. * * Runs before the fetch patch so a composition without `globalThis.fetch` * still gets a working meter. + * + * @returns each mount's report, settled once the mount has been checked; none + * rejects. */ -const installUsageService = ( +const installRemoteServices = ( ctx: CordisContext, config: ResolvedPluginConfig -): void => { - if (!config.usageEnabled) { +): Promise[] => { + const reports: Promise[] = []; + if (config.usageEnabled) { + reports.push( + installRemoteService(ctx, GoUsageService, usageRemote, { + baseURL: () => resolveGoBaseURL(ctx, config.usageBaseURL), + keySource: config.keySource, + // The installation's own store, last and only when the user turned it on. + readOpencodeStore: config.readOpencodeStore, + }) + ); + } else { ctx.logger?.info?.( "[dsh-opencode-patch] usage service NOT registered; the meter will render nothing" ); - return; } - const usageOptions = { - baseURL: () => resolveGoBaseURL(ctx, config.usageBaseURL), - keySource: config.keySource, - // The installation's own store, last and only when the user turned it on. - readOpencodeStore: config.readOpencodeStore, - }; - if (typeof ctx.plugin !== "function") { - // `ctx.plugin` is the documented way and the one that gets cordis's own - // bookkeeping. A composition without it is not one to give up on, though: - // `ctx.provide` publishes the same service under the same name, which is what - // the client looks for - and a meter that cannot mount is otherwise a - // silence with no way to tell it from a meter whose service answered - // nothing. - mountByProvide(ctx, "opencodeGoUsage", GoUsageService, usageOptions); - return; - } - // **Registered on the ROOT context, once per process.** - // - // Measured, and it is the whole of "the usage meter is not rendering: no usage - // service: remote.opencodeGoUsage.read is missing": the host applies this - // plugin more than once (a live reload does it on every patch), and the second - // activation's `ctx.plugin(GoUsageService, …)` was refused as a duplicate - // because the first activation's service was still alive. Then the first - // fiber disposed — taking its service with it — and the second registration - // had already been lost. Net result: no service at all, and the Gateway has - // nothing to expose. - // - // Reproduced on a real cordis: apply → `present`, apply again → - // `present`, dispose the first then re-apply → `MISSING`. - // - // Tied to the activation fiber, the service cannot survive that ordering. On - // the root it belongs to the process, which is what a Gateway-scanned service - // should be: `read()` resolves the credential per call, so a long-lived - // instance holds no state that a reload could stale. The cost is that turning - // `usageEnabled` off needs a process restart rather than a reload; a meter - // that disappears and never comes back is the worse trade. - const root: CordisContext = ctx.root ?? ctx; - if (usageMountedOn.has(root)) { - verifyUsageService(root); - return; - } - usageMountedOn.add(root); - // A throw here is not local: `apply` runs its installers in order, so an - // unguarded throw takes every installer AFTER this one down with it - silently, - // because nothing catches it either. Reported and contained instead, so a - // meter that cannot mount is a meter that says so rather than one that also - // takes the rest of the plugin with it. - try { - root.plugin?.(GoUsageService, usageOptions); - } catch (error) { - usageMountedOn.delete(root); - ctx.logger?.warn?.( - "[dsh-opencode-patch] the usage service did not mount; the meter will render nothing", - error - ); - } - verifyUsageService(root); -}; - -/** - * Confirm the usage service is REACHABLE from the context, and say so either way. - * - * The client reported, from a browser console: - * - * the usage meter is not rendering: no usage service: remote.opencodeGoUsage.read is missing - * - * That names the client's half as fine (the contribution mounts, the descriptor - * names `service: "opencodeGoUsage"`). The Gateway exposes a namespace from the - * host's registry, so what is missing is a Service it can find — and there are - * three ways that happens with nothing in the client to show it: - * - * 1. the host never registered it (this installer threw, or `usageEnabled` is off - * in the row the host actually loaded); - * 2. it registered under another key; - * 3. it registered as a VALUE (`ctx.provide`) rather than a Service, and the - * Gateway's "source-mode discovery" scans services, so a provided value is - * invisible to it even though `ctx.get` can see it. - * - * Cases 2 and 3 are indistinguishable from the client and from here, so this reads - * the service back through the same context and reports what it found: whether it - * resolved, and whether it carries the `typertRemote` binding the Gateway scans - * for. One line, once per process, so the next console says which case it is - * instead of a meter that is merely absent. - */ -const verifyUsageService = (ctx: CordisContext): void => { - let resolved: unknown; - try { - resolved = - typeof ctx.get === "function" ? ctx.get("opencodeGoUsage") : undefined; - } catch { - // An unregistered name answers undefined on some hosts and throws on others. - resolved = undefined; - } - if (!isRecord(resolved)) { - ctx.logger?.warn?.( - "[dsh-opencode-patch] the usage service did not register: ctx.get('opencodeGoUsage') answered nothing", - "the meter will render nothing with 'no usage service'" - ); - return; - } - const binding: unknown = resolved.typertRemote; - if (!isRecord(binding)) { - ctx.logger?.warn?.( - "[dsh-opencode-patch] the usage service registered but carries no typertRemote binding", - "the Gateway's source-mode discovery will not find it; ctx.provide() publishes a value, not a Service", - "the meter will render nothing with 'no usage service'" - ); - return; - } - ctx.logger?.info?.( - "[dsh-opencode-patch] the usage service is reachable with a typertRemote binding for namespace %s", - typeof binding.namespace === "string" ? binding.namespace : "(unnamed)" + reports.push( + installRemoteService(ctx, ModelPurgeService, purgeRemote), + installRemoteService(ctx, OpencodeStoreService, storeRemote) ); -}; - -/** - * Roots that already carry the purge service, for the same reason the usage - * service keeps its own set: `apply` runs once per live reload, and a service - * tied to the activation fiber dies with it while a second registration is - * refused as a duplicate. Process-scoped is what a Gateway-scanned service wants. - */ -const purgeMountedOn = new WeakSet(); - -const installPurgeService = (ctx: CordisContext): void => { - const root: CordisContext = ctx.root ?? ctx; - if (purgeMountedOn.has(root)) { - return; - } - purgeMountedOn.add(root); - if (typeof ctx.plugin !== "function") { - mountByProvide(ctx, "opencodeModelPurge", ModelPurgeService); - return; - } - try { - root.plugin?.(ModelPurgeService); - } catch (error) { - purgeMountedOn.delete(root); - // Not fatal, and said out loud: without this service the card's purge row - // reports that it is unavailable rather than failing the whole plugin. - console.warn( - "[dsh-opencode-patch] the purge service did not mount; the card will say so", - error - ); - } -}; - -const storeMountedOn = new WeakSet(); - -const installStoreService = (ctx: CordisContext): void => { - const root: CordisContext = ctx.root ?? ctx; - if (storeMountedOn.has(root)) { - return; - } - storeMountedOn.add(root); - if (typeof ctx.plugin !== "function") { - mountByProvide(ctx, STORE_NAMESPACE, OpencodeStoreService); - return; - } - try { - root.plugin?.(OpencodeStoreService); - } catch (error) { - storeMountedOn.delete(root); - console.warn( - "[dsh-opencode-patch] the opencode store inspection service did not mount", - error - ); - } + return reports; }; /** * Wrap `globalThis.fetch` so claimed OpenCode traffic carries session affinity * and the gateway headers, restoring it on dispose. * - * @returns `false` when there is no fetch to wrap. The caller then stops: the - * stream hook and discovery must not attach, or turns would run without the - * session affinity this plugin exists to provide. + * @returns `undefined` when there is no fetch to wrap. The caller then stops: + * the stream hook and discovery must not attach, or turns would run without + * the session affinity this plugin exists to provide. Otherwise, whether + * `globalThis.fetch` is still a live patch of this module's, this + * activation's or one that wraps it (L1), which the stream hook reads per + * decorated turn. */ const installFetchPatch = ( ctx: CordisContext, config: ResolvedPluginConfig, als: AsyncLocalStorage -): boolean => { +): (() => boolean) | undefined => { const originalFetch: unknown = globalThis.fetch; if (!isFetchFunction(originalFetch)) { ctx.logger?.warn?.( "[dsh-opencode-patch] globalThis.fetch is unavailable; cannot inject x-opencode-session" ); - return false; + return undefined; } const patched = patchFetch(originalFetch, als, config, { @@ -339,6 +159,7 @@ const installFetchPatch = ( ctx.effect?.(() => { globalThis.fetch = patched; + livePatches.add(patched); // Console as well as the logger: the logger's default exporter only buffers, // so a boot line that says "the plugin loaded" has to reach stdout to be worth // anything when someone is checking whether a build was picked up. @@ -348,28 +169,33 @@ const installFetchPatch = ( )}]` ); return () => { + livePatches.delete(patched); if (globalThis.fetch === patched) { globalThis.fetch = originalFetch; } }; }, "dsh-opencode-patch.fetch-patch"); - return true; + return () => livePatches.has(globalThis.fetch); }; /** * Capture turn state (session id, project, parent) on every streamed turn, - * except a turn on a plane the route claim left to another plugin (D27). + * except a turn on a plane the route claim left to another plugin (D27), and + * warn once if a decorated turn finds the fetch patch replaced (L1). */ const installStreamHook = ( ctx: CordisContext, config: ResolvedPluginConfig, als: AsyncLocalStorage, - steppedAside: () => ReadonlySet + steppedAside: () => ReadonlySet, + fetchInPlace: () => boolean ): void => { - ctx.on?.("llm/stream", createStreamHook(ctx, config, als, steppedAside), { - prepend: true, - }); + ctx.on?.( + "llm/stream", + createStreamHook(ctx, config, als, steppedAside, fetchInPlace), + { prepend: true } + ); }; /** @@ -395,7 +221,7 @@ const installDiscovery = ( }; /** - * Plugin entry: claim the plane routes, register the quota service, patch + * Plugin entry: claim the plane routes, mount the remote services, patch * `fetch` for OpenCode traffic, and capture turn state for configured * providers. * @@ -404,8 +230,8 @@ const installDiscovery = ( * last, and its `internal/config` listener is in place before any pi-ai * config run this activation can see; * 2. the Settings graft, which reads the claim's committed entries; - * 3. the rename notice and the services, before the fetch patch, so a - * composition without `globalThis.fetch` still gets a working meter; + * 3. the rename notice and the remote services, before the fetch patch, so + * a composition without `globalThis.fetch` still gets a working meter; * 4. the fetch patch, which bails out (warning) when there is nothing to * wrap — the stream hook must not be attached then, or turns would run * without session affinity; @@ -413,9 +239,10 @@ const installDiscovery = ( * plane's fetch with the catalog (D3), which only make sense once the * request path is patched. * - * The returned promise is the claim's mount: cordis waits for it, which is - * what orders a pi-ai restart against the rest of the boot (F17). It never - * rejects. + * The returned promise is the claim's mount and the remote services' + * reports: cordis waits for it, which is what orders a pi-ai restart against + * the rest of the boot (F17), and each service is checked once its mount has + * settled. It never rejects. * * @param ctx - the Cordis host context. * @param rawConfig - the row config, if any. @@ -430,13 +257,12 @@ export const apply = async ( const claim = installRouteClaim(ctx, config); installDescribeGraft(ctx, claim); logRenameNotice(ctx); - installUsageService(ctx, config); - installPurgeService(ctx); - installStoreService(ctx); + const services = installRemoteServices(ctx, config); - if (installFetchPatch(ctx, config, als)) { - installStreamHook(ctx, config, als, claim.steppedAside); + const fetchInPlace = installFetchPatch(ctx, config, als); + if (fetchInPlace !== undefined) { + installStreamHook(ctx, config, als, claim.steppedAside, fetchInPlace); installDiscovery(ctx, config, claim); } - await claim.mounted; + await Promise.all([claim.mounted, ...services]); }; diff --git a/src/stream-hook.ts b/src/stream-hook.ts index 1cfa199..e425d31 100644 --- a/src/stream-hook.ts +++ b/src/stream-hook.ts @@ -111,6 +111,7 @@ export const createStreamHook = ( ctx: DebugContext & { logger?: { info?: (msg: string, ...args: unknown[]) => void; + warn?: (msg: string, ...args: unknown[]) => void; }; }, config: ResolvedPluginConfig, @@ -121,9 +122,19 @@ export const createStreamHook = ( * decorated or priced; its requests run under a turn marked `foreign`, * which the fetch patch passes through untouched. Read per turn. */ - steppedAside: () => ReadonlySet = () => new Set() + steppedAside: () => ReadonlySet = () => new Set(), + /** + * Whether `globalThis.fetch` is still a live fetch patch of the plugin's, + * this activation's own or a later one wrapping it (L1): the predicate its + * installer returns, read per decorated turn. A turn decorated after + * something replaced it carries the session and gateway headers only if the + * replacement calls through, so the hook says so, once. A disposed patch + * that P6's stale restore put back reads as a replacement too. + */ + fetchInPlace: () => boolean = () => true ): StreamHandler => { const { debug, debugFile, providers } = config; + let fetchReplacedWarned = false; return (options: unknown, next: () => unknown): unknown => { if (!isStreamOptions(options)) { @@ -150,6 +161,12 @@ export const createStreamHook = ( ) : foreign; } + if (!fetchReplacedWarned && !fetchInPlace()) { + fetchReplacedWarned = true; + ctx.logger?.warn?.( + "[dsh-opencode-patch] globalThis.fetch is no longer the function this plugin installed: a turn's OpenCode requests carry the session and gateway headers only if whatever replaced it calls through to it" + ); + } const sessionProp: unknown = options.sessionId; // The session is looked up FIRST: its `createdAt` is what lets the derived diff --git a/src/usage-contract.ts b/src/usage-contract.ts index 862a6be..4ba52d5 100644 --- a/src/usage-contract.ts +++ b/src/usage-contract.ts @@ -219,9 +219,10 @@ declare module "@deepseek-ai/dsh-typert-protocol" { * `dsh-opencode-go` owns an `opencodeGoUsage` contract of its own and its * account page calls `readAccount(ref)`; this plugin mounts the * root-context instance the Typert gateway resolves for that namespace - * (see `installUsageService`), so a namespace offering only `read` answers - * that page with `active Service "opencodeGoUsage" has no callable method - * "readAccount"` and the account row reads as unavailable. + * (see `installRemoteService` in `src/host/remote.ts`), so a namespace + * offering only `read` answers that page with `active Service + * "opencodeGoUsage" has no callable method "readAccount"` and the + * account row reads as unavailable. */ readAccount: (ref: string) => Promise>; }; diff --git a/src/usage.ts b/src/usage.ts index ee23d53..ad8e9c7 100644 --- a/src/usage.ts +++ b/src/usage.ts @@ -452,7 +452,8 @@ export class GoUsageService extends TypertRemoteService { * * Both plugins mount a remote service under `opencodeGoUsage`, and the Typert * gateway resolves the ROOT-context one — this instance, because - * `installUsageService` mounts it on the root on purpose. That page calls + * `installRemoteService` (`src/host/remote.ts`) mounts it on the root, while + * a live activation holds it, on purpose. That page calls * `readAccount(ref)` against the namespace, so a namespace offering only * `read` fails it with `active Service "opencodeGoUsage" has no callable * method "readAccount"` while the meter itself keeps working. diff --git a/test/lifecycle.test.ts b/test/lifecycle.test.ts index 47251a6..9967460 100644 --- a/test/lifecycle.test.ts +++ b/test/lifecycle.test.ts @@ -7,9 +7,11 @@ * plugin's effects run and are undone as a deployment runs and undoes them. * Only the degraded-composition diagnostics keep a stand-in (`degradedHost`), * because a real `Context` registers what it is asked to and always has - * `plugin`: a `plugin` that registers nothing, with and without `get` (the - * three unreachable-service diagnostics), and a composition with no `plugin`, - * or with neither `plugin` nor `provide`. + * `plugin`: a `plugin` that registers nothing or throws, with and without + * `get` (the three unreachable-service diagnostics, and a refused mount), a + * `plugin` whose fiber the case settles (`apply` waiting for the reports), + * and a composition with no `plugin` (with or without `provide`, which is + * never called). * * @module test/lifecycle.test */ @@ -19,7 +21,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import type { Context } from "@deepseek-ai/cordis"; +import { Context } from "@deepseek-ai/cordis"; import { afterEach, describe, expect, it, vi } from "vitest"; import { seedDivergentCatalog, ZEN_FREE_PROBE } from "./support/catalogs.ts"; @@ -190,7 +192,8 @@ describe("apply (plugin lifecycle)", () => { // remote.opencodeGoUsage.read is missing" // // The reproduction, on real cordis: apply, apply again on the same root, - // dispose the first. On the root, once per process, the service survives. + // dispose the first. On the root, the service outlives the first + // activation while the second still holds it. vi.stubGlobal("fetch", replacementFetch); const { ctx, fiber: first } = await mountPlugin({ config: { usageEnabled: true }, @@ -205,6 +208,20 @@ describe("apply (plugin lifecycle)", () => { | undefined; expect(usage, "the reload lost the usage service").toBeDefined(); expect(typeof usage?.read).toBe("function"); + // The purge and store-inspection services mount the same way, and survive + // the same ordering. + const purge = ctx.get("opencodeModelPurge" as never) as + | { purge?: unknown } + | undefined; + expect(typeof purge?.purge, "the reload lost the purge service").toBe( + "function" + ); + const store = ctx.get(STORE_NAMESPACE as never) as + | { inspect?: unknown } + | undefined; + expect(typeof store?.inspect, "the reload lost the store service").toBe( + "function" + ); await second.dispose(); // KNOWN_DEFECT P6: disposing the first activation while the second's patch @@ -216,6 +233,28 @@ describe("apply (plugin lifecycle)", () => { vi.unstubAllGlobals(); }); + it("a reload keeps every remote service reachable", async () => { + // A reload is `fiber.update`: the activation unloads and applies again with + // the new config. Each of the three services answers once it resolves. + vi.stubGlobal("fetch", replacementFetch); + const { ctx, fiber } = await mountPlugin({ + config: { usageEnabled: true }, + }); + await fiber.update({ debug: true, usageEnabled: true }); + const usage = ctx.get("opencodeGoUsage" as never) as + | { read?: unknown } + | undefined; + const purge = ctx.get("opencodeModelPurge" as never) as + | { purge?: unknown } + | undefined; + const store = ctx.get(STORE_NAMESPACE as never) as + | { inspect?: unknown } + | undefined; + expect(typeof usage?.read, "usage").toBe("function"); + expect(typeof purge?.purge, "purge").toBe("function"); + expect(typeof store?.inspect, "store").toBe("function"); + }); + it("the usage service reads the Go usage endpoint of the composition", async () => { // The service's base URL is a closure over the composition's config, read // per call: with no Go row and no `usageBaseURL` it is the plane's @@ -337,60 +376,426 @@ describe("apply (plugin lifecycle)", () => { ).toBe(true); }); - it("survives a composition with neither mount path, and reports it (KNOWN_DEFECT P5: the provide fallback never publishes)", async () => { - // With no `ctx.plugin`, each installer takes the `ctx.provide` fallback — - // and today that fallback cannot publish anything: it constructs the - // service first, and a service's constructor registers itself through - // `ctx.reflect`, which only a real cordis context has (and a real context - // always has `plugin`). So on this stand-in each service reports, by its - // own name, that it did not mount, `provide` is never called, and `apply` - // carries on without throwing. P5 (`installRemoteService`) either drops the - // provide arm or makes it publish; the empty list below is what it flips. + it("reports a composition with no ctx.plugin by each service's name, and never provides one", async () => { + // A service the Gateway can find is a mounted Service with a + // `typertRemote` binding; a value published through `ctx.provide` is + // invisible to its discovery, and constructing a service needs + // `ctx.reflect`, which only a real context has (and a real context always + // has `plugin`). So a composition without `plugin` mounts nothing and + // says so, service by service, whether or not it has `provide` — and + // `apply` carries on without throwing. const warned: string[] = []; const logger = { warn: (...args: unknown[]) => warned.push(args.map(String).join(" ")), }; const services = ["opencodeGoUsage", "opencodeModelPurge", STORE_NAMESPACE]; const provided: string[] = []; - const withProvide = degradedHost({ + for (const host of [ + degradedHost({ + logger, + provide: (name) => { + provided.push(name); + }, + }), + degradedHost({ logger }), + ]) { + warned.length = 0; + try { + await expect( + apply(host.ctx, { usageEnabled: true }) + ).resolves.toBeUndefined(); + } finally { + host.dispose(); + } + for (const service of services) { + expect(warned, service).toContainEqual( + expect.stringContaining( + `${service} did not mount: this composition has no ctx.plugin` + ) + ); + } + expect(warned.join(" ")).not.toContain("neither ctx.plugin"); + } + expect(provided).toEqual([]); + }); + + it("the usage service reads the settings of the latest reload, not the first", async () => { + // The service used to keep the options of the activation that first + // mounted it, so a changed `usageBaseURL` took effect only after `dsh` + // restarted. A reload now reads the reloaded row's. + vi.stubEnv("OPENCODE_GO_API_KEY", "sk-go-usage-test"); + const urls: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + urls.push(urlOf(input)); + return Response.json({}); + }) + ); + const { ctx, fiber } = await mountPlugin({ + config: { + usageBaseURL: "https://relay-a.example/zen/go/v1", + usageEnabled: true, + }, + }); + await fiber.update({ + usageBaseURL: "https://relay-b.example/zen/go/v1", + usageEnabled: true, + }); + const usage = ctx.get("opencodeGoUsage" as never) as unknown as { + read: () => Promise; + }; + // The stub answers no usage windows, so the read itself fails; what is + // asserted is where it went. + await expect(usage.read()).rejects.toThrow(/usage response/u); + expect(urls, urls.join(", ")).toContain( + "https://relay-b.example/zen/go/v1/usage" + ); + expect(urls, urls.join(", ")).not.toContain( + "https://relay-a.example/zen/go/v1/usage" + ); + }); + + it("two live activations: the usage service reads the latest one's settings, then the remaining one's", async () => { + vi.stubEnv("OPENCODE_GO_API_KEY", "sk-go-usage-test"); + const urls: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + urls.push(urlOf(input)); + return Response.json({}); + }) + ); + const { ctx } = await mountPlugin({ + config: { + usageBaseURL: "https://relay-a.example/zen/go/v1", + usageEnabled: true, + }, + }); + const { fiber: second } = await mountPlugin({ + config: { + usageBaseURL: "https://relay-b.example/zen/go/v1", + usageEnabled: true, + }, + ctx, + }); + const usage = (): { + read: () => Promise; + typertRemote: unknown; + } => + ctx.get("opencodeGoUsage" as never) as unknown as { + read: () => Promise; + typertRemote: unknown; + }; + await expect(usage().read()).rejects.toThrow(/usage response/u); + expect(urls, urls.join(", ")).toEqual([ + "https://relay-b.example/zen/go/v1/usage", + ]); + const binding = usage().typertRemote; + + // Latest first, so the fetch patches unwind cleanly (the other order is + // P6's). The service is the same one, now reading the remaining row's. + urls.length = 0; + await second.dispose(); + expect(usage().typertRemote, "the same service").toBe(binding); + await expect(usage().read()).rejects.toThrow(/usage response/u); + expect(urls, urls.join(", ")).toEqual([ + "https://relay-a.example/zen/go/v1/usage", + ]); + }); + + it("disabling the plugin withdraws its three remote services", async () => { + const namespaces = [ + "opencodeGoUsage", + "opencodeModelPurge", + STORE_NAMESPACE, + ]; + // Read as booleans: a live service is a cordis proxy, which the + // assertion's diff cannot print. + const present = (ctx: Context): boolean[] => + namespaces.map((namespace) => ctx.get(namespace as never) !== undefined); + + const { ctx, fiber } = await mountPlugin({ + config: { usageEnabled: true }, + }); + expect(present(ctx)).toEqual([true, true, true]); + await fiber.dispose(); + expect(present(ctx)).toEqual([false, false, false]); + + // Two overlapping activations: the services go with the last of them. + const { ctx: shared, fiber: first } = await mountPlugin({ + config: { usageEnabled: true }, + }); + const { fiber: second } = await mountPlugin({ + config: { usageEnabled: true }, + ctx: shared, + }); + await second.dispose(); + expect(present(shared)).toEqual([true, true, true]); + await first.dispose(); + expect(present(shared)).toEqual([false, false, false]); + }); + + it("turning usageEnabled off takes effect on reload", async () => { + const { ctx, fiber } = await mountPlugin({ + config: { usageEnabled: true }, + }); + await fiber.update({ usageEnabled: false }); + expect( + ctx.get("opencodeGoUsage" as never) === undefined, + "the usage service outlived usageEnabled: false" + ).toBe(true); + const purge = ctx.get("opencodeModelPurge" as never) as + | { purge?: unknown } + | undefined; + const store = ctx.get(STORE_NAMESPACE as never) as + | { inspect?: unknown } + | undefined; + expect(typeof purge?.purge).toBe("function"); + expect(typeof store?.inspect).toBe("function"); + + await fiber.update({ usageEnabled: true }); + const usage = ctx.get("opencodeGoUsage" as never) as + | { read?: unknown } + | undefined; + expect(typeof usage?.read).toBe("function"); + }); + + it("reports a refused mount through the activation's logger, never the console", async () => { + // (a) A real context where another provider already holds the name: the + // service's fiber fails, and the activation says so on its own logger. + const taken = new Context(); + taken.provide("opencodeModelPurge" as never, {} as never); + const { lines } = await mountPlugin({ + config: { usageEnabled: false }, + ctx: taken, + }); + expect( + lines().some( + (line) => + line.startsWith("warn dsh-opencode-patch:") && + line.includes("opencodeModelPurge did not mount") && + line.includes("has been registered") + ), + lines().join("\n") + ).toBe(true); + + // (b) A `plugin` that throws: each service reports through the logger, + // and nothing reaches the console. + const warned: string[] = []; + const logger = { + info: () => { + // the reachable lines are not what this case reads + }, + warn: (...args: unknown[]) => warned.push(args.map(String).join(" ")), + }; + const consoleWarn = vi.spyOn(console, "warn"); + const host = degradedHost({ logger, - provide: (name) => { - provided.push(name); + plugin: () => { + throw new Error("refused"); }, }); try { - await expect( - apply(withProvide.ctx, { usageEnabled: true }) - ).resolves.toBeUndefined(); + await apply(host.ctx, { usageEnabled: true }); + for (const namespace of [ + "opencodeGoUsage", + "opencodeModelPurge", + STORE_NAMESPACE, + ]) { + expect( + warned.some( + (line) => + line.includes(`${namespace} did not mount`) && + line.includes("refused") + ), + `${namespace}: ${warned.join(" | ")}` + ).toBe(true); + } + expect( + consoleWarn.mock.calls.filter((call) => + call.some((argument) => String(argument).includes("did not mount")) + ) + ).toEqual([]); } finally { - withProvide.dispose(); + host.dispose(); + consoleWarn.mockRestore(); } - for (const service of services) { - expect(warned, service).toContainEqual( - expect.stringContaining(`${service} did not mount`) - ); + }); + + it("a healthy boot reports every service reachable, and no false 'did not register'", async () => { + // The check used to run before cordis had loaded the service, so every + // healthy boot logged that the usage service did not register. + const { lines } = await mountPlugin({ config: { usageEnabled: true } }); + expect( + lines().filter((line) => line.includes("did not register: ctx.get(")) + ).toEqual([]); + for (const namespace of [ + "opencodeGoUsage", + "opencodeModelPurge", + STORE_NAMESPACE, + ]) { + expect( + lines().some( + (line) => line.includes("reachable") && line.includes(namespace) + ), + `${namespace}: ${lines().join("\n")}` + ).toBe(true); } - expect(warned.join(" ")).not.toContain("neither ctx.plugin"); - expect(provided, "KNOWN_DEFECT P5").toEqual([]); - - // Neither `plugin` nor `provide`: the installer says so instead of throwing, - // which is what keeps one service's absence from taking the rest of `apply` - // down with it. - warned.length = 0; - const neither = degradedHost({ logger }); + }); + + it("warns once when globalThis.fetch is no longer the function it installed (L1)", async () => { + echoHeaders(); + const { ctx, lines } = await mountPlugin(); + const replaced = (): string[] => + lines().filter((line) => line.includes("globalThis.fetch is no longer")); + + // The known answer first: with the patch in place, no warning. + await drain( + stream(ctx, { provider: "opencode", sessionId: "l1-before" }, echoTurn) + ); + expect(replaced()).toEqual([]); + + // Whatever replaced it calls through to nothing of ours. + const foreign = vi.fn( + async (_input: RequestInfo | URL, init?: RequestInit) => + Response.json(Object.fromEntries(new Headers(init?.headers))) + ); + globalThis.fetch = foreign; + + // A turn the hook does not decorate says nothing. + const down = createMockStream("x"); + expect(stream(ctx, { provider: "deepseek" }, () => down)).toBe(down); + expect(replaced()).toEqual([]); + + // Two decorated turns: one warning. + await drain( + stream(ctx, { provider: "opencode", sessionId: "l1-a" }, echoTurn) + ); + await drain( + stream(ctx, { provider: "opencode", sessionId: "l1-b" }, echoTurn) + ); + expect(replaced()).toHaveLength(1); + expect(replaced()[0]?.startsWith("warn ")).toBe(true); + }); + + it("two live activations raise no L1 warning: the later patch wraps the earlier one", async () => { + // Two rows of the plugin (the rename notice's legacy row beside the + // current one) nest their fetch patches: the second wraps the first's, + // which still runs underneath, so neither activation's hook may say its + // patch was replaced. + echoHeaders(); + const first = await mountPlugin(); + const second = await mountPlugin({ ctx: first.ctx }); try { - await expect( - apply(neither.ctx, { usageEnabled: true }) - ).resolves.toBeUndefined(); - } finally { - neither.dispose(); - } - for (const service of services) { - expect(warned, service).toContainEqual( - expect.stringContaining( - `${service} did not mount: this composition has neither ctx.plugin nor ctx.provide` + const [headers] = await drain( + stream( + first.ctx, + { provider: "opencode", sessionId: "l1-nested" }, + echoTurn ) ); + expect((headers as Record)["x-opencode-session"]).toBe( + openCodeSessionIdFor("l1-nested") + ); + expect( + first + .lines() + .filter((line) => line.includes("globalThis.fetch is no longer")) + ).toEqual([]); + } finally { + // Latest first, so each restore finds its own patch current. + await second.fiber.dispose(); + await first.fiber.dispose(); + } + }); + + it("three activations, the middle one disposed first: the remaining one warns though its patch runs (KNOWN_DEFECT P6)", async () => { + // pC wraps pB wraps pA. Disposing B leaves pC current; disposing C then + // restores the fetch C wrapped, the disposed pB, which no live activation + // holds. pB still calls through to pA, so A's turn carries its header, + // but A's predicate reads pB as a replacement and warns (L1). + echoHeaders(); + const first = await mountPlugin(); + const second = await mountPlugin({ ctx: first.ctx }); + const third = await mountPlugin({ ctx: first.ctx }); + await second.fiber.dispose(); + await third.fiber.dispose(); + const [headers] = await drain( + stream( + first.ctx, + { provider: "opencode", sessionId: "l1-three" }, + echoTurn + ) + ); + expect((headers as Record)["x-opencode-session"]).toBe( + openCodeSessionIdFor("l1-three") + ); + // KNOWN_DEFECT P6: the stale restore put a disposed patch back, so this + // reads one warning; P6's chain-aware restore flips it to none. + expect( + first + .lines() + .filter((line) => line.includes("globalThis.fetch is no longer")), + "KNOWN_DEFECT P6" + ).toHaveLength(1); + await first.fiber.dispose(); + // The disposed pB outlives every activation (the same defect); the case + // owns the stub underneath and removes it, so the leftover cannot reach + // the next case. + expect(globalThis.fetch?.name, "KNOWN_DEFECT P6").toBe(PATCHED_FETCH); + vi.unstubAllGlobals(); + }); + + it("apply waits for the remote services' reports before it settles", async () => { + // cordis waits for `apply`'s promise; the services' reports are part of + // it, so a mount still loading keeps the activation pending and its + // report has not been made yet. + const infos: string[] = []; + let settle: () => void = () => { + // assigned below, synchronously + }; + const loaded = new Promise((resolve) => { + settle = resolve; + }); + const host = degradedHost({ + get: (service) => ({ typertRemote: { namespace: service } }), + logger: { + info: (...args: unknown[]) => { + infos.push(args.map(String).join(" ")); + }, + warn: () => { + // not what this case reads + }, + }, + plugin: () => ({ + await: async () => loaded, + dispose: () => { + // nothing to unload + }, + }), + }); + const reachable = (): string[] => + infos.filter((line) => line.includes("is reachable")); + try { + let applied = false; + const applying = (async () => { + await apply(host.ctx, { usageEnabled: true }); + applied = true; + })(); + for (let tick = 0; tick < 5; tick += 1) { + await new Promise((resolve) => { + setImmediate(resolve); + }); + } + expect(applied, "apply settled before the mounts did").toBe(false); + expect(reachable()).toEqual([]); + settle(); + await applying; + expect(applied).toBe(true); + expect(reachable()).toHaveLength(3); + } finally { + host.dispose(); } }); diff --git a/test/opencode-store-service.test.ts b/test/opencode-store-service.test.ts index 6fb6f13..c1318f2 100644 --- a/test/opencode-store-service.test.ts +++ b/test/opencode-store-service.test.ts @@ -125,8 +125,9 @@ describe("opencode-store-service: the inspection seam", () => { }); it("ignores an injected resolver that is not a function", async () => { - // `mountByProvide` passes its `options` through untouched, so a composition - // that hands over something else must not turn into a crash at call time. + // `installRemoteService` hands the Service its activation's options as + // given (a view of the latest live activation's), so a composition that + // hands over something else must not turn into a crash at call time. const service = new OpencodeStoreService(new Context(), { resolveStorePath: "not a function", }); diff --git a/test/remote-service.test.ts b/test/remote-service.test.ts new file mode 100644 index 0000000..76fe742 --- /dev/null +++ b/test/remote-service.test.ts @@ -0,0 +1,602 @@ +/** + * `installRemoteService`: the one installer for the plugin's remote services. + * + * On a bare cordis `Context`, with a probe service of its own rather than the + * plugin's three: one service per root while a live activation holds it, the + * latest live activation's options, the last activation's dispose withdrawing + * it, and every outcome reported through the activation's own logger once the + * mount has settled. The stand-in compositions at the end are the shapes a + * real `Context` cannot take: a `plugin` that throws, answers no fiber, or + * answers a fiber whose settling (and dispose) the case controls; no + * `plugin`; no `effect`. They are this file's own, for the installer's + * clauses, as the route claim keeps its stand-in host: `degradedHost` stands + * for the plugin's composition, not for this module. + * + * @module test/remote-service.test + */ +import { Context, type Fiber } from "@deepseek-ai/cordis"; +import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { logLines } from "./support/cordis-wait.ts"; +import { + type CordisContext, + installRemoteService, + type RemoteContract, +} from "./support/src.ts"; + +interface ProbeOptions { + readonly value?: string; + readonly extra?: number; +} + +/** A remote service that answers the options it was handed. */ +class Probe extends TypertRemoteService { + static made = 0; + static last: Probe | undefined; + readonly options: ProbeOptions | undefined; + + constructor(ctx: unknown, options?: ProbeOptions) { + super(ctx as never, "probeRemote"); + Probe.made += 1; + Probe.last = this; + this.options = options; + } + + read(): string | undefined { + return this.options?.value; + } +} + +const contract: RemoteContract = { + descriptors: [ + { method: "read", namespace: "probeRemote", service: "probeRemote" }, + ], +}; + +/** The plugin each activation is: one runtime, as the Loader's rows are. */ +const Act = { + apply: (ctx: CordisContext, config: ProbeOptions) => + installRemoteService(ctx, Probe, contract, config), + name: "act", +}; + +const activate = async (root: Context, value: string): Promise => { + const fiber = root.plugin(Act as never, { value } as never); + await fiber.await(); + return fiber; +}; + +/** The probe as the root answers it now, or `undefined`. */ +const probeOn = (root: Context): Probe | undefined => + root.get("probeRemote" as never) as Probe | undefined; + +const bindingOf = (root: Context): unknown => probeOn(root)?.typertRemote; + +beforeEach(() => { + Probe.made = 0; + Probe.last = undefined; +}); + +describe("installRemoteService · on a real context", () => { + it("mounts one activation's service on the root, and withdraws it with that activation", async () => { + const root = new Context(); + const log = logLines(root); + const act = await activate(root, "a"); + expect(probeOn(root)?.read()).toBe("a"); + expect(Probe.made).toBe(1); + expect( + log.lines().some((line) => line.includes("probeRemote is reachable")), + log.lines().join("\n") + ).toBe(true); + await act.dispose(); + expect(probeOn(root) === undefined, "the service outlived its holder").toBe( + true + ); + }); + + it("shares one service between overlapping activations, reading the latest live one's options, in either disposal order", async () => { + const root = new Context(); + const first = await activate(root, "a"); + const second = await activate(root, "b"); + expect(probeOn(root)?.read()).toBe("b"); + expect(Probe.made).toBe(1); + const binding = bindingOf(root); + await second.dispose(); + expect(probeOn(root)?.read()).toBe("a"); + expect(bindingOf(root), "the same service").toBe(binding); + await first.dispose(); + expect(probeOn(root) === undefined).toBe(true); + + // The other order, on a root of its own. + Probe.made = 0; + const other = new Context(); + const early = await activate(other, "a"); + const late = await activate(other, "b"); + await early.dispose(); + expect(probeOn(other)?.read()).toBe("b"); + await late.dispose(); + expect(probeOn(other) === undefined).toBe(true); + expect(Probe.made).toBe(1); + }); + + it("mounts anew on a reload, with the reloaded options", async () => { + const root = new Context(); + const act = await activate(root, "a"); + await act.update({ value: "b" } as never); + expect(probeOn(root)?.read()).toBe("b"); + expect(Probe.made).toBe(2); + }); + + it("hands the service a view of the latest live activation's options", async () => { + const root = new Context(); + const first = await activate(root, "a"); + const probe = Probe.last; + const view = probe?.options ?? {}; + const second = root.plugin(Act as never, { extra: 1, value: "b" } as never); + await second.await(); + expect(Object.keys(view)).toEqual(["extra", "value"]); + expect("extra" in view).toBe(true); + expect(Object.getOwnPropertyDescriptor(view, "extra")).toEqual({ + configurable: true, + enumerable: true, + value: 1, + writable: true, + }); + await second.dispose(); + expect(Object.keys(view)).toEqual(["value"]); + expect("extra" in view).toBe(false); + expect(Object.getOwnPropertyDescriptor(view, "extra")).toBeUndefined(); + // With no live holder left, the view reads the mounting activation's. + await first.dispose(); + expect(probe?.read()).toBe("a"); + }); + + it("reports a refused mount on the activation's logger, and lets the next activation retry", async () => { + const root = new Context(); + const log = logLines(root); + const occupant = root.plugin((ctx: Context) => { + ctx.provide("probeRemote" as never, {} as never); + }); + await occupant.await(); + await activate(root, "a"); + const lines = log.lines(); + expect( + lines.some( + (line) => + line.startsWith("warn act:") && + line.includes("probeRemote did not mount") && + line.includes("has been registered") + ), + lines.join("\n") + ).toBe(true); + expect(lines.some((line) => line.includes("reachable"))).toBe(false); + + await occupant.dispose(); + await activate(root, "b"); + expect(probeOn(root)?.read()).toBe("b"); + }); +}); + +/** A stand-in composition: a logger that records, and what the case gives. */ +const standIn = ( + parts: Record = {} +): { + readonly ctx: CordisContext; + readonly warned: string[]; + readonly infos: string[]; + readonly release: () => Promise; +} => { + const warned: string[] = []; + const infos: string[] = []; + const disposers: (() => unknown)[] = []; + const ctx = { + effect: (callback: () => unknown) => { + const disposer = callback(); + if (typeof disposer === "function") { + disposers.push(disposer as () => unknown); + } + }, + logger: { + info: (...args: unknown[]) => infos.push(args.map(String).join(" ")), + warn: (...args: unknown[]) => warned.push(args.map(String).join(" ")), + }, + ...parts, + } as unknown as CordisContext; + return { + ctx, + infos, + release: async () => { + for (const disposer of disposers.splice(0).toReversed()) { + await disposer(); + } + }, + warned, + }; +}; + +/** + * A fiber whose settling the case controls, and, with `holdDispose`, whose + * dispose stays pending until the case calls `finishDispose`. + */ +const heldFiber = ({ + holdDispose = false, +}: { readonly holdDispose?: boolean } = {}): { + readonly fiber: { + await: () => Promise; + dispose: () => Promise; + }; + readonly settle: (error?: Error) => void; + readonly disposed: () => number; + readonly finishDispose: () => void; +} => { + let disposals = 0; + let finishDispose: () => void = () => { + // replaced below when the dispose is held + }; + const disposeGate = holdDispose + ? new Promise((resolve) => { + finishDispose = resolve; + }) + : Promise.resolve(); + let settle: (error?: Error) => void = () => { + throw new Error("the promise below assigns this synchronously"); + }; + const settled = new Promise((resolve, reject) => { + settle = (error) => { + if (error === undefined) { + resolve(); + } else { + reject(error); + } + }; + }); + return { + disposed: () => disposals, + fiber: { + await: async () => settled, + dispose: async () => { + disposals += 1; + await disposeGate; + }, + }, + finishDispose: () => { + finishDispose(); + }, + settle: (error) => { + settle(error); + }, + }; +}; + +describe("installRemoteService · stand-in compositions", () => { + it("reports a throwing plugin, and tries again on the next install", async () => { + const plugin = vi.fn(() => { + throw new Error("refused"); + }); + const host = standIn({ plugin }); + await expect( + installRemoteService(host.ctx, Probe, contract) + ).resolves.toBeUndefined(); + expect(host.warned).toEqual([ + "[dsh-opencode-patch] probeRemote did not mount Error: refused", + ]); + await installRemoteService(host.ctx, Probe, contract); + expect(plugin).toHaveBeenCalledTimes(2); + }); + + it("reports a composition with no plugin, and never provides", async () => { + const provide = vi.fn(); + const host = standIn({ provide }); + await expect( + installRemoteService(host.ctx, Probe, contract) + ).resolves.toBeUndefined(); + expect(host.warned).toEqual([ + "[dsh-opencode-patch] probeRemote did not mount: this composition has no ctx.plugin the client's probeRemote.read will find nothing", + ]); + expect(provide).not.toHaveBeenCalled(); + }); + + it("mounts nothing for a contract that names no single service", async () => { + const plugin = vi.fn(); + const host = standIn({ plugin }); + for (const descriptors of [ + [ + { method: "read", namespace: "x", service: "x" }, + { method: "read", namespace: "x", service: "y" }, + ], + // One service, but bound under two namespaces. + [ + { method: "read", namespace: "x", service: "x" }, + { method: "list", namespace: "y", service: "x" }, + ], + [], + ]) { + await expect( + installRemoteService(host.ctx, Probe, { descriptors }) + ).resolves.toBeUndefined(); + } + expect(host.warned).toEqual([ + "[dsh-opencode-patch] a remote contract naming no single service and namespace was not mounted x,y x", + "[dsh-opencode-patch] a remote contract naming no single service and namespace was not mounted x x,y", + "[dsh-opencode-patch] a remote contract naming no single service and namespace was not mounted ", + ]); + expect(plugin).not.toHaveBeenCalled(); + }); + + it("holds for good in a composition with no effect", async () => { + const plugin = vi.fn(); + const host = standIn({ effect: undefined, plugin }); + await installRemoteService(host.ctx, Probe, contract, { value: "a" }); + await installRemoteService(host.ctx, Probe, contract, { value: "b" }); + expect(plugin).toHaveBeenCalledTimes(1); + const [, view] = plugin.mock.calls[0] as unknown as [unknown, ProbeOptions]; + expect(view.value).toBe("b"); + }); + + it("reports each verification outcome", async () => { + const outcome = async ( + get: () => unknown + ): Promise<{ warned: string[]; infos: string[] }> => { + const host = standIn({ get, plugin: vi.fn() }); + await installRemoteService(host.ctx, Probe, contract); + return host; + }; + const unregistered = await outcome(() => { + throw new Error("unregistered"); + }); + expect(unregistered.warned).toEqual([ + "[dsh-opencode-patch] probeRemote did not register: ctx.get('probeRemote') answered nothing the client's probeRemote.read will find nothing", + ]); + const unbound = await outcome(() => ({ read: () => "x" })); + expect(unbound.warned.join(" ")).toContain("no typertRemote binding"); + expect(unbound.warned.join(" ")).toContain("ctx.provide()"); + const elsewhere = await outcome(() => ({ + typertRemote: { namespace: "other" }, + })); + expect(elsewhere.warned).toEqual([ + "[dsh-opencode-patch] probeRemote is bound for namespace other, not probeRemote, the one the client calls", + ]); + const numbered = await outcome(() => ({ typertRemote: { namespace: 42 } })); + expect(numbered.warned).toEqual([ + "[dsh-opencode-patch] probeRemote is bound for namespace 42, not probeRemote, the one the client calls", + ]); + const reachable = await outcome(() => ({ + typertRemote: { namespace: "probeRemote" }, + })); + expect(reachable.warned).toEqual([]); + expect(reachable.infos).toEqual([ + "[dsh-opencode-patch] probeRemote is reachable with a typertRemote binding for namespace %s probeRemote", + ]); + + // A composition with no `get` reads as nothing answered. + const host = standIn({ plugin: vi.fn() }); + await installRemoteService(host.ctx, Probe, contract); + expect(host.warned.join(" ")).toContain("did not register"); + + // An answer with `await` but no `dispose` is no fiber: nothing is waited + // for, and the read-back still runs. + const halfFiber = standIn({ + plugin: () => ({ await: () => new Promise(() => {}) }), + }); + await installRemoteService(halfFiber.ctx, Probe, contract); + expect(halfFiber.warned.join(" ")).toContain("did not register"); + }); + + it("says nothing for an activation that went before its mount settled", async () => { + const held = heldFiber(); + const host = standIn({ get: vi.fn(), plugin: () => held.fiber }); + const reported = installRemoteService(host.ctx, Probe, contract); + await host.release(); + expect(held.disposed()).toBe(1); + held.settle(); + await expect(reported).resolves.toBeUndefined(); + expect([...host.warned, ...host.infos]).toEqual([]); + }); + + it("reports a fiber that fails, disposing it once while another activation waits on it too", async () => { + const held = heldFiber(); + const plugin = vi.fn(() => held.fiber); + const host = standIn({ plugin }); + const first = installRemoteService(host.ctx, Probe, contract); + const second = installRemoteService(host.ctx, Probe, contract); + held.settle(new Error("refused late")); + await expect(Promise.all([first, second])).resolves.toEqual([ + undefined, + undefined, + ]); + expect(plugin).toHaveBeenCalledTimes(1); + expect(host.warned).toEqual([ + "[dsh-opencode-patch] probeRemote did not mount Error: refused late", + "[dsh-opencode-patch] probeRemote did not mount Error: refused late", + ]); + expect(held.disposed()).toBe(1); + // The mount was cleared, so the next install mounts again (and, on the + // same refused fiber, reports and disposes it again). + await installRemoteService(host.ctx, Probe, contract); + expect(plugin).toHaveBeenCalledTimes(2); + expect(held.disposed()).toBe(2); + + // Releasing every holder of a cleared mount disposes nothing a second + // time: the report already disposed each failed fiber. + await host.release(); + expect(held.disposed()).toBe(2); + expect( + host.warned.filter((line) => line.includes("did not dispose")) + ).toEqual([]); + }); + + it("releases a mount that answered no fiber without disposing anything", async () => { + const plugin = vi.fn(); + const host = standIn({ + get: () => ({ typertRemote: { namespace: "probeRemote" } }), + plugin, + }); + await installRemoteService(host.ctx, Probe, contract); + await expect(host.release()).resolves.toBeUndefined(); + expect(plugin).toHaveBeenCalledTimes(1); + // A dispose of the missing fiber would have thrown and been reported. + expect(host.warned).toEqual([]); + }); + + it("reports a fiber whose await throws, and a dispose that throws", async () => { + const throwing = { + await: () => { + throw new Error("no await"); + }, + dispose: () => { + throw new Error("no dispose"); + }, + }; + const host = standIn({ plugin: () => throwing }); + await installRemoteService(host.ctx, Probe, contract); + expect(host.warned).toEqual([ + "[dsh-opencode-patch] probeRemote did not mount Error: no await", + "[dsh-opencode-patch] probeRemote did not dispose cleanly Error: no dispose", + ]); + }); + + it("disposes only with the last holder, and returns the dispose for the unload to await", async () => { + const held = heldFiber({ holdDispose: true }); + const host = standIn({ + get: () => ({ typertRemote: { namespace: "probeRemote" } }), + plugin: () => held.fiber, + }); + const other = standIn({ root: host.ctx }); + const first = installRemoteService(host.ctx, Probe, contract); + const second = installRemoteService(other.ctx, Probe, contract); + held.settle(); + await Promise.all([first, second]); + expect(other.infos.join(" ")).toContain("probeRemote is reachable"); + await host.release(); + expect(held.disposed()).toBe(0); + + // The last release starts the dispose, and does not settle before it. + let released = false; + const releasing = (async () => { + await other.release(); + released = true; + })(); + for (let tick = 0; tick < 5; tick += 1) { + await new Promise((resolve) => { + setImmediate(resolve); + }); + } + expect(held.disposed()).toBe(1); + expect(released, "the release settled before the dispose").toBe(false); + held.finishDispose(); + await releasing; + expect(released).toBe(true); + }); + + it("checks the binding against the contract's namespace, and reads the service by its key", async () => { + // A wire namespace that differs from the Cordis key: the Gateway checks + // the binding against the descriptor's `namespace`, and the service is + // registered under its `service`. + const wire: RemoteContract = { + descriptors: [ + { method: "read", namespace: "probeWire", service: "probeRemote" }, + ], + }; + const outcome = async ( + namespace: string + ): Promise<{ warned: string[]; infos: string[]; asked: unknown[] }> => { + const asked: unknown[] = []; + const host = standIn({ + get: (name: unknown) => { + asked.push(name); + return { typertRemote: { namespace } }; + }, + plugin: vi.fn(), + }); + await installRemoteService(host.ctx, Probe, wire); + return { ...host, asked }; + }; + const reachable = await outcome("probeWire"); + expect(reachable.asked).toEqual(["probeRemote"]); + expect(reachable.warned).toEqual([]); + expect(reachable.infos).toEqual([ + "[dsh-opencode-patch] probeRemote is reachable with a typertRemote binding for namespace %s probeWire", + ]); + const keyed = await outcome("probeRemote"); + expect(keyed.warned).toEqual([ + "[dsh-opencode-patch] probeRemote is bound for namespace probeRemote, not probeWire, the one the client calls", + ]); + }); + + it("reads frozen and non-configurable options through the view", async () => { + // The view's target is its own empty object, so a frozen mounting + // activation's options, or a later holder's non-configurable property, + // break no Proxy invariant: every trap reads the latest live holder's. + const plugin = vi.fn(); + const host = standIn({ plugin }); + await installRemoteService( + host.ctx, + Probe, + contract, + Object.freeze({ value: "a" }) + ); + const later: ProbeOptions = {}; + Object.defineProperty(later, "value", { + configurable: false, + enumerable: true, + value: "b", + writable: false, + }); + Object.defineProperty(later, "extra", { + configurable: false, + enumerable: true, + value: 2, + writable: false, + }); + await installRemoteService(host.ctx, Probe, contract, later); + const [, view] = plugin.mock.calls[0] as unknown as [unknown, ProbeOptions]; + expect(view.value).toBe("b"); + expect(view.extra).toBe(2); + expect("extra" in view).toBe(true); + expect(Object.keys(view)).toEqual(["value", "extra"]); + expect(Object.getOwnPropertyDescriptor(view, "value")).toEqual({ + configurable: true, + enumerable: true, + value: "b", + writable: false, + }); + expect({ ...view }).toEqual({ extra: 2, value: "b" }); + }); + + it("refuses a write, a define, a delete and a freeze on the view", async () => { + // A writable key the holder carries is the case a write would poison: + // reaching the empty target, it would land there non-configurable and + // every later read of it would break a Proxy invariant. + const plugin = vi.fn(); + const host = standIn({ plugin }); + await installRemoteService(host.ctx, Probe, contract, { value: "a" }); + const [, view] = plugin.mock.calls[0] as unknown as [ + unknown, + { value?: string; extra?: number }, + ]; + expect(() => { + view.value = "x"; + }).toThrow(TypeError); + expect(() => { + view.extra = 1; + }).toThrow(TypeError); + expect(() => + Object.defineProperty(view, "value", { value: "y", writable: true }) + ).toThrow(TypeError); + expect(() => { + delete view.value; + }).toThrow(TypeError); + expect(() => Object.freeze(view)).toThrow(TypeError); + expect(view.value).toBe("a"); + expect("extra" in view).toBe(false); + expect(Object.keys(view)).toEqual(["value"]); + expect(Object.getOwnPropertyDescriptor(view, "value")).toEqual({ + configurable: true, + enumerable: true, + value: "a", + writable: true, + }); + expect({ ...view }).toEqual({ value: "a" }); + }); +}); diff --git a/test/stream-hook.test.ts b/test/stream-hook.test.ts index bf8265c..79719f0 100644 --- a/test/stream-hook.test.ts +++ b/test/stream-hook.test.ts @@ -237,6 +237,62 @@ describe("stream-hook · a plane another plugin owns (D27)", () => { }); }); +describe("stream-hook · the fetch it installed was replaced (L1)", () => { + /** + * A turn is decorated through the plugin's fetch patch, so a turn that runs + * after something else replaced `globalThis.fetch` carries the session and + * gateway headers only if the replacement calls through. The installer + * hands the hook a predicate, read per decorated turn, and the hook says so + * once — never for a turn it does not decorate. + */ + const drainTurn = ( + hook: ReturnType, + provider: string + ) => + collectUnknown( + hook({ model: "m", provider }, () => + createMockStream("chunk") + ) as AsyncIterable + ); + + it("warns once, on decorated turns only", async () => { + const warn = vi.fn(); + const hook = createStreamHook( + { logger: { info: () => {}, warn } } as never, + resolveConfig({ usageEnabled: false } as never), + new AsyncLocalStorage(), + () => new Set(["opencode-go"]), + () => false + ); + // A provider the plugin does not own, and a stepped-aside plane. + const next = vi.fn(() => "next-result"); + expect(hook({ model: "m", provider: "other" }, next)).toBe("next-result"); + await drainTurn(hook, "opencode-go"); + expect(warn).not.toHaveBeenCalled(); + + await drainTurn(hook, "opencode"); + await drainTurn(hook, "opencode"); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0]?.[0])).toContain( + "globalThis.fetch is no longer the function this plugin installed" + ); + }); + + it("says nothing while the patch is in place", async () => { + const warn = vi.fn(); + const hook = createStreamHook( + { logger: { info: () => {}, warn } } as never, + resolveConfig({ usageEnabled: false } as never), + new AsyncLocalStorage(), + () => new Set(), + () => true + ); + await drainTurn(hook, "opencode"); + await drainTurn(hook, "opencode"); + expect(warn).not.toHaveBeenCalled(); + }); +}); + describe("stream-hook · the debug gate", () => { /** * Naming `debugFile` writes the file; `debug` governs the console alone. The diff --git a/test/support/composition.ts b/test/support/composition.ts index ba606d4..7ce9767 100644 --- a/test/support/composition.ts +++ b/test/support/composition.ts @@ -17,13 +17,16 @@ * {@link degradedHost} is the one stand-in for the plugin's COMPOSITION, for * the diagnostics a real `Context` cannot reach, because a real one registers * what it is asked to and always has `plugin`: (1) a `plugin` that registers - * nothing, with and without `get`, which drives the three unreachable-service - * diagnostics; (2) a composition with no `plugin`, or with neither `plugin` - * nor `provide`. Even there `effect` runs - * its callback and keeps the disposer, so nothing it installs outlives the - * case. (The route claim's clause tests keep a stand-in host of their own, - * `route-claim-stand-in.ts`, for fiber states a real host cannot be put in on - * demand; that is a module under test, not the composition.) + * nothing or throws, with and without `get`, which drives the three + * unreachable-service diagnostics and a refused mount; (2) a `plugin` whose + * fiber the case settles, for `apply` waiting on the services' reports; (3) a + * composition with no `plugin` (with or without `provide`, which is never + * called). Even there `effect` runs its callback and keeps the disposer, so + * nothing it installs outlives the case. (The route claim's clause tests keep + * a stand-in host of their own, `route-claim-stand-in.ts`, for fiber states a + * real host cannot be put in on demand, and `test/remote-service.test.ts` + * keeps stand-in compositions for `installRemoteService`'s clauses; those are + * modules under test, not the composition.) * * @module test/support/composition */ @@ -139,7 +142,8 @@ export interface DegradedHost { /** * A composition a real `Context` cannot stand for: a `plugin` that registers - * nothing (with or without `get`), or one missing `plugin`, or `provide` too. + * nothing, throws or answers a fiber the case settles (with or without + * `get`), or one missing `plugin` (with or without `provide`). * Its `effect` runs the callback and records the disposer, as * cordis does, so the fetch patch it installs is restored by `dispose()`. * diff --git a/test/support/src.ts b/test/support/src.ts index 86f6af9..32d318c 100644 --- a/test/support/src.ts +++ b/test/support/src.ts @@ -167,6 +167,10 @@ export { rewrapOnAdaptersUpdated, } from "../../src/host/patch-method.ts"; +// host/remote.ts +export { installRemoteService } from "../../src/host/remote.ts"; +export type { RemoteContract } from "../../src/host/remote.ts"; + // identity.ts export { inject, diff --git a/vite.config.ts b/vite.config.ts index 3cd8645..8fba57e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -328,6 +328,14 @@ export default defineConfig({ functions: 100, lines: 99, }, + // The one installer for the remote services (P5): every branch is + // driven, on a real context or a stand-in composition, so 100. + "src/host/remote.ts": { + statements: 100, + branches: 100, + functions: 100, + lines: 100, + }, "src/host/describe-graft.ts": { statements: 100, branches: 94, @@ -349,25 +357,31 @@ export default defineConfig({ // 100 / 98.02 / 100 / 100: the uncovered set is the same, but the // debug gate that names `debugFile` as the switch for both halves (D24) // removed reachable branches from the composition, so the same - // unreachable ones are a larger share of lifecycle.ts. What is left - // uncovered no composition reaches, and is named here rather than - // driven through a stand-in: - // - lifecycle.ts: `root.plugin()` throwing for the usage, purge and - // store services (L172-173, L258-261, L283-284), `ctx.get` throwing - // (L213), a usage binding whose namespace is not a string (L233), the - // debug hook's `debugFile ?? ""` behind its own guard (L335), an - // `llm` scope that is not a record or has no `effect` (L380-381, L385); - // - stream-hook.ts: `headerValueFor` answering `undefined` for a - // non-empty session id (L150, L155), which it never does; - // - fetch-patch.ts: the report's "present" for a request key or a - // captured key (L331, L333), since either one fills the wire and no - // report follows, and a session header that is null after the patch - // set it (L475). + // unreachable ones are a larger share of lifecycle.ts. P5 moved the + // three services' mounts to `host/remote.ts` (held at 100 by its own + // floor above, its throwing `plugin`, throwing `get` and non-string + // binding namespace covered there by `test/remote-service.test.ts`'s + // stand-in compositions), which left lifecycle.ts at 98.07 / 85.71 / + // 100 / 97.95: the same unreachable branches in a smaller file. What + // is left uncovered no composition reaches, and is named here, by + // expression rather than line, rather than driven through a stand-in: + // - lifecycle.ts: the debug hook's `config.debugFile ?? ""` behind its + // own guard, and in `installDiscovery` an `llm` scope that is not a + // record or has no `effect`; + // - stream-hook.ts: the two `headerValueFor(…) ?? + // fallbackSessionId(…)` fallbacks for a non-empty string or numeric + // session id, since `headerValueFor` never answers `undefined` there; + // - fetch-patch.ts: the no-credential report's `requestKey === + // undefined ? "none" : "present"` and `captured === undefined ? + // "none" : "present"` arms, since either key fills the wire and no + // report follows, and the debug entry's `session: + // headers.get(SESSION_HEADER) ?? null` after the patch set the + // header. "src/lifecycle.ts": { - statements: 92.5, - branches: 91.5, + statements: 98, + branches: 85.5, functions: 100, - lines: 92.5, + lines: 97.5, }, "src/stream-hook.ts": { statements: 100,