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-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. | | `/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 `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 `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, in Settings → Plugins → Plugin configuration. | | 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**, All three skills are **user-invocable and deliberately not model-invocable**,
matching slopo's own agent configs. Typing `/slopo-review` anywhere in a message 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 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 ## Settings
The plugin contributes a card to **Settings → Plugins → Plugin configuration**, The plugin ships a configuration page on the **Plugins** page — the sidebar
keyed on the `dsh-slopo` settings namespace the Host registers. Every setting in entry that lists bundles, rows and official plugins — reached by opening the
the table below is editable there, overridden fields are marked, and Reset `dsh-slopo` bundle and pressing **Configure**. Its key is the bundle's package
returns a field to the deployment default rather than to empty. 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 That the namespace is the entry id is the point. Since dsh 0.1.7-rc.2 the
header is a button that discloses the controls in place, the header marks it when framework projects this plugin's exported `Config` schema onto the entry that
it holds unsaved edits — visible even while collapsed, since staged edits outlive composed it, so there is one source of truth for what is configurable. Every
being folded away — and a save that lands collapses it again. 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 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 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 client bundles at activation, so **a client half only appears after the profile
restarts.** Without it the namespace still works — it is editable in restarts.** Without it the namespace still works — it is editable in the
`$DSH_HOME/settings.yaml` and through the tool's `configure` action — there is profile's patch layer and through the tool's `configure` action — there is
simply no form drawn for it, because the tab renders the intersection of served simply no form drawn for it, because the page only renders a bundle that
namespaces and registered cards. registers a configuration entry for itself.
## Configure it ## 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 | | Setting | Meaning |
| --- | --- | | --- | --- |
@@ -228,6 +239,29 @@ Every action indexes first so the gate runs before anything expensive, and
## Things worth knowing ## 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`, **Five settings are frozen at first index.** `source_dir`, `embedding_model`,
`embedding_dimensions` and both node-count thresholds are written into `embedding_dimensions` and both node-count thresholds are written into
`slopo.db` on the first `index`, and every later run refuses to mix generations: `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` 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. 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 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 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 fake `require` that answers `react` and `@deepseek-ai/dsh-client-ui-primitives`
deliberate: it checks the card stays inside the platform's seed table, which is and throws on anything else. That last part is deliberate: it checks the card
the constraint a hand-written client half is most likely to break. The card is stays inside the platform's seed table, which is the constraint a hand-written
then rendered and its handlers driven, which is not a substitute for looking at client half is most likely to break. The card is then rendered and its handlers
it but does cover the wiring it cannot work without. 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 `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 `@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 declared dependency — `peerDependencies` states the compatibility contract
would install a second copy that drifts from the host's. 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 ## Releases and updating
@@ -321,14 +375,20 @@ would install a second copy that drifts from the host's.
`vX.Y.Z`; `vX.Y.Z`;
4. creates the Gitea release with those notes as the body. 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 | | in a commit subject | effect |
|---|---| |---|---|
| `[minor]` | release as a minor version | | `feat`, as its own word (`feat:`, `feat(x):`) | minor — `0.1.0` → `0.2.0` |
| `[major]` | release as a major version | | `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 | | `[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 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. 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. no invented headings.
The logic lives in `.gitea/scripts/release.sh` so it runs without a runner, and 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 `npm run test:release` exercises it — an empty range, a repository with no tags
repository with no tags yet, and a patch number already at 9. 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 ### Making `dsh plugin update` work
+393 -317
View File
@@ -1,26 +1,37 @@
/** /**
* dsh-slopo — browser half: the card in Settings → Plugins → Plugin configuration. * dsh-slopo — browser half: the bundle's configuration page on the Plugins page.
* *
* The Host half registers a `dsh-slopo` settings namespace. This file registers * Where the card lives, and why it moved. Up to dsh 0.1.6 the settings surfaces
* a card under that exact namespace key. Both are required: the tab renders the * were: a settings namespace a plugin registered for itself, plus a
* INTERSECTION of the namespaces the Host serves and the cards the browser * `settings.plugin.item` card the plugin's client half registered under that
* claims, so a served namespace with no card draws nothing at all, and a card * namespace key, rendered inside Settings → Plugins → Plugin configuration.
* whose namespace is not served is never dispatched. * 0.1.7-rc.2 removed both halves of that contract:
* *
* The chrome follows the framework's `PluginCard`: the root is an `<li>` (the * * `ctx.settings.installSection(...)` no longer exists, and a namespace is no
* tab renders every card into a `<ul>`), the header is its own `<button>` that * longer chosen by the plugin — it IS the Loader profile entry id, which the
* discloses the controls in place, the card starts collapsed, it marks itself * bundle's own patch declares (`dsh-slopo`).
* when it holds unsaved edits, and it collapses again after a successful save. * * the `settings.plugin.item` slot and the `settingsScope` service no longer
* Nothing about that is imported — `PluginCard` lives in that package's client * exist either. Configuration now lives on the Plugins page, and a bundle's
* bundle and the client purity gate forbids cross-plugin value imports — so the * own settings register into `plugins.bundle.config` keyed by the bundle's
* behaviour is reproduced here rather than borrowed. * PACKAGE NAME, which the page only dispatches for a package it lists.
*
* The service that replaced `settingsScope` is `configForms`, and the object it
* hands out for one entry (`configForms.get(id)`) is API-compatible with what
* this card used before: `getSnapshot()`, `subscribe()`, `mutate(ops, revision)`.
* What actually changed is the chrome. The framework now ships the form
* vocabulary this card hand-rolled — `SettingsFormModel` stages the edits and
* fences the save with the revision the drafts were read at, `SettingsForm`
* draws the frame and the single Save, and the field controls carry the
* overridden badge, the reset and the invalid-edit block. Reproducing that by
* hand is what the file used to do; using it is the point.
* *
* Hand-written plain JavaScript inside the module-loader wrapper, with no build * Hand-written plain JavaScript inside the module-loader wrapper, with no build
* step and no bundler. `dsh plugin add git+…` fetches sources rather than build * step and no bundler. `dsh plugin add git+…` fetches sources rather than build
* output, so a `prepare` script would make every user allowlist a build before * output, so a `prepare` script would make every user allowlist a build before
* the first install succeeds. The only import is `react`, which is in the * the first install succeeds. The imports are `react` and
* platform seed table; anything outside that table would have to be bundled * `@deepseek-ai/dsh-client-ui-primitives`, both platform seed words; anything
* into this file or declared in `dsh.client.external`. * outside that table would have to be bundled into this file or declared in
* `dsh.client.external`.
* *
* Styling is inline on purpose, for the same reason: there is no stylesheet this * Styling is inline on purpose, for the same reason: there is no stylesheet this
* file may reach. * file may reach.
@@ -32,338 +43,403 @@ window.__ModuleLoader__.load({
var exports = module.exports var exports = module.exports
const react = require('react') const react = require('react')
const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
const NS = 'dsh-slopo'
const h = react.createElement const h = react.createElement
const { Checkbox, SettingsForm, SettingsFormModel, SettingsSecretField, SettingsValueField } = primitives
const TITLE = 'Slopo' const { settingsNumberField, settingsTextField } = primitives
const DESCRIPTION = 'Embedding backend, index scope and the budget that stops a large embed.'
/** /**
* The settings this card edits. * The bundle's package name.
* *
* This list mirrors the Host schema in index.js. It is duplicated rather * One string plays three roles and they must agree: it is the npm package
* than fetched because the card renders its own controls — there is no * the Plugins page lists, the Loader entry id inside that bundle's patch
* generic schema-to-form renderer in the framework — and a field the Host * (and therefore the settings namespace `configForms` addresses), and the
* does not know is rejected there anyway, so a drift shows up as a clear * `plugins.bundle.config` key this card registers under.
* error rather than silent data loss. */
const BUNDLE = 'dsh-slopo'
/** Dictionary namespace owned by this plugin. */
const NS = 'dsh-slopo'
/** The one write-only field: a credential the wire never carries back. */
const API_KEY_FIELD = 'embeddingApiKey'
const DESCRIPTION =
'Embedding backend, index scope and the budget that stops a large embed.'
/** The card's own copy. */
const en = {
description: DESCRIPTION,
overridden: 'Overridden',
reset: 'Reset to default',
readOnly: 'This deployment stores settings read-only.',
unavailable: 'This plugin is not loaded, so it cannot be configured right now.',
save: 'Save',
saving: 'Saving…',
saveFailed: 'The deployment did not accept these values; they were left for you to correct.',
invalidNumber: 'Enter a number, or leave blank to use the default.',
apiKeySet: 'A key is stored.',
apiKeyUnset: 'No key is stored.',
listHint: 'Comma separated.',
'binary.label': 'slopo executable',
'binary.hint': 'Empty discovers it: managed install, then PATH, then ~/.local/bin.',
'autoInstall.label': 'Install slopo with uv when missing',
'autoInstall.hint': 'Installs into $DSH_HOME with uv, so the executable survives a container recreation.',
'embeddingModel.label': 'Embedding model',
'embeddingModel.hint':
'LiteLLM string, provider prefix included — e.g. ollama/unclemusclez/jina-embeddings-v2-base-code:latest',
'embeddingDimensions.label': 'Embedding dimensions',
'embeddingDimensions.hint': 'Must match what the model returns. Frozen into the index at first use.',
'embeddingApiBase.label': 'Embedding API base',
'embeddingApiBase.hint': 'e.g. http://127.0.0.1:11434',
'embeddingApiKey.label': 'Embedding API key',
'embeddingApiKey.hint':
'Passed to slopo in its environment and never written into the slopo config file. It is write-only here: the deployment reports whether one is stored, never the key.',
'embeddingParamsJson.label': 'Extra embedding parameters',
'embeddingParamsJson.hint': 'A JSON object, merged into embedding_params.',
'sourceDir.label': 'Source directory',
'sourceDir.hint': "Empty uses the session's working directory.",
'sourceDirExclude.label': 'Extra exclude patterns',
'sourceDirExclude.hint': 'Gitignore style, comma separated. Added to the defaults rather than replacing them.',
'useDefaultExcludes.label': 'Exclude node_modules and build output',
'useDefaultExcludes.hint':
'Off means slopo parses your dependencies too — on a repo with node_modules, the difference between ~1,500 and ~114,000 code units.',
'maxIndexUnits.label': 'Refuse to embed more than this many units',
'maxIndexUnits.hint': '0 removes the limit. Makes a large scope a question rather than an hours-long embed.',
'ignoreFile.label': 'Ignore file',
'ignoreFile.hint': 'Point this at the repository to version the dismissed clusters.',
'stateDir.label': 'State directory',
'stateDir.hint': 'Empty uses $DSH_HOME/dsh-slopo/workspaces/<workspace>.',
'embeddingRequestDelay.label': 'Delay between embedding requests (ms)',
'embeddingRequestDelay.hint': 'Rate limit for a provider that rejects bursts. 0 sends as fast as it can.',
'timeoutMs.label': 'Foreground timeout (ms)',
'timeoutMs.hint': 'How long one foreground slopo run may take before it is killed.',
}
/**
* The fields this card edits, in display order.
*
* This list mirrors the Host schema in index.js, including `kind`. It is
* duplicated rather than fetched because the card renders its own controls —
* there is no schema-to-form renderer in the framework — and a field the
* Host does not know is rejected there anyway, so a drift shows up as a
* clear error rather than silent data loss. Every key must be a `volatile()`
* field of that schema or the write is refused.
*/ */
const FIELDS = [ const FIELDS = [
{ { key: 'embeddingModel', kind: 'text' },
key: 'embeddingModel', { key: 'embeddingDimensions', kind: 'number' },
label: 'Embedding model', { key: 'embeddingApiBase', kind: 'text' },
hint: 'LiteLLM string, provider prefix included — e.g. ollama/unclemusclez/jina-embeddings-v2-base-code:latest', { key: 'embeddingApiKey', kind: 'secret' },
}, { key: 'embeddingParamsJson', kind: 'text' },
{ key: 'embeddingDimensions', label: 'Embedding dimensions', kind: 'number', hint: 'Must match what the model returns. Frozen into the index at first use.' }, { key: 'binary', kind: 'text' },
{ key: 'embeddingApiBase', label: 'Embedding API base', hint: 'e.g. http://127.0.0.1:11434' }, { key: 'autoInstall', kind: 'boolean' },
{ { key: 'sourceDir', kind: 'text' },
key: 'embeddingApiKey', { key: 'sourceDirExclude', kind: 'list' },
label: 'Embedding API key', { key: 'useDefaultExcludes', kind: 'boolean' },
kind: 'password', { key: 'maxIndexUnits', kind: 'number' },
hint: 'Passed to slopo in its environment and never written into the slopo config file.', { key: 'ignoreFile', kind: 'text' },
}, { key: 'stateDir', kind: 'text' },
{ key: 'embeddingParamsJson', label: 'Extra embedding parameters', hint: 'A JSON object, merged into embedding_params.' }, { key: 'embeddingRequestDelay', kind: 'number' },
{ key: 'binary', label: 'slopo executable', hint: 'Empty discovers it: managed install, then PATH, then ~/.local/bin.' }, { key: 'timeoutMs', kind: 'number' },
{ key: 'autoInstall', label: 'Install slopo with uv when missing', kind: 'boolean' },
{ key: 'sourceDir', label: 'Source directory', hint: 'Empty uses the session working directory.' },
{
key: 'sourceDirExclude',
label: 'Extra exclude patterns',
kind: 'list',
hint: 'Comma separated, gitignore style. Added to the defaults rather than replacing them.',
},
{
key: 'useDefaultExcludes',
label: 'Exclude node_modules and build output',
kind: 'boolean',
hint: 'Off means slopo parses your dependencies too — on a repo with node_modules, the difference between ~1,500 and ~114,000 code units.',
},
{
key: 'maxIndexUnits',
label: 'Refuse to embed more than this many units',
kind: 'number',
hint: 'Makes a large scope a question rather than an hours-long embed. 0 removes the limit.',
},
{ key: 'stateDir', label: 'State directory', hint: 'Empty uses $DSH_HOME/dsh-slopo/workspaces/<workspace>.' },
{ key: 'ignoreFile', label: 'Ignore file', hint: 'Point this at the repository to version the dismissed clusters.' },
{ key: 'embeddingRequestDelay', label: 'Delay between embedding requests (ms)', kind: 'number' },
{ key: 'timeoutMs', label: 'Foreground timeout (ms)', kind: 'number' },
] ]
const BORDER = '1px solid var(--dsh-border, #3a3a3a)' /** A label or hint from this card's dictionary. */
const copy = (t, key, part) => t(`${key}.${part}`)
const S = { /** Every field's label and hint, resolving against the active locale. */
// The tab renders into a <ul>, so the root must be an <li> or the list const describeField = (t, field) => ({
// styling never reaches it. key: field.key,
card: { listStyle: 'none', border: BORDER, borderRadius: '8px', margin: '8px 0', overflow: 'hidden' }, kind: field.kind,
cardOpen: { background: 'var(--dsh-surface, rgba(255,255,255,0.02))' }, label: copy(t, field.key, 'label'),
header: { hint: copy(t, field.key, 'hint'),
display: 'flex', id: `plugin-config-${BUNDLE}-${field.key}`,
alignItems: 'center', })
gap: '12px',
width: '100%', /**
padding: '12px 16px', * A boolean field's draft conversion.
border: 'none', *
background: 'transparent', * The shared kit exports number and text specs only; a switch is a set of
color: 'inherit', * `true`/`false`, so the control has nothing to parse but the two literals
textAlign: 'left', * it renders. Anything else is an invalid draft, which blocks the save
cursor: 'pointer', * rather than silently writing `false`.
font: 'inherit', */
const settingsBooleanField = (field) => ({
field,
format: (value) => (value === true ? 'true' : 'false'),
parse: (text) => {
const trimmed = text.trim()
if (trimmed === 'true') return { kind: 'set', value: true }
if (trimmed === 'false') return { kind: 'set', value: false }
return undefined
}, },
headText: { display: 'flex', flexDirection: 'column', gap: '2px', flex: '1 1 auto', minWidth: 0 }, })
name: { fontSize: '14px', fontWeight: 600 },
description: { fontSize: '11px', opacity: 0.65, lineHeight: 1.45 }, /**
pending: { * A string-array field's draft conversion: a comma-separated list.
flex: '0 0 auto', *
fontSize: '10px', * An empty draft is an empty list rather than a clear: the schema declares
textTransform: 'uppercase', * this field's default as `[]`, so "no extra patterns" and "fall back to
letterSpacing: '0.04em', * the composition layer" are the same answer.
padding: '2px 6px', */
borderRadius: '4px', const settingsListField = (field) => ({
border: BORDER, field,
opacity: 0.8, format: (value) => (Array.isArray(value) ? value.join(', ') : ''),
}, parse: (text) => ({
chevron: { flex: '0 0 auto', fontSize: '10px', opacity: 0.7, transition: 'transform 120ms ease' }, kind: 'set',
chevronOpen: { transform: 'rotate(180deg)' }, value: text
body: { padding: '0 16px 16px 16px' }, .split(/[,\n]/)
readOnly: { fontSize: '11px', opacity: 0.75, marginTop: 0, marginBottom: '12px' }, .map((part) => part.trim())
hint: { fontSize: '11px', opacity: 0.65, marginTop: '2px', lineHeight: 1.45 }, .filter((part) => part !== ''),
row: { marginTop: '12px' }, }),
label: { display: 'block', fontSize: '12px', fontWeight: 500, marginBottom: '4px' }, })
input: {
width: '100%', /** The spec one field's drafts are converted with. */
boxSizing: 'border-box', function specFor(field) {
padding: '6px 8px', if (field.kind === 'number') return settingsNumberField(field.key)
fontSize: '12px', if (field.kind === 'boolean') return settingsBooleanField(field.key)
borderRadius: '6px', if (field.kind === 'list') return settingsListField(field.key)
border: BORDER, return settingsTextField(field.key)
background: 'transparent',
color: 'inherit',
},
actions: { display: 'flex', gap: '8px', alignItems: 'center', marginTop: '16px', flexWrap: 'wrap' },
button: {
padding: '6px 12px',
fontSize: '12px',
borderRadius: '6px',
border: BORDER,
background: 'transparent',
color: 'inherit',
cursor: 'pointer',
},
primary: { fontWeight: 600 },
note: { fontSize: '11px', opacity: 0.75, marginLeft: 'auto' },
} }
/** The editable string form of one field's value. */ /** The form frame's copy, read from this card's dictionary. */
function toText(field, value) { const formLabels = (t) => ({
if (field.kind === 'list') return Array.isArray(value) ? value.join(', ') : '' unavailable: t('unavailable'),
if (field.kind === 'boolean') return value === true ? 'true' : 'false' readOnly: t('readOnly'),
if (value === undefined || value === null) return '' saveFailed: t('saveFailed'),
return String(value) save: t('save'),
} saving: t('saving'),
})
/** The value to write for one field, from its string form. */ /** The field-control copy shared by every row. */
function fromText(field, text) { const fieldLabels = (t) => ({
const trimmed = typeof text === 'string' ? text.trim() : '' overriddenLabel: t('overridden'),
if (field.kind === 'number') return Number(trimmed) resetLabel: t('reset'),
if (field.kind === 'boolean') return trimmed === 'true' invalidLabel: t('invalidNumber'),
if (field.kind === 'list') { })
return trimmed === ''
? []
: trimmed
.split(/[,\n]/)
.map((part) => part.trim())
.filter((part) => part !== '')
}
return text
}
/** Build a draft of every field from a resolved section. */ /** One staged text row. */
function toDraft(value) { function Row(props) {
const draft = {} const { t, state, disabled, edit, resetField, meta } = props
for (const field of FIELDS) draft[field.key] = toText(field, value?.[field.key]) return h(SettingsValueField, {
return draft id: meta.id,
} label: meta.label,
hint: meta.hint,
function Field(props) {
const { field, value, overridden, disabled, onChange } = props
const common = {
style: S.input,
value,
disabled, disabled,
'aria-label': field.label, numeric: meta.kind === 'number',
onChange: (event) => onChange(event.target.value), ...fieldLabels(t),
} ...state,
onEdit: (text) => edit(meta.key, text),
onReset: () => resetField(meta.key),
})
}
/**
* One staged switch row.
*
* `Checkbox` owns native keyboard and form semantics but no override
* affordance, so the badge and the reset sit beside it — the same pair a
* `SettingsValueField` draws for a text row.
*/
function SwitchRow(props) {
const { t, state, disabled, edit, resetField, meta } = props
const checked = state.text === 'true'
return h( return h(
'div', 'div',
{ style: S.row }, { style: { display: 'flex', flexDirection: 'column', gap: '4px' } },
h( h(
'label', 'div',
{ style: S.label }, { style: { display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap' } },
field.label, h(Checkbox, {
overridden ? h('span', { style: { opacity: 0.6, fontWeight: 400 } }, ' · overridden') : null, label: meta.label,
checked,
disabled,
onChange: (next) => edit(meta.key, next ? 'true' : 'false'),
}),
state.overridden
? h(
'button',
{
type: 'button',
disabled,
onClick: () => resetField(meta.key),
style: {
font: 'inherit',
fontSize: '11px',
cursor: 'pointer',
background: 'transparent',
color: 'inherit',
opacity: 0.75,
border: 'none',
padding: 0,
},
},
`${t('overridden')} · ${t('reset')}`,
)
: null,
), ),
field.kind === 'boolean' h('div', { style: { fontSize: '11px', opacity: 0.65, lineHeight: 1.45 } }, meta.hint),
? h('input', {
type: 'checkbox',
checked: value === 'true',
disabled,
'aria-label': field.label,
onChange: (event) => onChange(event.target.checked ? 'true' : 'false'),
})
: h('input', { ...common, type: field.kind === 'password' ? 'password' : 'text' }),
field.hint === undefined ? null : h('div', { style: S.hint }, field.hint),
)
}
function Card(props) {
const scope = props.scope
const [snapshot, setSnapshot] = react.useState(() => scope.getSnapshot())
const [draft, setDraft] = react.useState(null)
const [busy, setBusy] = react.useState(false)
const [note, setNote] = react.useState('')
// Collapsed on mount, like every other card in the tab.
const [open, setOpen] = react.useState(false)
react.useEffect(() => scope.subscribe(() => setSnapshot(scope.getSnapshot())), [scope])
const status = snapshot.status
const value = snapshot.value
const baseline = react.useMemo(() => toDraft(value), [value])
const current = draft ?? baseline
const user = snapshot.user
if (status === 'unavailable') {
// A deployment without the Host half, or a remote browser: show no trace.
return null
}
const dirtyKeys = FIELDS.filter((field) => current[field.key] !== baseline[field.key]).map((field) => field.key)
const overriddenKeys = user !== null && typeof user === 'object' ? Object.keys(user) : []
const writable = snapshot.writable === true
const save = async () => {
setBusy(true)
setNote('')
try {
for (const field of FIELDS) {
const next = current[field.key]
if (next === baseline[field.key]) continue
// Clearing a field is an unset, not a write of "", so the value
// re-inherits the composition layer instead of becoming empty.
if (String(next).trim() === '') await scope.unset(field.key)
else await scope.set(field.key, fromText(field, next))
}
setDraft(null)
setNote('Saved.')
// A landed save folds the card back up. A failed or blocked one stays
// open with the drafts, which is the whole point of staging them.
setOpen(false)
} catch (error) {
setNote(`Save failed: ${error?.message ?? error}`)
} finally {
setBusy(false)
}
}
const reset = async () => {
setBusy(true)
setNote('')
try {
for (const key of overriddenKeys) await scope.unset(key)
setDraft(null)
setNote('Reset to the deployment defaults.')
} catch (error) {
setNote(`Reset failed: ${error?.message ?? error}`)
} finally {
setBusy(false)
}
}
return h(
'li',
{ style: open ? { ...S.card, ...S.cardOpen } : S.card },
h(
'button',
{
type: 'button',
style: S.header,
'aria-expanded': open,
'aria-label': `${open ? 'Collapse' : 'Expand'}: ${TITLE}`,
onClick: () => setOpen(!open),
},
h(
'span',
{ style: S.headText },
h('span', { style: S.name }, TITLE),
h('span', { style: S.description }, DESCRIPTION),
),
// Marked on the header, so unsaved edits are visible while collapsed.
dirtyKeys.length > 0 ? h('span', { style: S.pending }, 'unsaved') : null,
h('span', { style: open ? { ...S.chevron, ...S.chevronOpen } : S.chevron }, '▾'),
),
open
? h(
'div',
{ style: S.body },
writable ? null : h('p', { style: S.readOnly, role: 'status' }, 'This namespace is read-only here.'),
...FIELDS.map((field) =>
h(Field, {
key: field.key,
field,
value: current[field.key],
overridden: overriddenKeys.includes(field.key),
disabled: busy || !writable,
onChange: (next) => setDraft({ ...current, [field.key]: next }),
}),
),
h(
'div',
{ style: S.actions },
h(
'button',
{ type: 'button', style: { ...S.button, ...S.primary }, disabled: busy || dirtyKeys.length === 0, onClick: save },
dirtyKeys.length === 0 ? 'Save' : `Save ${dirtyKeys.length} change${dirtyKeys.length === 1 ? '' : 's'}`,
),
h(
'button',
{ type: 'button', style: S.button, disabled: busy || overriddenKeys.length === 0, onClick: reset },
`Reset ${overriddenKeys.length} override${overriddenKeys.length === 1 ? '' : 's'}`,
),
note === '' ? null : h('span', { style: S.note }, note),
),
)
: null,
) )
} }
/** /**
* Register the card, but only once a settings transport exists. * Render the bundle's page: the form, or nothing while the deployment does
* not serve the entry.
* *
* `ctx.inject(['settingsScope'], …)` rather than a top-level `inject`, * @param props - the card's state snapshot, the form actions, and locale copy.
* because a required dependency that is unsatisfied means this plugin never * @returns the settings form.
* applies at all — the card would vanish along with everything else instead */
* of simply not appearing. function SlopoCard(props) {
const { t } = props
const state = props.useSlopoCard((snapshot) => snapshot)
// `view` is `page` for a bundle's configuration slot; the bundle's
// one-liner is the package's own description, so there is no summary view
// to answer here.
const disabled = !state.writable
const children = []
for (const field of FIELDS) {
const meta = describeField(t, field)
const shared = { key: field.key, t, state: state[field.key], disabled, edit: props.edit, resetField: props.resetField, meta }
children.push(field.kind === 'boolean' ? h(SwitchRow, shared) : h(Row, shared))
}
return h(
SettingsForm,
{ labels: formLabels(t), state, onSave: props.save, onDiscard: props.discard },
h(
'div',
{ style: { display: 'flex', flexDirection: 'column', gap: '14px' } },
// The key is write-only, so it is not one of the rows above.
h(SettingsSecretField, {
id: `plugin-config-${BUNDLE}-${API_KEY_FIELD}`,
label: copy(t, API_KEY_FIELD, 'label'),
hint: copy(t, API_KEY_FIELD, 'hint'),
disabled,
text: state.apiKey.text,
configured: state.apiKeyConfigured,
stateLabel: state.apiKeyConfigured ? t('apiKeySet') : t('apiKeyUnset'),
onEdit: (text) => props.edit(API_KEY_FIELD, text),
}),
...children,
),
)
}
/**
* The card's staged form over the `dsh-slopo` profile entry.
*
* `SettingsFormModel` is what makes Save the only write: a draft is staged
* against the revision it was read at, an invalid draft blocks the save
* instead of being dropped, and a refused save keeps its drafts so the user
* can correct them. The model subscribes to the scope itself and republishes
* through {@link SettingsFormModel.bind}, so this class only has to describe
* what one snapshot contains.
*/
class SlopoCardController {
/**
* @param ctx - the browser plugin context, whose `configForms` reports the entry's secret slots.
* @param scope - the shared configuration form for the `dsh-slopo` entry.
*/
constructor(ctx, scope) {
const specs = FIELDS.filter((field) => field.kind !== 'secret').map(specFor)
const secrets = [
{
field: API_KEY_FIELD,
// The credential is a settings field, not a separate credential
// reference, so its one-way write is the same fenced mutation the
// ordinary rows use — with the value never coming back.
write: (text) => scope.mutate([{ op: 'set', path: [API_KEY_FIELD], value: text }]),
},
]
this.ctx = ctx
this.form = new SettingsFormModel(scope, specs, secrets)
this.store = this.form.bind(() => this.projection())
}
/** Whether the deployment reports a stored key; the value itself never rides. */
readApiKeyConfigured() {
const view = this.ctx.configForms.describe().getSnapshot().view
const entry = view?.namespaces?.find((row) => row.ns === BUNDLE)
const slot = entry?.secrets?.find(
(secret) => Array.isArray(secret.path) && secret.path.length === 1 && secret.path[0] === API_KEY_FIELD,
)
return slot?.set === true
}
/** Build the snapshot the card renders from the form's current reads. */
projection() {
const state = {
...this.form.shell(),
apiKey: this.form.field(API_KEY_FIELD),
apiKeyConfigured: this.readApiKeyConfigured(),
}
for (const field of FIELDS) {
if (field.kind !== 'secret') state[field.key] = this.form.field(field.key)
}
return state
}
/** Build the face the slot registration injects: the card's state and its actions. */
inject() {
return { hooks: { slopoCard: this.store }, ...this.form.actions() }
}
/** Release the form's accepted-value subscription. */
dispose() {
this.form.dispose()
}
}
/**
* Register the card, but only once the settings transport exists.
*
* `ctx.inject(['configForms'], …)` rather than a top-level `inject`: a
* required dependency that is unsatisfied means this plugin never applies at
* all, and the card would simply not exist on a deployment that composes no
* settings surface — which is the correct outcome, not a load failure.
*
* `whileServed` is the second gate. The registration only stands while the
* deployment actually serves the `dsh-slopo` namespace, so an uninstalled or
* disabled bundle leaves no card behind, and the Plugins page's own
* `plugins.bundle.config` ledger stays in step with what the Host serves.
*/ */
function apply(ctx) { function apply(ctx) {
ctx.inject(['settingsScope'], (scoped) => { ctx.effect(
const binder = scoped.settingsScope () =>
if (binder === undefined) return ctx.locale.register(NS, {
const scope = binder.bind({ namespace: NS }) en,
scoped.slots.inject('settings.plugin.item', () => }),
scoped.slots.register( 'dsh-slopo: card dictionary',
{ )
name: 'settings.plugin.item', ctx.inject(['configForms'], (child) => {
key: NS, const forms = child.configForms
inject: () => ({ scope }), if (forms === undefined) return
}, const controller = new SlopoCardController(child, forms.get(BUNDLE))
(props) => h(Card, props), child.effect(
), () => () => controller.dispose(),
'dsh-slopo: form subscription',
)
child.effect(
() =>
forms.whileServed([BUNDLE], () =>
child.slots.inject('plugins.bundle.config', () =>
child.slots.register(
{
name: 'plugins.bundle.config',
key: BUNDLE,
locale: NS,
inject: () => controller.inject(),
},
SlopoCard,
),
),
),
'dsh-slopo: Plugins page card',
) )
}) })
} }
module.exports = { name: NS, inject: ['slots'], apply } module.exports = { name: BUNDLE, inject: ['slots', 'locale'], apply }
return module.exports return module.exports
}, },
}) })
+8 -5
View File
@@ -4,8 +4,10 @@
# * three skills on `ctx.skills`, all user-invocable and NOT model-invocable # * three skills on `ctx.skills`, all user-invocable and NOT model-invocable
# (slopo's own agent configs make the same choice, because every one of # (slopo's own agent configs make the same choice, because every one of
# these commands can spend minutes embedding); # these commands can spend minutes embedding);
# * a configuration namespace on `ctx.settings`, so the embedding backend is # * the `dsh-slopo` configuration, which is this plugin's own exported Config:
# editable at runtime rather than baked into this file; # since dsh 0.1.7-rc.2 the framework projects that schema onto this entry and
# addresses it by `id`, which is why the id below is also the browser card's
# slot key. It is editable at runtime rather than baked into this file;
# * one `slopo` tool that resolves the CLI, runs it, and streams progress. # * one `slopo` tool that resolves the CLI, runs it, and streams progress.
# #
# The row references the package by NAME rather than a relative path, so Node # The row references the package by NAME rather than a relative path, so Node
@@ -13,9 +15,10 @@
# #
# There is deliberately NO `config:` block here. Every value the plugin needs is # There is deliberately NO `config:` block here. Every value the plugin needs is
# either discovered at runtime (the slopo binary), set by the user through the # either discovered at runtime (the slopo binary), set by the user through the
# settings namespace, or genuinely optional. Shipping defaults would mean baking # settings card or the tool's `configure` action, or genuinely optional. Shipping
# one machine's paths into a layer that other machines also apply, and the # defaults would mean baking one machine's paths into a layer that other machines
# plugin's own schema already carries the defaults it can honestly claim. # also apply, and the plugin's own schema already carries the defaults it can
# honestly claim.
- insert: - insert:
- id: dsh-slopo - id: dsh-slopo
+125 -67
View File
@@ -7,8 +7,11 @@
* All three are user-invocable and deliberately NOT model-invocable, * All three are user-invocable and deliberately NOT model-invocable,
* matching upstream: every one of these commands can spend minutes * matching upstream: every one of these commands can spend minutes
* embedding, so the human decides when. * embedding, so the human decides when.
* 2. A `dsh-slopo` settings namespace on `ctx.settings`, so the embedding * 2. The `dsh-slopo` configuration: this module's own exported `Config`, which
* backend is editable at runtime instead of baked into a YAML layer. * the framework projects onto the Loader entry that composed it and
* addresses by that entry's id. Nothing is registered for it — a section
* assembled from a schema cannot disagree with the schema — and the page
* that edits it lives in `client/client.js`.
* 3. One `slopo` tool that resolves the CLI, materialises the config, and * 3. One `slopo` tool that resolves the CLI, materialises the config, and
* runs the long work as a background job. * runs the long work as a background job.
* *
@@ -17,7 +20,7 @@
* all. Putting `settings` in the top-level inject would make the tool and the * all. Putting `settings` in the top-level inject would make the tool and the
* skills disappear on any profile without a settings provider, which is a * skills disappear on any profile without a settings provider, which is a
* silent whole-plugin failure for an optional capability. The optional form * silent whole-plugin failure for an optional capability. The optional form
* degrades to the composition config instead. * simply has nowhere to persist a `configure` and says so.
* *
* Plain ESM, no build step — deliberately. `dsh plugin add git+...` fetches the * Plain ESM, no build step — deliberately. `dsh plugin add git+...` fetches the
* source tree, not build output, and a `prepare` script would make every user * source tree, not build output, and a `prepare` script would make every user
@@ -52,6 +55,7 @@ import {
installEnv, installEnv,
lastLogError, lastLogError,
commandLine, commandLine,
runShell,
slopoArgs, slopoArgs,
uvInstallArgs, uvInstallArgs,
} from './lib/slopo.js' } from './lib/slopo.js'
@@ -107,38 +111,49 @@ const SETTING_KEYS = [
* `embedding_model` and `embedding_dimensions`, and guessing an embedding * `embedding_model` and `embedding_dimensions`, and guessing an embedding
* backend would mean guessing where money is spent. Unset means unset, and the * backend would mean guessing where money is spent. Unset means unset, and the
* tool says so. * tool says so.
*
* Every field is marked `volatile()`, which is what puts it on the settings
* page: the framework's form projection keeps only the fields whose nearest
* volatile ancestor makes them editable without remounting the plugin, and an
* entry with no such field is dropped from `settings.describe` entirely. An
* unmarked field would still be *configurable* through the profile patch — it
* would simply be invisible to every settings UI.
*
* `embeddingApiKey` additionally carries `role('secret')`, so the settings
* wire never carries it: every remote read is redacted (`secrets` reports only
* whether a value is set) and the browser sends the key one way, on write.
*/ */
export const Config = Schema.object({ export const Config = Schema.object({
/** Explicit slopo executable. Empty means autodiscover. */ /** Explicit slopo executable. Empty means autodiscover. */
binary: Schema.string().default(''), binary: Schema.string().default('').volatile(),
/** Install slopo into $DSH_HOME with uv when it is missing. */ /** Install slopo into $DSH_HOME with uv when it is missing. */
autoInstall: Schema.boolean().default(true), autoInstall: Schema.boolean().default(true).volatile(),
/** LiteLLM model string, e.g. `ollama/jina-embeddings-v2-base-code:latest`. */ /** LiteLLM model string, e.g. `ollama/jina-embeddings-v2-base-code:latest`. */
embeddingModel: Schema.string().default(''), embeddingModel: Schema.string().default('').volatile(),
/** Output dimensions, which must match the model. */ /** Output dimensions, which must match the model. */
embeddingDimensions: Schema.number().default(0), embeddingDimensions: Schema.number().default(0).volatile(),
/** Provider base URL, e.g. `http://127.0.0.1:11434` for Ollama. */ /** Provider base URL, e.g. `http://127.0.0.1:11434` for Ollama. */
embeddingApiBase: Schema.string().default(''), embeddingApiBase: Schema.string().default('').volatile(),
/** Provider key. Passed to slopo in its environment, never written to disk. */ /** Provider key. Passed to slopo in its environment, never written to disk. */
embeddingApiKey: Schema.string().default(''), embeddingApiKey: Schema.string().default('').role('secret').volatile(),
/** Extra LiteLLM embedding params, as a JSON object string. */ /** Extra LiteLLM embedding params, as a JSON object string. */
embeddingParamsJson: Schema.string().default('{}'), embeddingParamsJson: Schema.string().default('{}').volatile(),
/** Code to index. Empty means the session's working directory. */ /** Code to index. Empty means the session's working directory. */
sourceDir: Schema.string().default(''), sourceDir: Schema.string().default('').volatile(),
/** Extra gitignore-style paths to skip, on top of the defaults. */ /** Extra gitignore-style paths to skip, on top of the defaults. */
sourceDirExclude: Schema.array(String).default([]), sourceDirExclude: Schema.array(String).default([]).volatile(),
/** Exclude vendored dependencies and build output. Off means slopo sees everything. */ /** Exclude vendored dependencies and build output. Off means slopo sees everything. */
useDefaultExcludes: Schema.boolean().default(true), useDefaultExcludes: Schema.boolean().default(true).volatile(),
/** Refuse to embed more than this many units without being asked. 0 removes the limit. */ /** Refuse to embed more than this many units without being asked. 0 removes the limit. */
maxIndexUnits: Schema.number().default(5000), maxIndexUnits: Schema.number().default(5000).volatile(),
/** Ignore file. Empty means inside the per-workspace state directory. */ /** Ignore file. Empty means inside the per-workspace state directory. */
ignoreFile: Schema.string().default(''), ignoreFile: Schema.string().default('').volatile(),
/** State directory. Empty means $DSH_HOME/dsh-slopo/workspaces/<workspace>. */ /** State directory. Empty means $DSH_HOME/dsh-slopo/workspaces/<workspace>. */
stateDir: Schema.string().default(''), stateDir: Schema.string().default('').volatile(),
/** Milliseconds between embedding requests, for rate-limited providers. */ /** Milliseconds between embedding requests, for rate-limited providers. */
embeddingRequestDelay: Schema.number().default(0), embeddingRequestDelay: Schema.number().default(0).volatile(),
/** Cooperative budget for a foreground slopo run. */ /** Cooperative budget for a foreground slopo run. */
timeoutMs: Schema.number().default(600_000), timeoutMs: Schema.number().default(600_000).volatile(),
}) })
/** Resolve the harness home, preferring the harness's own resolver. */ /** Resolve the harness home, preferring the harness's own resolver. */
@@ -338,20 +353,29 @@ export function apply(ctx, config) {
const logger = ctx.logger('dsh-slopo') const logger = ctx.logger('dsh-slopo')
const dshHome = dshHomeOf(ctx) const dshHome = dshHomeOf(ctx)
// The authoritative settings source. Starts as the composition config and is // `config` is the live configuration, not a snapshot of the composition
// replaced by the settings scope's thunk once a provider attaches; if none // layer: a settings write rewrites this entry's patch and the Loader
// ever does, everything below still works off the composition layer. // re-applies the plugin with the new values, so every read below sees the
let readSettings = () => config // deployment's current answer. There is deliberately no cached copy — a cache
// is exactly what a reload invalidates.
let writeSettings let writeSettings
// The settings namespace IS this plugin's Config. The framework projects the
// exported schema onto the Loader entry that composed it and addresses it by
// that entry's id, which the bundle's patch declares as `dsh-slopo` — the
// same string the browser card binds to. Nothing is registered here any more:
// 0.1.7-rc.2 removed `ctx.settings.installSection`, because a section
// assembled from a schema cannot disagree with the schema.
//
// `configure({ auto: false })` records that this entry ships its own page
// (`client/client.js` registers one onto the Plugins page), so the deployment
// must not also generate one for the same entry.
ctx.inject(['settings'], (settingsCtx) => { ctx.inject(['settings'], (settingsCtx) => {
try { try {
settingsCtx.settings.installSection(ctx, SETTINGS_NAMESPACE, Config, config, { settingsCtx.effect(
setSource: (current) => { () => settingsCtx.settings.configure({ auto: false }, ctx.fiber),
readSettings = current 'dsh-slopo: settings page policy',
}, )
onChange: () => {},
})
writeSettings = (patch) => settingsCtx.settings.update(SETTINGS_NAMESPACE, patch) writeSettings = (patch) => settingsCtx.settings.update(SETTINGS_NAMESPACE, patch)
} catch (error) { } catch (error) {
logger.warn('dsh-slopo: settings namespace unavailable: %s', error?.message ?? error) logger.warn('dsh-slopo: settings namespace unavailable: %s', error?.message ?? error)
@@ -360,8 +384,15 @@ export function apply(ctx, config) {
ctx.skills.registerProvider(() => skillsProvider) ctx.skills.registerProvider(() => skillsProvider)
/** The slopo executable, installing it first when allowed. */ /**
async function ensureSlopo(settings, run) { * The slopo executable, installing it first when allowed.
*
* `installInto` is the directory the install runs in. It is deliberately NOT
* the workspace: `uv tool install` has nothing to do with the project, and a
* shell that must `cd` into a workspace that does not exist yet fails before
* `uv` is ever reached.
*/
async function ensureSlopo(settings, run, installInto) {
const configured = typeof settings.binary === 'string' ? settings.binary.trim() : '' const configured = typeof settings.binary === 'string' ? settings.binary.trim() : ''
if (configured !== '' && existsSync(configured)) return configured if (configured !== '' && existsSync(configured)) return configured
const found = findSlopo(dshHome) const found = findSlopo(dshHome)
@@ -369,7 +400,11 @@ export function apply(ctx, config) {
if (settings.autoInstall !== true) { if (settings.autoInstall !== true) {
return { error: 'SLOPO_NOT_INSTALLED', remedy: 'Set `autoInstall`, or install slopo yourself and set `binary`.' } return { error: 'SLOPO_NOT_INSTALLED', remedy: 'Set `autoInstall`, or install slopo yourself and set `binary`.' }
} }
const install = await run(commandLine(['uv', ...uvInstallArgs()]), { env: installEnv(dshHome), timeoutMs: 900_000 }) const install = await run(commandLine(['uv', ...uvInstallArgs()]), {
env: installEnv(dshHome),
timeoutMs: 900_000,
workdir: installInto,
})
if (install.exitCode !== 0) { if (install.exitCode !== 0) {
return { return {
error: 'SLOPO_INSTALL_FAILED', error: 'SLOPO_INSTALL_FAILED',
@@ -446,7 +481,7 @@ export function apply(ctx, config) {
}, },
async execute(args, exec) { async execute(args, exec) {
const cwd = exec.agent?.session.header.cwd ?? process.cwd() const cwd = exec.agent?.session.header.cwd ?? process.cwd()
const settings = readSettings() ?? config const settings = config
const paths = workspacePaths(dshHome, cwd, settings) const paths = workspacePaths(dshHome, cwd, settings)
const shell = ctx.get('shell') const shell = ctx.get('shell')
// Resolved once, up front, so `status` can probe without a second // Resolved once, up front, so `status` can probe without a second
@@ -484,18 +519,16 @@ export function apply(ctx, config) {
let slopoVersion let slopoVersion
if (binary !== undefined && shell !== undefined) { if (binary !== undefined && shell !== undefined) {
try { try {
const versionResult = await shell.run( const versionResult = await runShell(shell, {
shell.resolve({ command: commandLine([binary, '--version']),
command: commandLine([binary, '--version']), workdir: paths.cwd,
workdir: paths.cwd, timeoutMs: 30_000,
timeoutMs: 30_000, ...(sandboxPolicy === undefined ? {} : { sandboxPolicy }),
...(sandboxPolicy === undefined ? {} : { sandboxPolicy }), })
}),
)
slopoVersion = slopoVersion =
versionResult.exitCode === 0 versionResult.exitCode === 0
? (versionResult.stdout?.text ?? '').trim().split('\n')[0] ? versionResult.stdout.text.trim().split('\n')[0]
: `exit code ${versionResult.exitCode}: ${(versionResult.stderr?.text ?? '').trim().slice(0, 200)}` : `exit code ${versionResult.exitCode}: ${versionResult.stderr.text.trim().slice(0, 200)}`
} catch (error) { } catch (error) {
slopoVersion = `could not run: ${error?.message ?? error}` slopoVersion = `could not run: ${error?.message ?? error}`
} }
@@ -642,25 +675,34 @@ export function apply(ctx, config) {
}) })
} }
/** Run one command through the shell seam. */ /**
const run = async (command, { env, timeoutMs, signal } = {}) => { * Run one command through the shell seam.
const result = await shell.run( *
shell.resolve({ * `runShell` returns the seam's own result; everything below wants the
command, * flattened strings, so the adaptation happens once, here.
workdir: paths.cwd, *
timeoutMs: timeoutMs ?? (Number(settings.timeoutMs) || 600_000), * `workdir` is overridable because installing slopo has nothing to do
...(signal === undefined ? {} : { signal }), * with the workspace: the workspace may not even exist yet on a fresh
// Merges over a parent-derived base, so PATH and HOME survive. * session, and a shell that cannot `cd` there fails before `uv` is
...(env === undefined ? {} : { env }), * reached. The plugin's own state directory is always a real directory
// Omitting this substitutes the deployment default; see above. * by the time an install runs.
...(sandboxPolicy === undefined ? {} : { sandboxPolicy }), */
}), const run = async (command, { env, timeoutMs, signal, workdir } = {}) => {
) const result = await runShell(shell, {
command,
workdir: workdir ?? paths.cwd,
timeoutMs: timeoutMs ?? (Number(settings.timeoutMs) || 600_000),
...(signal === undefined ? {} : { signal }),
// Merges over a parent-derived base, so PATH and HOME survive.
...(env === undefined ? {} : { env }),
// Omitting this substitutes the deployment default; see above.
...(sandboxPolicy === undefined ? {} : { sandboxPolicy }),
})
return { return {
exitCode: result.exitCode, exitCode: result.exitCode,
stdout: result.stdout?.text ?? '', stdout: result.stdout.text,
stderr: result.stderr?.text ?? '', stderr: result.stderr.text,
truncated: Boolean(result.stdout?.truncated || result.stderr?.truncated), truncated: result.stdout.truncated || result.stderr.truncated,
timedOut: Boolean(result.timedOut), timedOut: Boolean(result.timedOut),
} }
} }
@@ -697,14 +739,20 @@ export function apply(ctx, config) {
}) })
} }
const binary = await ensureSlopo(settings, run) // The state directory and the config file come FIRST, before the
// executable is resolved. The config is a pure function of the
// settings — nothing about it needs slopo to exist — and writing it
// early means a run that stops at "not installed" still leaves the
// resolved configuration on disk to look at. It also gives the install
// below a real working directory to run in.
await mkdir(paths.stateDir, { recursive: true })
await writeFile(paths.configPath, renderSlopoConfig(slopoConfig), 'utf8')
const binary = await ensureSlopo(settings, run, paths.stateDir)
if (typeof binary !== 'string') { if (typeof binary !== 'string') {
return foreground({ ok: false, exitCode: null, errorCode: binary.error, message: binary.remedy, remedy: binary.detail }) return foreground({ ok: false, exitCode: null, errorCode: binary.error, message: binary.remedy, remedy: binary.detail })
} }
await mkdir(paths.stateDir, { recursive: true })
await writeFile(paths.configPath, renderSlopoConfig(slopoConfig), 'utf8')
const env = const env =
typeof settings.embeddingApiKey === 'string' && settings.embeddingApiKey !== '' typeof settings.embeddingApiKey === 'string' && settings.embeddingApiKey !== ''
? { SLOPO_EMBEDDING_API_KEY: settings.embeddingApiKey } ? { SLOPO_EMBEDDING_API_KEY: settings.embeddingApiKey }
@@ -765,9 +813,19 @@ export function apply(ctx, config) {
const jobId = jobs.start({ const jobId = jobs.start({
kind: 'slopo', kind: 'slopo',
label: `slopo ${args.action}`, label: `slopo ${args.action}`,
...(exec.agent === undefined ? {} : { owner: exec.agent }), // Ownership is fenced by SESSION ID, not by the agent object: the
// registry cancels and awaits the job when that session's agent is
// disposed, and it looks the id up itself. Passing `exec.agent`
// here registered a job no session could collect or stop.
...(exec.agent === undefined ? {} : { owner: exec.agent.id }),
run: () => { run: () => {
const controller = new AbortController() const controller = new AbortController()
// `done` resolves to a JobOutcome: `{ status, detail, result }`.
// The report rides `result`, which the registry hands to the
// model on its first read after settlement — read it back with
// job_output. (The field is `result`, not `output`: the ring
// carries a job's stream, and `result` carries the return value
// of one whose product is a value rather than a stream.)
const done = (async () => { const done = (async () => {
const { refused, counted, outcome } = await runScoped(controller.signal) const { refused, counted, outcome } = await runScoped(controller.signal)
const logDetail = await readLogTail(paths.logFile) const logDetail = await readLogTail(paths.logFile)
@@ -775,17 +833,17 @@ export function apply(ctx, config) {
return { return {
status: 'failed', status: 'failed',
detail: 'SCOPE_TOO_LARGE', detail: 'SCOPE_TOO_LARGE',
output: `${refused}\n\n${budgetRemedy(Number(settings.maxIndexUnits), excludes)}`, result: `${refused}\n\n${budgetRemedy(Number(settings.maxIndexUnits), excludes)}`,
} }
} }
if (outcome.exitCode === 0) { if (outcome.exitCode === 0) {
return { status: 'completed', detail: 'exit code: 0', output: outcome.stdout.trim() || 'No duplicates found' } return { status: 'completed', detail: 'exit code: 0', result: outcome.stdout.trim() || 'No duplicates found' }
} }
const explained = explainFailure({ stderr: outcome.stderr, stdout: outcome.stdout, logDetail }) const explained = explainFailure({ stderr: outcome.stderr, stdout: outcome.stdout, logDetail })
return { return {
status: 'failed', status: 'failed',
detail: explained.code, detail: explained.code,
output: [explained.message, explained.remedy, logDetail.trim()].filter((line) => line !== '').join('\n\n'), result: [explained.message, explained.remedy, logDetail.trim()].filter((line) => line !== '').join('\n\n'),
} }
})() })()
return { cancel: () => controller.abort(), done } return { cancel: () => controller.abort(), done }
+42
View File
@@ -112,6 +112,48 @@ export function commandLine(argv) {
return argv.map(shellQuote).join(' ') return argv.map(shellQuote).join(' ')
} }
/**
* Run one command through the harness shell seam.
*
* The seam is `execute(resolve(request))`, whose handle carries the foreground
* projection as `result()`. That is deliberately two steps: the handle is the
* background-capable object, and awaiting `result()` is what makes a run
* foreground. The single-step `run(spec)` this plugin used before
* 0.1.7-rc.2 no longer exists on `ShellExecutor`, so calling it threw
* `shell.run is not a function` on every action that needed a command.
*
* `result()` rejects only for infrastructure failures (a spawn that never
* produced a process); non-zero exits, timeout kills and abort kills resolve
* with a descriptive result. The returned shape is the plugin's own flattened
* form, so callers never have to know which generation of the seam they are on.
*
* @param {{ execute: Function, resolve: Function }} shell the `ctx.shell` service
* @param {object} request a `ShellExecRequest`
* @returns {Promise<{
* exitCode: number | null,
* signal: string | null,
* timedOut: boolean,
* aborted: boolean,
* timeoutMs: number,
* sandbox: object | undefined,
* stdout: { text: string, truncated: boolean },
* stderr: { text: string, truncated: boolean },
* }>}
*/
export async function runShell(shell, request) {
const result = await (await shell.execute(shell.resolve(request))).result()
return {
exitCode: result.exitCode,
signal: result.signal,
timedOut: result.timedOut,
aborted: result.aborted,
timeoutMs: result.timeoutMs,
sandbox: result.sandbox,
stdout: { text: result.stdout.text, truncated: result.stdout.truncated },
stderr: { text: result.stderr.text, truncated: result.stderr.truncated },
}
}
/** /**
* Slopo arguments for one action. * Slopo arguments for one action.
* *
+18 -2
View File
@@ -32,7 +32,17 @@
"scripts": { "scripts": {
"test": "node --test test/*.test.js", "test": "node --test test/*.test.js",
"test:release": "bash .gitea/scripts/release.test.sh", "test:release": "bash .gitea/scripts/release.test.sh",
"release:plan": "bash .gitea/scripts/release.sh should-release \"$(bash .gitea/scripts/release.sh range)\"" "release:plan": "bash .gitea/scripts/release.sh should-release \"$(bash .gitea/scripts/release.sh range)\"",
"release:notes": "bash .gitea/scripts/release.sh notes \"$(bash .gitea/scripts/release.sh range)\""
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.4",
"@deepseek-ai/dsh-client-ui-primitives": ">=0.1.7-rc.2",
"@deepseek-ai/dsh-settings": ">=0.1.7-rc.2",
"@deepseek-ai/dsh-skill": ">=0.1.7-rc.2",
"@deepseek-ai/dsh-tools": ">=0.1.7-rc.2",
"@deepseek-ai/schemastery": "^3.18.4",
"react": "^18.3.1"
}, },
"dsh": { "dsh": {
"bundle": { "bundle": {
@@ -41,9 +51,15 @@
"client": { "client": {
"platform": "web", "platform": "web",
"inject": [ "inject": [
"@deepseek-ai/dsh-client-ui-slots", "@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-plugin-manager",
"@deepseek-ai/dsh-client-ui-settings" "@deepseek-ai/dsh-client-ui-settings"
] ]
},
"compatibility": {
"dshReleases": {
"0.1.7-rc.2": "compatible"
}
} }
}, },
"keywords": [ "keywords": [
+705 -241
View File
File diff suppressed because it is too large. Load diff
+163 -30
View File
@@ -9,7 +9,7 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { exec } from 'node:child_process' import { exec } from 'node:child_process'
import { existsSync, mkdtempSync, writeFileSync } from 'node:fs' import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import { test } from 'node:test' import { test } from 'node:test'
@@ -39,7 +39,16 @@ const FULL_CONFIG = {
maxIndexUnits: 5000, maxIndexUnits: 5000,
} }
/** A shell executor that really runs the command. */ /**
* A shell executor shaped like `ShellExecutor` in dsh 0.1.7-rc.2: `resolve`,
* then `execute(spec)` returning a process handle whose `result()` is the
* foreground projection.
*
* There is deliberately no `run(spec)`: that single-step method was removed from
* the seam, and a plugin that still calls it fails with
* `shell.run is not a function` the first time it needs a command. Keeping the
* mock honest about the shape is the point — this file is the contract test.
*/
const shell = { const shell = {
sandboxMode: undefined, sandboxMode: undefined,
resolve: (request) => ({ resolve: (request) => ({
@@ -48,8 +57,8 @@ const shell = {
stdoutMaxBytes: 10_000_000, stdoutMaxBytes: 10_000_000,
...request, ...request,
}), }),
run(spec) { async execute(spec) {
return new Promise((resolve) => { const result = await new Promise((resolve) => {
exec( exec(
spec.command, spec.command,
{ cwd: spec.workdir, env: { ...process.env, ...(spec.env ?? {}) }, timeout: spec.timeoutMs, maxBuffer: 64 * 1024 * 1024 }, { cwd: spec.workdir, env: { ...process.env, ...(spec.env ?? {}) }, timeout: spec.timeoutMs, maxBuffer: 64 * 1024 * 1024 },
@@ -66,12 +75,27 @@ const shell = {
}, },
) )
}) })
return { ...result, result: async () => result }
}, },
start() { start() {
throw new Error('background process start is not used by these tests') throw new Error('background process start is not used by these tests')
}, },
} }
/**
* A stub slopo executable.
*
* Discovery itself is `test/slopo.test.js`'s business. Here the tool must never
* reach `uv tool install`: that is a network round trip, it makes the suite
* slow and flaky, and a canned runner would answer it with success without
* installing anything — leaving the plugin to report SLOPO_INSTALL_MISSING.
* Pointing `binary` at a file that exists takes the "already installed" branch,
* which is the state every one of these tests means to exercise. A test that
* wants the missing-executable path passes `binary: ''` and `autoInstall: false`.
*/
const stubSlopo = join(mkdtempSync(join(tmpdir(), 'dsh-slopo-bin-')), 'slopo')
writeFileSync(stubSlopo, '#!/bin/sh\nexit 0\n', { mode: 0o755 })
/** /**
* Build a context exposing exactly the services the plugin consumes. * Build a context exposing exactly the services the plugin consumes.
* *
@@ -79,7 +103,7 @@ const shell = {
* so the assertions can see a result rather than a job handle. * so the assertions can see a result rather than a job handle.
*/ */
function harness(overrides = {}, { withSettings = true, withJobs = false, withShell = true, sandboxMode, sandboxPolicyResolver, shellRun } = {}) { function harness(overrides = {}, { withSettings = true, withJobs = false, withShell = true, sandboxMode, sandboxPolicyResolver, shellRun } = {}) {
const config = { ...FULL_CONFIG, ...overrides } const config = { ...FULL_CONFIG, binary: stubSlopo, ...overrides }
// A per-harness shell, so a test can vary sandboxMode and every resolved // A per-harness shell, so a test can vary sandboxMode and every resolved
// request is recorded for assertion. // request is recorded for assertion.
const scopedShell = { const scopedShell = {
@@ -90,19 +114,28 @@ function harness(overrides = {}, { withSettings = true, withJobs = false, withSh
return { workdir: process.cwd(), timeoutMs: 600_000, stdoutMaxBytes: 10_000_000, ...request } return { workdir: process.cwd(), timeoutMs: 600_000, stdoutMaxBytes: 10_000_000, ...request }
}, },
// A canned runner lets a test drive the index gate without a real slopo. // A canned runner lets a test drive the index gate without a real slopo.
...(shellRun === undefined ? {} : { run: shellRun }), // It replaces only the low-level run, so the `execute().result()` frame
// stays exactly the shape the real seam has.
async execute(spec) {
state.shellExecutions += 1
const result = shellRun === undefined ? await shell.execute(spec).then((handle) => handle.result()) : await shellRun(spec)
return { ...result, result: async () => result }
},
} }
const state = { const state = {
tools: new Map(), tools: new Map(),
updates: [], updates: [],
provider: undefined, provider: undefined,
namespace: undefined, pagePolicy: undefined,
settingsEffects: 0,
shell: scopedShell, shell: scopedShell,
shellRequests: [], shellRequests: [],
shellExecutions: 0,
jobs: undefined, jobs: undefined,
} }
const ctx = { const ctx = {
fiber: { uid: 'test-fiber' },
logger: () => ({ info() {}, warn() {}, debug() {}, error() {} }), logger: () => ({ info() {}, warn() {}, debug() {}, error() {} }),
get(name) { get(name) {
if (name === 'shell') return withShell ? scopedShell : undefined if (name === 'shell') return withShell ? scopedShell : undefined
@@ -112,12 +145,22 @@ function harness(overrides = {}, { withSettings = true, withJobs = false, withSh
}, },
inject(deps, callback) { inject(deps, callback) {
if (!withSettings || !deps.includes('settings')) return if (!withSettings || !deps.includes('settings')) return
// The child context `ctx.inject` hands back: its own effect scope plus
// the settings service. 0.1.7-rc.2 has no `installSection` — an entry's
// exported Config IS the form — so the only things a plugin does here are
// claim its page and write through `update`.
callback({ callback({
effect(effect) {
state.settingsEffects += 1
return effect()
},
settings: { settings: {
installSection(owner, namespace, schema, entry, hooks) { configure(presentation, owner) {
state.namespace = namespace state.pagePolicy = presentation
state.schema = schema state.pagePolicyOwner = owner
hooks.setSource(() => config) return () => {
state.pagePolicy = undefined
}
}, },
async update(namespace, patch) { async update(namespace, patch) {
state.updates.push({ namespace, patch }) state.updates.push({ namespace, patch })
@@ -142,7 +185,7 @@ function harness(overrides = {}, { withSettings = true, withJobs = false, withSh
return { config, state } return { config, state }
} }
const execContext = (cwd) => ({ signal: new AbortController().signal, agent: { session: { header: { cwd } } } }) const execContext = (cwd) => ({ signal: new AbortController().signal, agent: { id: 'session-1', session: { header: { cwd } } } })
test('Config is a schemastery schema, which is what cordis requires', () => { test('Config is a schemastery schema, which is what cordis requires', () => {
assert.equal(typeof Config, 'function') assert.equal(typeof Config, 'function')
@@ -192,9 +235,50 @@ test('every skill tells the agent to call the tool rather than a shell command',
} }
}) })
test('the settings namespace is registered under a grammar-valid name', () => { test('the plugin claims its own settings page instead of a generated one', () => {
// `auto: false` says "this entry ships a client page" — client/client.js
// registers one onto the Plugins page. The owner must be the plugin's own
// fiber, not the settings service's.
const { state } = harness() const { state } = harness()
assert.match(state.namespace, /^[a-z][a-z0-9-]*$/) assert.deepEqual(state.pagePolicy, { auto: false })
assert.equal(state.pagePolicyOwner?.uid, 'test-fiber', 'the policy belongs to the plugin instance')
assert.equal(state.settingsEffects, 1, 'the policy is registered as an effect, so a reload drops it')
})
test('the namespace is the Loader entry id the bundle patch declares', async () => {
const { state } = harness()
await state.tools.get('slopo').execute({ action: 'configure', config: { embeddingModel: 'x' } }, execContext('/tmp'))
assert.equal(state.updates[0].namespace, 'dsh-slopo')
})
test('the host half no longer calls the removed installSection API', () => {
// 0.1.7-rc.2 deleted `ctx.settings.installSection`; a plugin that still calls
// it loses its whole settings surface, silently, behind a try/catch.
const source = readFileSync(new URL('../index.js', import.meta.url), 'utf8')
const code = source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
assert.doesNotMatch(code, /installSection/)
})
test('every Config field is volatile, which is what puts it on the settings page', () => {
// The framework's form projection keeps only fields under a volatile
// ancestor, and an entry whose form is empty is dropped from
// `settings.describe` — so an unmarked field is invisible to every UI.
// The walk is over the live schema: `Config.toJSON()` is a reference
// envelope for the wire, not a tree.
const fields = Object.entries(Config.dict ?? {})
assert.ok(fields.length > 0, 'the schema must declare fields')
for (const [key, node] of fields) {
assert.equal(node.meta?.volatile, true, `${key} must be marked volatile()`)
}
})
test('the embedding API key is a declared secret, so the settings wire never carries it', () => {
assert.equal(Config.dict.embeddingApiKey.meta?.role, 'secret')
// …and no other field claims to be one, because the browser cannot read a
// secret back and every other field is one the card must display.
for (const [key, node] of Object.entries(Config.dict)) {
if (key !== 'embeddingApiKey') assert.notEqual(node.meta?.role, 'secret', `${key} must not be secret`)
}
}) })
test('status reports the missing settings instead of pretending to be ready', async () => { test('status reports the missing settings instead of pretending to be ready', async () => {
@@ -264,22 +348,35 @@ test('review refuses to run before the workspace is configured', async () => {
}) })
test('review writes a config the plugin owns and passes the key only in the environment', async () => { test('review writes a config the plugin owns and passes the key only in the environment', async () => {
const { state } = harness({ // Hermetic on purpose: `binary` points at a stub that exists, so the plugin
embeddingModel: 'ollama/example:latest', // takes the "already installed" branch instead of running `uv tool install`
embeddingDimensions: 768, // over the network, and the workspace is a real directory so the shell seam
embeddingApiBase: 'http://127.0.0.1:11434', // has somewhere to run. The canned runner answers both commands.
embeddingApiKey: 'super-secret', const workspace = mkdtempSync(join(tmpdir(), 'dsh-slopo-ws-'))
}) const binary = join(workspace, 'slopo')
writeFileSync(binary, '#!/bin/sh\nexit 0\n', { mode: 0o755 })
const ran = []
const { state } = harness(
{
binary,
embeddingModel: 'ollama/example:latest',
embeddingDimensions: 768,
embeddingApiBase: 'http://127.0.0.1:11434',
embeddingApiKey: 'super-secret',
},
{ shellRun: cannedShell(ran, { indexOutput: 'Indexed 12 code units from 3 files.' }) },
)
const tool = state.tools.get('slopo') const tool = state.tools.get('slopo')
const value = await tool.execute({ action: 'review' }, execContext('/tmp/nonexistent-repo-xyz')) const value = await tool.execute({ action: 'review' }, execContext(workspace))
// It will fail — there is no git repo there — but the failure must be a
// reported one, and the config must have been materialised first.
assert.equal(value.kind, 'foreground') assert.equal(value.kind, 'foreground')
assert.ok(existsSync(value.configPath), `expected config at ${value.configPath}`) assert.ok(existsSync(value.configPath), `expected config at ${value.configPath}`)
const { readFileSync } = await import('node:fs')
const written = readFileSync(value.configPath, 'utf8') const written = readFileSync(value.configPath, 'utf8')
assert.match(written, /embedding_model: "ollama\/example:latest"/) assert.match(written, /embedding_model: "ollama\/example:latest"/)
assert.doesNotMatch(written, /super-secret/) assert.doesNotMatch(written, /super-secret/)
// The key rides the environment of the run, not the file.
const executions = state.shellRequests.filter((request) => /agent-review/.test(request.command))
assert.equal(executions.length, 1)
assert.equal(executions[0].env.SLOPO_EMBEDDING_API_KEY, 'super-secret')
}) })
test('only the warm-up backgrounds by default; the query actions return directly', async () => { test('only the warm-up backgrounds by default; the query actions return directly', async () => {
@@ -321,6 +418,37 @@ test('only the warm-up backgrounds by default; the query actions return directly
assert.equal(started.length, 2) assert.equal(started.length, 2)
}) })
test('a background job is owned by the SESSION ID and settles a JobOutcome', async () => {
// Two things the 0.1.7-rc.2 job contract requires and the old code got wrong:
// `owner` is a session id (the registry looks the agent up itself, and a job
// owned by the wrong object can be neither collected nor cancelled), and the
// producer's `done` resolves a JobOutcome whose payload field is `result` —
// `output` is the ring's word, and a job that reports to `output` settles
// with nothing for job_output to hand back.
const started = []
const ran = []
const { state } = harness(
{ embeddingModel: 'ollama/example:latest', embeddingDimensions: 768 },
{ withJobs: true, shellRun: cannedShell(ran, { indexOutput: 'Indexed 5 code units from 1 files.' }) },
)
state.jobs = {
start(spec) {
started.push(spec)
return 'slopo-1'
},
}
const value = await state.tools.get('slopo').execute({ action: 'prepare' }, execContext('/tmp'))
assert.equal(value.kind, 'background')
assert.equal(started[0].owner, 'session-1', 'ownership is fenced by session id')
const hooks = started[0].run()
assert.equal(typeof hooks.cancel, 'function')
const outcome = await hooks.done
assert.equal(outcome.status, 'completed')
assert.equal(outcome.detail, 'exit code: 0')
assert.equal(typeof outcome.result, 'string', 'the report rides `result`, not `output`')
})
test( test(
'review runs the real slopo CLI end to end against a real repository', 'review runs the real slopo CLI end to end against a real repository',
{ skip: process.env.DSH_SLOPO_E2E !== '1' || findSlopo(testHome) === undefined }, { skip: process.env.DSH_SLOPO_E2E !== '1' || findSlopo(testHome) === undefined },
@@ -382,14 +510,19 @@ test('the sandbox policy is resolved for the CALLING SESSION, not the deployment
}, },
) )
const tool = state.tools.get('slopo') const tool = state.tools.get('slopo')
await tool.execute({ action: 'review' }, execContext('/tmp/nonexistent-repo-xyz')) await tool.execute({ action: 'review' }, execContext('/tmp'))
assert.equal(seen.length, 1, 'the policy must be resolved exactly once') assert.equal(seen.length, 1, 'the policy must be resolved exactly once')
assert.ok(seen[0].session !== undefined, 'the calling session must travel with the policy') assert.ok(seen[0].session !== undefined, 'the calling session must travel with the policy')
assert.equal(state.shellRequests.length, 1) // Every command the run issued carries it — not just the first. The count is
assert.deepEqual(state.shellRequests[0].sandboxPolicy, { // not the assertion: how many commands a review issues depends on whether the
mode: 'danger-full-access', // index gate let it through.
workspaceRoot: '/some/workspace', assert.ok(state.shellRequests.length >= 1, 'the run must reach the shell')
}) for (const request of state.shellRequests) {
assert.deepEqual(request.sandboxPolicy, {
mode: 'danger-full-access',
workspaceRoot: '/some/workspace',
})
}
}) })
test('a confining executor with no policy service is refused rather than guessed at', async () => { test('a confining executor with no policy service is refused rather than guessed at', async () => {