docs: bilingual config-plane documentation, regenerated catalogs, and the web-config-plane Agent Note
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
# Agent Note: the web configuration plane
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-30-web-config-plane.zh.md)
|
||||
|
||||
> Scope: the wire face and web UI deferred from the [request-level LLM configuration note](2026-07-29-request-level-llm-config-credentials.md) — the `settings.*`/`credentials.*`/`llm.*` RPC domains with pushed invalidations, layered+redacted `describe()`, the llm configurable-provider directory and topology event, the standalone `dsh-client-schema-form` renderer, and the Models settings page. The `deepseek` → `deepseek-official` provider-route rename rides along as the enabling breaking change.
|
||||
|
||||
## Problem
|
||||
|
||||
PR1 made LLM adapter configuration restart-free at the seam, but the only writer was a text editor on `settings.yaml`: the web client had no wire access to settings, credentials, or provider topology, so "store a key, prompt again" still meant leaving the product. Three gaps blocked a config page rather than one: `describe()` returned only the merged effective value (a form cannot tell a user override from a composition default, and serializing it would have shipped `role('secret')` values to every browser), nothing enumerated the providers an adapter *could* run (a bare-mounted `llm-pi-ai` was invisible until configured), and the two adapters both wanted a `deepseek` route key, so the directory could not attribute routes to owning namespaces unambiguously. Hand-maintaining a form per provider was rejected outright — the schemas already exist as schemastery `Config` values, and a second source of field truth drifts.
|
||||
|
||||
## Decision
|
||||
|
||||
**Wire domains on the compiled RPC map, rejections as codes, invalidations as frames.** `settings.describe/update/replace`, `credentials.describe/set/unset`, `llm.providers`, and `llm.models` (claiming the reserved `host.listModels` surface) join `RpcMethodMap`, so the seven compiler-locked wiring sites keep contract, schema, handler, and client in lockstep. Seam rejections fold into `settings-rejected {ns}` / `credential-rejected {ref}` business errors (HTTP stays a carrier), and three `HostFrame`s — `host/settings-changed {ns}`, `host/credentials-changed {ref}`, `host/models-changed` — follow the `host/commands-changed` shape so every client converges without polling. Writes join `pickDirectory`/`openPath` in the connection guard's privileged set: loopback + same-origin or 403, because a LAN-exposed dsh web must not accept config mutation from another origin.
|
||||
|
||||
**`describe()` grows layers and structural secret redaction.** `SettingsDescriptor` carries `base`/`user` beside the effective value, so the form marks "overridden" by presence in the user layer, not value inequality (an override *equal* to the base is still an override). `describe({ redactSecrets: true })` — mandatory at every wire face — strips `role('secret')` subtrees from all three layers via a pure structural walk of the schema (object/dict/array containers; a secret-role subtree is one opaque leaf) and enumerates the stripped slots as `{path, set}`, so a page can render write-only inputs without ever receiving a value.
|
||||
|
||||
**The llm seam declares configurability and announces topology.** `registerConfigurableProviders()` is an all-or-nothing, fiber-scoped directory of `{provider, displayName, settingsNs, settingsPath}` — the addressing a config page needs to open the right settings subtree for a route that may not exist yet; `listConfigurableProviders()` merges with live routes in the wire handler so undeclared live routes still report active. The zero-payload `'llm/adapters-updated'` event fires from all four registration/unregistration commit points with contained listener dispatch (INVARIANT rethrow), following the settings/commands precedent. `llm-deepseek`'s route renamed to `deepseek-official` because the pi-ai catalog legitimately owns `deepseek` as an aggregator entry; pre-release stance, no alias.
|
||||
|
||||
**A standalone schema-driven form renderer.** `dsh-client-schema-form` rehydrates the wire's `toJSON()` envelope into live schemastery nodes and renders by structural classification: objects/dicts/arrays recurse, all-literal unions become selects (an absent value shows `Default: X` from the fallback layer), dict key-unions feed the add-entry vocabulary, and anything it cannot faithfully edit renders as read-only JSON — visible, never dropped. Presence-in-draft drives the override badge and per-field Reset; a `renderField` hook lets consumers mount role-specific controls without the renderer knowing any role.
|
||||
|
||||
**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add vocabulary is the dormant directory remainder; badges come from route liveness and the credential reference's value-free `configured` state. The `credential-ref` role mounts the credential control: reference name in settings, key value **write-only** through `credentials.set`. An edit without removals lands as a minimal `settings.update` merge patch (stored secrets outside the patch survive); a field reset or row deletion replaces the whole user section via `settings.replace`, because merge semantics cannot express removal.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Serving JSON Schema over the wire** — schemastery's `toJSON()` envelope round-trips `role()`/meta and rehydrates into the validator the client already ships for drafts; converting to JSON Schema loses exactly the role annotations the credential control and secret redaction key on.
|
||||
- **Masking secrets per-field with sentinel backfill on `replace`** — the PR1 decision (secrets are references) already deleted the stored-literal case for the product default; structural redaction plus a write-only credential path handles the residue without teaching every writer a sentinel protocol.
|
||||
- **A `models` bridge plugin owning provider configuration** — same rejection as PR1: per-plugin namespaces plus a four-field directory declaration give the UI everything it needs; the bridge's unified dict re-imports the adapter-mapping indirection.
|
||||
- **Page-side polling instead of pushed frames** — the mux already carries `host/commands-changed`; three more frames cost one shape each and make a second tab, an external `settings.yaml` edit, and a settings-born route converge at event speed.
|
||||
|
||||
## Consequences
|
||||
|
||||
The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the dormant pi-ai catalog renders as add vocabulary, adding `anthropic` writes `settings.yaml` and the route registers live on the topology frame, the key stores write-only into the harness home's `.env`, and the badge converges from the credentials frame — zero model calls, ARIA goldens for the empty and configured states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh`. The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and the documented reset edge — a `settings.replace` cannot re-supply a stored *literal* secret in the replaced subtree, which the reference-based default makes unreachable.
|
||||
Reference in New Issue
Block a user