fix!: port the settings page and shell seam to dsh 0.1.7-rc.2
Release / release (push) Successful in 6s

0.1.7-rc.2 moved four things this bundle was built on, and every one of them
failed quietly rather than loudly, so the plugin looked installed and did
nothing:

  * `ctx.settings.installSection(...)` is gone. The call threw into a
    `try/catch` that only logged a warning, so the settings namespace was never
    registered and `configure` had nowhere to persist. An entry's exported
    Config IS the form now; the namespace is the Loader entry id the bundle's
    patch declares, and `ctx.settings.configure({ auto: false })` is what claims
    that entry's own page.

  * `settingsScope` and the `settings.plugin.item` slot are gone. The card
    registered into both, so it never rendered — the tab draws the intersection
    of served namespaces and registered cards, and that intersection was empty.
    It now registers into `plugins.bundle.config` keyed by the bundle's package
    name, over the `configForms` service, and uses the framework's own form
    chrome (`SettingsFormModel` / `SettingsForm` / `SettingsValueField` /
    `SettingsSecretField`) instead of the hand-rolled equivalent. A save is one
    revision-fenced write.

  * `ctx.shell.run(spec)` is gone. Every action that runs a command threw
    `shell.run is not a function`; the seam is now
    `await ctx.shell.execute(ctx.shell.resolve(request)).result()`.

  * A job's `owner` is a session id, not the agent object. The wrong owner
    registered a job no session could collect or cancel, and the producer's
    `done` resolved `output` where the registry reads `result`, so a finished
    job handed back nothing.

Two conformance gaps went with them: a Config field that is not marked
`volatile()` is invisible to every settings UI — the form projection keeps only
volatile fields, and an entry with no form is dropped from `settings.describe`
entirely — and `embeddingApiKey` was a plain field, so the key rode every
settings read back to the browser. It is `role('secret')` now: the browser is
told whether a key is stored and never receives the key.

Also declares the `@deepseek-ai/dsh-*` peerDependencies the compatibility gate
reads, so an incompatible runtime refuses this plugin at startup instead of
half-loading it.

Verified against a real 0.1.7-rc.2 boot: `settings.describe` serves the entry
with `autoGenerate: false`, the key redacted and reported only as `set: true`,
and no startup diagnostics. The host mock in the tests now has no `run` and no
`installSection`, so those seams are contract-tested rather than assumed, and
the browser tests read the installed harness's own slot catalog and package
exports — a rename fails a test instead of quietly emptying a page.
This commit is contained in:
dsh-slopo agent committed 2026-09-26 06:38:16 +01:00
1 parent ffefc42e01
commit 1b2ab3613f
8 files changed
+1549 -689

No files matched your search

+95 -27
View File
@@ -17,8 +17,9 @@ are in without remembering flags.
| `/slopo-analyze-ignore` | Triages the whole codebase's clusters, writing the obvious false positives into the ignore file. |
| `/slopo-analyze-one` | Reports one cluster at a time — optionally by hash — and judges whether it is worth acting on. |
| the `slopo` tool | Resolves the CLI, materialises the config, runs the work, and turns slopo's terse failures into something actionable. |
| the `dsh-slopo` settings namespace | The embedding backend and per-workspace state, registered on the Host. |
| the settings card | A form for all of it, in Settings → Plugins → Plugin configuration. |
| the `dsh-slopo` configuration | The embedding backend and per-workspace state. It is the Host plugin's own `Config`, so the deployment's schema and the form can never disagree. |
| the settings card | A form for all of it, on the **Plugins** page under the `dsh-slopo` bundle. |
All three skills are **user-invocable and deliberately not model-invocable**,
matching slopo's own agent configs. Typing `/slopo-review` anywhere in a message
injects that skill; the model will not reach for one on its own. Every one of
@@ -41,23 +42,31 @@ allowlist a build before the first install can succeed.
## Settings
The plugin contributes a card to **Settings → Plugins → Plugin configuration**,
keyed on the `dsh-slopo` settings namespace the Host registers. Every setting in
the table below is editable there, overridden fields are marked, and Reset
returns a field to the deployment default rather than to empty.
The plugin ships a configuration page on the **Plugins** page — the sidebar
entry that lists bundles, rows and official plugins — reached by opening the
`dsh-slopo` bundle and pressing **Configure**. Its key is the bundle's package
name, which is also the namespace: the Loader entry id the bundle's patch
declares.
The card behaves the way the other plugin cards do: it starts collapsed, its
header is a button that discloses the controls in place, the header marks it when
it holds unsaved edits — visible even while collapsed, since staged edits outlive
being folded away — and a save that lands collapses it again.
That the namespace is the entry id is the point. Since dsh 0.1.7-rc.2 the
framework projects this plugin's exported `Config` schema onto the entry that
composed it, so there is one source of truth for what is configurable. Every
field is marked `volatile()`, which is what puts it on the page, and
`embeddingApiKey` additionally carries `role('secret')`, so the browser is told
whether a key is stored and never receives the key itself.
Every setting in the table below is editable there; overridden fields are
marked, a draft that is not a valid value blocks the save, and Reset returns a
field to the deployment default rather than to empty. A save is one
revision-fenced write, so a second editor cannot be silently overwritten.
The card is the bundle's second half — the browser half. It needs no build step
and ships as plain ESM in the module-loader wrapper, but the platform snapshots
client bundles at activation, so **a client half only appears after the profile
restarts.** Without it the namespace still works — it is editable in
`$DSH_HOME/settings.yaml` and through the tool's `configure` action — there is
simply no form drawn for it, because the tab renders the intersection of served
namespaces and registered cards.
restarts.** Without it the namespace still works — it is editable in the
profile's patch layer and through the tool's `configure` action — there is
simply no form drawn for it, because the page only renders a bundle that
registers a configuration entry for itself.
## Configure it
@@ -84,7 +93,9 @@ Then set it up:
}
```
Values land in the `dsh-slopo` section of `$DSH_HOME/settings.yaml`.
Values land in the `config` of the `dsh-slopo` row in the profile's patch layer
(and, from the browser, in the same row through the settings domain — the card
and the tool write the same place).
| Setting | Meaning |
| --- | --- |
@@ -228,6 +239,29 @@ Every action indexes first so the gate runs before anything expensive, and
## Things worth knowing
**The harness seam moved under this plugin once, and the failure was silent.**
dsh 0.1.7-rc.2 replaced three things this bundle used:
| was | is |
| --- | --- |
| `ctx.settings.installSection(ctx, ns, Config, config, …)` | the exported `Config` itself, projected onto the Loader entry; `ctx.settings.configure({ auto: false })` claims the entry's own page |
| a plugin-chosen settings namespace + a `settings.plugin.item` card | the entry id, plus a `plugins.bundle.config` card keyed by the bundle's package name |
| `ctx.shell.run(spec)` | `await ctx.shell.execute(ctx.shell.resolve(request)).result()` |
All three broke quietly rather than loudly. `installSection` was missing, so the
call threw into a `try/catch` that logged a warning nobody reads; the card's slot
and the `settingsScope` service it asked for no longer existed, so the card
simply never rendered; and `shell.run` threw only on the paths that actually run
a command. On top of that, a `Config` field that is not marked `volatile()` is
invisible to every settings UI, and the `jobs` registry wants `exec.agent.id`
(a session id) as its owner — handing it the agent object registered a job no
session could collect.
Two habits follow. First, this bundle's tests bind to the *installed* harness's
own slot catalog and package exports, so a rename fails a test instead of
quietly emptying a page. Second, the browser half keeps its slot and service
names in exactly one place each, so the next rename is a one-line edit.
**Five settings are frozen at first index.** `source_dir`, `embedding_model`,
`embedding_dimensions` and both node-count thresholds are written into
`slopo.db` on the first `index`, and every later run refuses to mix generations:
@@ -296,18 +330,38 @@ that silently does nothing. Two real bugs were caught this way: a config
renderer that would write the API key to disk if handed one, and `--config-version`
placed before the subcommand, which slopo rejects with a usage error.
The mock's seams are shaped like the harness's, deliberately, because that is
what makes this file a contract test. `ctx.shell` exposes `resolve`/`execute`
with a handle whose `result()` settles the run — there is no `run(spec)`, which
is exactly the method dsh 0.1.7-rc.2 removed. `ctx.settings` has no
`installSection` either; it has `configure` and `update`, and the tests assert
that the plugin claims its own page and writes to the entry id the bundle patch
declares. The `jobs` fake records the spec, so the test can check that a
background job is owned by a **session id** and that its `done` resolves a
`JobOutcome` carrying `result`.
The browser half has no browser to run in, so its tests evaluate the bundle the
way the real module loader does — inside `window.__ModuleLoader__.load` — with a
fake `require` that answers `react` and throws on anything else. That last part is
deliberate: it checks the card stays inside the platform's seed table, which is
the constraint a hand-written client half is most likely to break. The card is
then rendered and its handlers driven, which is not a substitute for looking at
it but does cover the wiring it cannot work without.
fake `require` that answers `react` and `@deepseek-ai/dsh-client-ui-primitives`
and throws on anything else. That last part is deliberate: it checks the card
stays inside the platform's seed table, which is the constraint a hand-written
client half is most likely to break. The card is then rendered and its handlers
driven — including the field specs, run through a copy of the shipped
`SettingsFormModel` staging semantics — which is not a substitute for looking at
it but does cover the wiring it cannot work without. Three further tests read the
*installed* harness instead: that `plugins.bundle.config` is a declared slot and
`settings.plugin.item` is gone, that the slot is dispatched by bundle package
name, and that every primitive the card requires is still exported. They skip
when no global dsh installation is present.
`node_modules/@deepseek-ai` is a local symlink to the installed harness so the
`@deepseek-ai/*` imports resolve during tests. It is gitignored and is not a
declared dependency — an out-of-tree bundle that depended on an in-box package
would install a second copy that drifts from the host's.
declared dependency — `peerDependencies` states the compatibility contract
instead, and an out-of-tree bundle that depended on an in-box package would
install a second copy that drifts from the host's. The declared peers are what
the harness's compatibility gate reads: a plugin whose `@deepseek-ai/dsh-*`
ranges do not admit the running version is refused at startup rather than
half-loaded.
## Releases and updating
@@ -321,14 +375,20 @@ would install a second copy that drifts from the host's.
`vX.Y.Z`;
4. creates the Gitea release with those notes as the body.
The bump is a patch unless a commit in the release says otherwise:
The bump is read from the commit subjects in the release, so the intent travels
with the change instead of living in a file someone has to remember to update:
| in a commit subject | effect |
|---|---|
| `[minor]` | release as a minor version |
| `[major]` | release as a major version |
| `feat`, as its own word (`feat:`, `feat(x):`) | minor — `0.1.0` → `0.2.0` |
| `feat!:` / `fix(x)!:` / `BREAKING` | major — `0.1.0` → `1.0.0` |
| `fix:`, `docs:`, prose, anything else | patch — `0.1.0` → `0.1.1` |
| `[minor]` / `[major]` | force the bump when the subject cannot carry it |
| `[skip release]` | this push publishes nothing |
Only the **subject** line is read, and `feat` has to be a word — `Fix the feature
flag leak` is a patch, not a minor.
With no tags yet, the version already in `package.json` is what gets tagged — the
first release should match what the code says, not invent a number.
@@ -338,8 +398,16 @@ commits carry one; otherwise the notes are a flat list. Prose commit subjects ge
no invented headings.
The logic lives in `.gitea/scripts/release.sh` so it runs without a runner, and
`npm run test:release` exercises it — 31 checks, including an empty range, a
repository with no tags yet, and a patch number already at 9.
`npm run test:release` exercises it — an empty range, a repository with no tags
yet, a patch number already at 9, a `feat` that is only a word. `npm run
release:plan` says whether the current HEAD would release, and `npm run
release:notes` prints the notes it would publish, both without releasing.
The runner needs nothing but Gitea's own job token (`secrets.GITEA_TOKEN`), which
`permissions: contents: write` in the workflow is what unlocks. Two caveats worth
knowing before the first push: Actions has to be enabled on the repository, and a
runner with the `ubuntu-latest` label has to be registered against the instance —
otherwise the run sits queued forever.
### Making `dsh plugin update` work