fix!: port the settings page and shell seam to dsh 0.1.7-rc.2
Release / release (push) Successful in 6s
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:
1 parent
ffefc42e01
commit
1b2ab3613f
8 files changed
+1519
-659
No files matched your search
@@ -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
|
||||
|
||||
|
||||
+381
-305
@@ -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
|
||||
* a card under that exact namespace key. Both are required: the tab renders the
|
||||
* INTERSECTION of the namespaces the Host serves and the cards the browser
|
||||
* claims, so a served namespace with no card draws nothing at all, and a card
|
||||
* whose namespace is not served is never dispatched.
|
||||
* Where the card lives, and why it moved. Up to dsh 0.1.6 the settings surfaces
|
||||
* were: a settings namespace a plugin registered for itself, plus a
|
||||
* `settings.plugin.item` card the plugin's client half registered under that
|
||||
* namespace key, rendered inside Settings → Plugins → Plugin configuration.
|
||||
* 0.1.7-rc.2 removed both halves of that contract:
|
||||
*
|
||||
* The chrome follows the framework's `PluginCard`: the root is an `<li>` (the
|
||||
* tab renders every card into a `<ul>`), the header is its own `<button>` that
|
||||
* discloses the controls in place, the card starts collapsed, it marks itself
|
||||
* when it holds unsaved edits, and it collapses again after a successful save.
|
||||
* Nothing about that is imported — `PluginCard` lives in that package's client
|
||||
* bundle and the client purity gate forbids cross-plugin value imports — so the
|
||||
* behaviour is reproduced here rather than borrowed.
|
||||
* * `ctx.settings.installSection(...)` no longer exists, and a namespace is no
|
||||
* longer chosen by the plugin — it IS the Loader profile entry id, which the
|
||||
* bundle's own patch declares (`dsh-slopo`).
|
||||
* * the `settings.plugin.item` slot and the `settingsScope` service no longer
|
||||
* exist either. Configuration now lives on the Plugins page, and a bundle's
|
||||
* own settings register into `plugins.bundle.config` keyed by the bundle's
|
||||
* 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
|
||||
* 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
|
||||
* the first install succeeds. The only import is `react`, which is in the
|
||||
* platform seed table; anything outside that table would have to be bundled
|
||||
* into this file or declared in `dsh.client.external`.
|
||||
* the first install succeeds. The imports are `react` and
|
||||
* `@deepseek-ai/dsh-client-ui-primitives`, both platform seed words; anything
|
||||
* 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
|
||||
* file may reach.
|
||||
@@ -32,338 +43,403 @@ window.__ModuleLoader__.load({
|
||||
var exports = module.exports
|
||||
|
||||
const react = require('react')
|
||||
const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
|
||||
|
||||
const NS = 'dsh-slopo'
|
||||
const h = react.createElement
|
||||
|
||||
const TITLE = 'Slopo'
|
||||
const DESCRIPTION = 'Embedding backend, index scope and the budget that stops a large embed.'
|
||||
const { Checkbox, SettingsForm, SettingsFormModel, SettingsSecretField, SettingsValueField } = primitives
|
||||
const { settingsNumberField, settingsTextField } = primitives
|
||||
|
||||
/**
|
||||
* The settings this card edits.
|
||||
* The bundle's package name.
|
||||
*
|
||||
* This list mirrors the Host schema in index.js. It is duplicated rather
|
||||
* than fetched because the card renders its own controls — there is no
|
||||
* generic 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.
|
||||
* One string plays three roles and they must agree: it is the npm package
|
||||
* the Plugins page lists, the Loader entry id inside that bundle's patch
|
||||
* (and therefore the settings namespace `configForms` addresses), and the
|
||||
* `plugins.bundle.config` key this card registers under.
|
||||
*/
|
||||
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 = [
|
||||
{
|
||||
key: 'embeddingModel',
|
||||
label: 'Embedding model',
|
||||
hint: 'LiteLLM string, provider prefix included — e.g. ollama/unclemusclez/jina-embeddings-v2-base-code:latest',
|
||||
},
|
||||
{ key: 'embeddingDimensions', label: 'Embedding dimensions', kind: 'number', hint: 'Must match what the model returns. Frozen into the index at first use.' },
|
||||
{ key: 'embeddingApiBase', label: 'Embedding API base', hint: 'e.g. http://127.0.0.1:11434' },
|
||||
{
|
||||
key: 'embeddingApiKey',
|
||||
label: 'Embedding API key',
|
||||
kind: 'password',
|
||||
hint: 'Passed to slopo in its environment and never written into the slopo config file.',
|
||||
},
|
||||
{ key: 'embeddingParamsJson', label: 'Extra embedding parameters', hint: 'A JSON object, merged into embedding_params.' },
|
||||
{ key: 'binary', label: 'slopo executable', hint: 'Empty discovers it: managed install, then PATH, then ~/.local/bin.' },
|
||||
{ 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' },
|
||||
{ key: 'embeddingModel', kind: 'text' },
|
||||
{ key: 'embeddingDimensions', kind: 'number' },
|
||||
{ key: 'embeddingApiBase', kind: 'text' },
|
||||
{ key: 'embeddingApiKey', kind: 'secret' },
|
||||
{ key: 'embeddingParamsJson', kind: 'text' },
|
||||
{ key: 'binary', kind: 'text' },
|
||||
{ key: 'autoInstall', kind: 'boolean' },
|
||||
{ key: 'sourceDir', kind: 'text' },
|
||||
{ key: 'sourceDirExclude', kind: 'list' },
|
||||
{ key: 'useDefaultExcludes', kind: 'boolean' },
|
||||
{ key: 'maxIndexUnits', kind: 'number' },
|
||||
{ key: 'ignoreFile', kind: 'text' },
|
||||
{ key: 'stateDir', kind: 'text' },
|
||||
{ key: 'embeddingRequestDelay', kind: 'number' },
|
||||
{ key: 'timeoutMs', 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 = {
|
||||
// The tab renders into a <ul>, so the root must be an <li> or the list
|
||||
// styling never reaches it.
|
||||
card: { listStyle: 'none', border: BORDER, borderRadius: '8px', margin: '8px 0', overflow: 'hidden' },
|
||||
cardOpen: { background: 'var(--dsh-surface, rgba(255,255,255,0.02))' },
|
||||
header: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'inherit',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
font: 'inherit',
|
||||
},
|
||||
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: {
|
||||
flex: '0 0 auto',
|
||||
fontSize: '10px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
border: BORDER,
|
||||
opacity: 0.8,
|
||||
},
|
||||
chevron: { flex: '0 0 auto', fontSize: '10px', opacity: 0.7, transition: 'transform 120ms ease' },
|
||||
chevronOpen: { transform: 'rotate(180deg)' },
|
||||
body: { padding: '0 16px 16px 16px' },
|
||||
readOnly: { fontSize: '11px', opacity: 0.75, marginTop: 0, marginBottom: '12px' },
|
||||
hint: { fontSize: '11px', opacity: 0.65, marginTop: '2px', lineHeight: 1.45 },
|
||||
row: { marginTop: '12px' },
|
||||
label: { display: 'block', fontSize: '12px', fontWeight: 500, marginBottom: '4px' },
|
||||
input: {
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: '6px 8px',
|
||||
fontSize: '12px',
|
||||
borderRadius: '6px',
|
||||
border: BORDER,
|
||||
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' },
|
||||
}
|
||||
/** Every field's label and hint, resolving against the active locale. */
|
||||
const describeField = (t, field) => ({
|
||||
key: field.key,
|
||||
kind: field.kind,
|
||||
label: copy(t, field.key, 'label'),
|
||||
hint: copy(t, field.key, 'hint'),
|
||||
id: `plugin-config-${BUNDLE}-${field.key}`,
|
||||
})
|
||||
|
||||
/** The editable string form of one field's value. */
|
||||
function toText(field, value) {
|
||||
if (field.kind === 'list') return Array.isArray(value) ? value.join(', ') : ''
|
||||
if (field.kind === 'boolean') return value === true ? 'true' : 'false'
|
||||
if (value === undefined || value === null) return ''
|
||||
return String(value)
|
||||
}
|
||||
/**
|
||||
* A boolean field's draft conversion.
|
||||
*
|
||||
* The shared kit exports number and text specs only; a switch is a set of
|
||||
* `true`/`false`, so the control has nothing to parse but the two literals
|
||||
* it renders. Anything else is an invalid draft, which blocks the save
|
||||
* rather than silently writing `false`.
|
||||
*/
|
||||
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
|
||||
},
|
||||
})
|
||||
|
||||
/** The value to write for one field, from its string form. */
|
||||
function fromText(field, text) {
|
||||
const trimmed = typeof text === 'string' ? text.trim() : ''
|
||||
if (field.kind === 'number') return Number(trimmed)
|
||||
if (field.kind === 'boolean') return trimmed === 'true'
|
||||
if (field.kind === 'list') {
|
||||
return trimmed === ''
|
||||
? []
|
||||
: trimmed
|
||||
/**
|
||||
* A string-array field's draft conversion: a comma-separated list.
|
||||
*
|
||||
* An empty draft is an empty list rather than a clear: the schema declares
|
||||
* this field's default as `[]`, so "no extra patterns" and "fall back to
|
||||
* the composition layer" are the same answer.
|
||||
*/
|
||||
const settingsListField = (field) => ({
|
||||
field,
|
||||
format: (value) => (Array.isArray(value) ? value.join(', ') : ''),
|
||||
parse: (text) => ({
|
||||
kind: 'set',
|
||||
value: text
|
||||
.split(/[,\n]/)
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part !== '')
|
||||
}
|
||||
return text
|
||||
.filter((part) => part !== ''),
|
||||
}),
|
||||
})
|
||||
|
||||
/** The spec one field's drafts are converted with. */
|
||||
function specFor(field) {
|
||||
if (field.kind === 'number') return settingsNumberField(field.key)
|
||||
if (field.kind === 'boolean') return settingsBooleanField(field.key)
|
||||
if (field.kind === 'list') return settingsListField(field.key)
|
||||
return settingsTextField(field.key)
|
||||
}
|
||||
|
||||
/** Build a draft of every field from a resolved section. */
|
||||
function toDraft(value) {
|
||||
const draft = {}
|
||||
for (const field of FIELDS) draft[field.key] = toText(field, value?.[field.key])
|
||||
return draft
|
||||
}
|
||||
/** The form frame's copy, read from this card's dictionary. */
|
||||
const formLabels = (t) => ({
|
||||
unavailable: t('unavailable'),
|
||||
readOnly: t('readOnly'),
|
||||
saveFailed: t('saveFailed'),
|
||||
save: t('save'),
|
||||
saving: t('saving'),
|
||||
})
|
||||
|
||||
function Field(props) {
|
||||
const { field, value, overridden, disabled, onChange } = props
|
||||
const common = {
|
||||
style: S.input,
|
||||
value,
|
||||
/** The field-control copy shared by every row. */
|
||||
const fieldLabels = (t) => ({
|
||||
overriddenLabel: t('overridden'),
|
||||
resetLabel: t('reset'),
|
||||
invalidLabel: t('invalidNumber'),
|
||||
})
|
||||
|
||||
/** One staged text row. */
|
||||
function Row(props) {
|
||||
const { t, state, disabled, edit, resetField, meta } = props
|
||||
return h(SettingsValueField, {
|
||||
id: meta.id,
|
||||
label: meta.label,
|
||||
hint: meta.hint,
|
||||
disabled,
|
||||
'aria-label': field.label,
|
||||
onChange: (event) => onChange(event.target.value),
|
||||
numeric: meta.kind === 'number',
|
||||
...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(
|
||||
'div',
|
||||
{ style: S.row },
|
||||
{ style: { display: 'flex', flexDirection: 'column', gap: '4px' } },
|
||||
h(
|
||||
'label',
|
||||
{ style: S.label },
|
||||
field.label,
|
||||
overridden ? h('span', { style: { opacity: 0.6, fontWeight: 400 } }, ' · overridden') : null,
|
||||
),
|
||||
field.kind === 'boolean'
|
||||
? h('input', {
|
||||
type: 'checkbox',
|
||||
checked: value === 'true',
|
||||
'div',
|
||||
{ style: { display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap' } },
|
||||
h(Checkbox, {
|
||||
label: meta.label,
|
||||
checked,
|
||||
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(
|
||||
onChange: (next) => edit(meta.key, next ? 'true' : 'false'),
|
||||
}),
|
||||
state.overridden
|
||||
? h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
style: S.header,
|
||||
'aria-expanded': open,
|
||||
'aria-label': `${open ? 'Collapse' : 'Expand'}: ${TITLE}`,
|
||||
onClick: () => setOpen(!open),
|
||||
disabled,
|
||||
onClick: () => resetField(meta.key),
|
||||
style: {
|
||||
font: 'inherit',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
background: 'transparent',
|
||||
color: 'inherit',
|
||||
opacity: 0.75,
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
},
|
||||
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),
|
||||
),
|
||||
},
|
||||
`${t('overridden')} · ${t('reset')}`,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
h('div', { style: { fontSize: '11px', opacity: 0.65, lineHeight: 1.45 } }, meta.hint),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`,
|
||||
* because a required dependency that is unsatisfied means this plugin never
|
||||
* applies at all — the card would vanish along with everything else instead
|
||||
* of simply not appearing.
|
||||
* @param props - the card's state snapshot, the form actions, and locale copy.
|
||||
* @returns the settings form.
|
||||
*/
|
||||
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) {
|
||||
ctx.inject(['settingsScope'], (scoped) => {
|
||||
const binder = scoped.settingsScope
|
||||
if (binder === undefined) return
|
||||
const scope = binder.bind({ namespace: NS })
|
||||
scoped.slots.inject('settings.plugin.item', () =>
|
||||
scoped.slots.register(
|
||||
ctx.effect(
|
||||
() =>
|
||||
ctx.locale.register(NS, {
|
||||
en,
|
||||
}),
|
||||
'dsh-slopo: card dictionary',
|
||||
)
|
||||
ctx.inject(['configForms'], (child) => {
|
||||
const forms = child.configForms
|
||||
if (forms === undefined) return
|
||||
const controller = new SlopoCardController(child, forms.get(BUNDLE))
|
||||
child.effect(
|
||||
() => () => controller.dispose(),
|
||||
'dsh-slopo: form subscription',
|
||||
)
|
||||
child.effect(
|
||||
() =>
|
||||
forms.whileServed([BUNDLE], () =>
|
||||
child.slots.inject('plugins.bundle.config', () =>
|
||||
child.slots.register(
|
||||
{
|
||||
name: 'settings.plugin.item',
|
||||
key: NS,
|
||||
inject: () => ({ scope }),
|
||||
name: 'plugins.bundle.config',
|
||||
key: BUNDLE,
|
||||
locale: NS,
|
||||
inject: () => controller.inject(),
|
||||
},
|
||||
(props) => h(Card, props),
|
||||
SlopoCard,
|
||||
),
|
||||
),
|
||||
),
|
||||
'dsh-slopo: Plugins page card',
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { name: NS, inject: ['slots'], apply }
|
||||
module.exports = { name: BUNDLE, inject: ['slots', 'locale'], apply }
|
||||
return module.exports
|
||||
},
|
||||
})
|
||||
+8
-5
@@ -4,8 +4,10 @@
|
||||
# * 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
|
||||
# these commands can spend minutes embedding);
|
||||
# * a configuration namespace on `ctx.settings`, so the embedding backend is
|
||||
# editable at runtime rather than baked into this file;
|
||||
# * the `dsh-slopo` configuration, which is this plugin's own exported Config:
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
# either discovered at runtime (the slopo binary), set by the user through the
|
||||
# settings namespace, or genuinely optional. Shipping defaults would mean baking
|
||||
# one machine's paths into a layer that other machines also apply, and the
|
||||
# plugin's own schema already carries the defaults it can honestly claim.
|
||||
# settings card or the tool's `configure` action, or genuinely optional. Shipping
|
||||
# defaults would mean baking one machine's paths into a layer that other machines
|
||||
# also apply, and the plugin's own schema already carries the defaults it can
|
||||
# honestly claim.
|
||||
|
||||
- insert:
|
||||
- id: dsh-slopo
|
||||
|
||||
@@ -7,8 +7,11 @@
|
||||
* All three are user-invocable and deliberately NOT model-invocable,
|
||||
* matching upstream: every one of these commands can spend minutes
|
||||
* embedding, so the human decides when.
|
||||
* 2. A `dsh-slopo` settings namespace on `ctx.settings`, so the embedding
|
||||
* backend is editable at runtime instead of baked into a YAML layer.
|
||||
* 2. The `dsh-slopo` configuration: this module's own exported `Config`, which
|
||||
* 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
|
||||
* 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
|
||||
* skills disappear on any profile without a settings provider, which is a
|
||||
* 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
|
||||
* source tree, not build output, and a `prepare` script would make every user
|
||||
@@ -52,6 +55,7 @@ import {
|
||||
installEnv,
|
||||
lastLogError,
|
||||
commandLine,
|
||||
runShell,
|
||||
slopoArgs,
|
||||
uvInstallArgs,
|
||||
} from './lib/slopo.js'
|
||||
@@ -107,38 +111,49 @@ const SETTING_KEYS = [
|
||||
* `embedding_model` and `embedding_dimensions`, and guessing an embedding
|
||||
* backend would mean guessing where money is spent. Unset means unset, and the
|
||||
* 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({
|
||||
/** 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. */
|
||||
autoInstall: Schema.boolean().default(true),
|
||||
autoInstall: Schema.boolean().default(true).volatile(),
|
||||
/** 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. */
|
||||
embeddingDimensions: Schema.number().default(0),
|
||||
embeddingDimensions: Schema.number().default(0).volatile(),
|
||||
/** 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. */
|
||||
embeddingApiKey: Schema.string().default(''),
|
||||
embeddingApiKey: Schema.string().default('').role('secret').volatile(),
|
||||
/** 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. */
|
||||
sourceDir: Schema.string().default(''),
|
||||
sourceDir: Schema.string().default('').volatile(),
|
||||
/** 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. */
|
||||
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. */
|
||||
maxIndexUnits: Schema.number().default(5000),
|
||||
maxIndexUnits: Schema.number().default(5000).volatile(),
|
||||
/** 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>. */
|
||||
stateDir: Schema.string().default(''),
|
||||
stateDir: Schema.string().default('').volatile(),
|
||||
/** 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. */
|
||||
timeoutMs: Schema.number().default(600_000),
|
||||
timeoutMs: Schema.number().default(600_000).volatile(),
|
||||
})
|
||||
|
||||
/** 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 dshHome = dshHomeOf(ctx)
|
||||
|
||||
// The authoritative settings source. Starts as the composition config and is
|
||||
// replaced by the settings scope's thunk once a provider attaches; if none
|
||||
// ever does, everything below still works off the composition layer.
|
||||
let readSettings = () => config
|
||||
// `config` is the live configuration, not a snapshot of the composition
|
||||
// layer: a settings write rewrites this entry's patch and the Loader
|
||||
// re-applies the plugin with the new values, so every read below sees the
|
||||
// deployment's current answer. There is deliberately no cached copy — a cache
|
||||
// is exactly what a reload invalidates.
|
||||
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) => {
|
||||
try {
|
||||
settingsCtx.settings.installSection(ctx, SETTINGS_NAMESPACE, Config, config, {
|
||||
setSource: (current) => {
|
||||
readSettings = current
|
||||
},
|
||||
onChange: () => {},
|
||||
})
|
||||
settingsCtx.effect(
|
||||
() => settingsCtx.settings.configure({ auto: false }, ctx.fiber),
|
||||
'dsh-slopo: settings page policy',
|
||||
)
|
||||
writeSettings = (patch) => settingsCtx.settings.update(SETTINGS_NAMESPACE, patch)
|
||||
} catch (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)
|
||||
|
||||
/** 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() : ''
|
||||
if (configured !== '' && existsSync(configured)) return configured
|
||||
const found = findSlopo(dshHome)
|
||||
@@ -369,7 +400,11 @@ export function apply(ctx, config) {
|
||||
if (settings.autoInstall !== true) {
|
||||
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) {
|
||||
return {
|
||||
error: 'SLOPO_INSTALL_FAILED',
|
||||
@@ -446,7 +481,7 @@ export function apply(ctx, config) {
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const cwd = exec.agent?.session.header.cwd ?? process.cwd()
|
||||
const settings = readSettings() ?? config
|
||||
const settings = config
|
||||
const paths = workspacePaths(dshHome, cwd, settings)
|
||||
const shell = ctx.get('shell')
|
||||
// Resolved once, up front, so `status` can probe without a second
|
||||
@@ -484,18 +519,16 @@ export function apply(ctx, config) {
|
||||
let slopoVersion
|
||||
if (binary !== undefined && shell !== undefined) {
|
||||
try {
|
||||
const versionResult = await shell.run(
|
||||
shell.resolve({
|
||||
const versionResult = await runShell(shell, {
|
||||
command: commandLine([binary, '--version']),
|
||||
workdir: paths.cwd,
|
||||
timeoutMs: 30_000,
|
||||
...(sandboxPolicy === undefined ? {} : { sandboxPolicy }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
slopoVersion =
|
||||
versionResult.exitCode === 0
|
||||
? (versionResult.stdout?.text ?? '').trim().split('\n')[0]
|
||||
: `exit code ${versionResult.exitCode}: ${(versionResult.stderr?.text ?? '').trim().slice(0, 200)}`
|
||||
? versionResult.stdout.text.trim().split('\n')[0]
|
||||
: `exit code ${versionResult.exitCode}: ${versionResult.stderr.text.trim().slice(0, 200)}`
|
||||
} catch (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 } = {}) => {
|
||||
const result = await shell.run(
|
||||
shell.resolve({
|
||||
/**
|
||||
* Run one command through the shell seam.
|
||||
*
|
||||
* `runShell` returns the seam's own result; everything below wants the
|
||||
* flattened strings, so the adaptation happens once, here.
|
||||
*
|
||||
* `workdir` is overridable because installing slopo has nothing to do
|
||||
* with the workspace: the workspace may not even exist yet on a fresh
|
||||
* session, and a shell that cannot `cd` there fails before `uv` is
|
||||
* reached. The plugin's own state directory is always a real directory
|
||||
* by the time an install runs.
|
||||
*/
|
||||
const run = async (command, { env, timeoutMs, signal, workdir } = {}) => {
|
||||
const result = await runShell(shell, {
|
||||
command,
|
||||
workdir: paths.cwd,
|
||||
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 {
|
||||
exitCode: result.exitCode,
|
||||
stdout: result.stdout?.text ?? '',
|
||||
stderr: result.stderr?.text ?? '',
|
||||
truncated: Boolean(result.stdout?.truncated || result.stderr?.truncated),
|
||||
stdout: result.stdout.text,
|
||||
stderr: result.stderr.text,
|
||||
truncated: result.stdout.truncated || result.stderr.truncated,
|
||||
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') {
|
||||
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 =
|
||||
typeof settings.embeddingApiKey === 'string' && settings.embeddingApiKey !== ''
|
||||
? { SLOPO_EMBEDDING_API_KEY: settings.embeddingApiKey }
|
||||
@@ -765,9 +813,19 @@ export function apply(ctx, config) {
|
||||
const jobId = jobs.start({
|
||||
kind: 'slopo',
|
||||
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: () => {
|
||||
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 { refused, counted, outcome } = await runScoped(controller.signal)
|
||||
const logDetail = await readLogTail(paths.logFile)
|
||||
@@ -775,17 +833,17 @@ export function apply(ctx, config) {
|
||||
return {
|
||||
status: 'failed',
|
||||
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) {
|
||||
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 })
|
||||
return {
|
||||
status: 'failed',
|
||||
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 }
|
||||
|
||||
@@ -112,6 +112,48 @@ export function commandLine(argv) {
|
||||
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.
|
||||
*
|
||||
|
||||
+18
-2
@@ -32,7 +32,17 @@
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.js",
|
||||
"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": {
|
||||
"bundle": {
|
||||
@@ -41,9 +51,15 @@
|
||||
"client": {
|
||||
"platform": "web",
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"compatibility": {
|
||||
"dshReleases": {
|
||||
"0.1.7-rc.2": "compatible"
|
||||
}
|
||||
}
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
+705
-241
File diff suppressed because it is too large.
Load diff
+156
-23
@@ -9,7 +9,7 @@
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
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 { join } from 'node:path'
|
||||
import { test } from 'node:test'
|
||||
@@ -39,7 +39,16 @@ const FULL_CONFIG = {
|
||||
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 = {
|
||||
sandboxMode: undefined,
|
||||
resolve: (request) => ({
|
||||
@@ -48,8 +57,8 @@ const shell = {
|
||||
stdoutMaxBytes: 10_000_000,
|
||||
...request,
|
||||
}),
|
||||
run(spec) {
|
||||
return new Promise((resolve) => {
|
||||
async execute(spec) {
|
||||
const result = await new Promise((resolve) => {
|
||||
exec(
|
||||
spec.command,
|
||||
{ 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() {
|
||||
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.
|
||||
*
|
||||
@@ -79,7 +103,7 @@ const shell = {
|
||||
* so the assertions can see a result rather than a job handle.
|
||||
*/
|
||||
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
|
||||
// request is recorded for assertion.
|
||||
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 }
|
||||
},
|
||||
// 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 = {
|
||||
tools: new Map(),
|
||||
updates: [],
|
||||
provider: undefined,
|
||||
namespace: undefined,
|
||||
pagePolicy: undefined,
|
||||
settingsEffects: 0,
|
||||
shell: scopedShell,
|
||||
shellRequests: [],
|
||||
shellExecutions: 0,
|
||||
jobs: undefined,
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
fiber: { uid: 'test-fiber' },
|
||||
logger: () => ({ info() {}, warn() {}, debug() {}, error() {} }),
|
||||
get(name) {
|
||||
if (name === 'shell') return withShell ? scopedShell : undefined
|
||||
@@ -112,12 +145,22 @@ function harness(overrides = {}, { withSettings = true, withJobs = false, withSh
|
||||
},
|
||||
inject(deps, callback) {
|
||||
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({
|
||||
effect(effect) {
|
||||
state.settingsEffects += 1
|
||||
return effect()
|
||||
},
|
||||
settings: {
|
||||
installSection(owner, namespace, schema, entry, hooks) {
|
||||
state.namespace = namespace
|
||||
state.schema = schema
|
||||
hooks.setSource(() => config)
|
||||
configure(presentation, owner) {
|
||||
state.pagePolicy = presentation
|
||||
state.pagePolicyOwner = owner
|
||||
return () => {
|
||||
state.pagePolicy = undefined
|
||||
}
|
||||
},
|
||||
async update(namespace, patch) {
|
||||
state.updates.push({ namespace, patch })
|
||||
@@ -142,7 +185,7 @@ function harness(overrides = {}, { withSettings = true, withJobs = false, withSh
|
||||
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', () => {
|
||||
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()
|
||||
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 () => {
|
||||
@@ -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 () => {
|
||||
const { state } = harness({
|
||||
// Hermetic on purpose: `binary` points at a stub that exists, so the plugin
|
||||
// takes the "already installed" branch instead of running `uv tool install`
|
||||
// over the network, and the workspace is a real directory so the shell seam
|
||||
// has somewhere to run. The canned runner answers both commands.
|
||||
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 value = await tool.execute({ action: 'review' }, execContext('/tmp/nonexistent-repo-xyz'))
|
||||
// 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.
|
||||
const value = await tool.execute({ action: 'review' }, execContext(workspace))
|
||||
assert.equal(value.kind, 'foreground')
|
||||
assert.ok(existsSync(value.configPath), `expected config at ${value.configPath}`)
|
||||
const { readFileSync } = await import('node:fs')
|
||||
const written = readFileSync(value.configPath, 'utf8')
|
||||
assert.match(written, /embedding_model: "ollama\/example:latest"/)
|
||||
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 () => {
|
||||
@@ -321,6 +418,37 @@ test('only the warm-up backgrounds by default; the query actions return directly
|
||||
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(
|
||||
'review runs the real slopo CLI end to end against a real repository',
|
||||
{ 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')
|
||||
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.ok(seen[0].session !== undefined, 'the calling session must travel with the policy')
|
||||
assert.equal(state.shellRequests.length, 1)
|
||||
assert.deepEqual(state.shellRequests[0].sandboxPolicy, {
|
||||
// Every command the run issued carries it — not just the first. The count is
|
||||
// not the assertion: how many commands a review issues depends on whether the
|
||||
// index gate let it through.
|
||||
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 () => {
|
||||
|
||||
Reference in new issue
Block a user