fix!: restore the settings page against the harness 0.1.7 plugin contracts

The configuration page registered into seats that 0.1.7 removed, so it could
not appear at all — and the schema carried none of the metadata the new
settings document requires.

Host half. `ctx.settings.installSection` no longer exists: a plugin exports a
`Config` schema, the Harness keys one form per composed entry by that row's
`id`, and `ctx.settings.describe` skips an entry with no `volatile()` field
outright. Every field is now volatile, which is also what makes a saved edit
apply to the running instance instead of remounting it — so `apply` reads the
section through `settingsSnapshot`, which unwraps the live references a
volatile field arrives as. Writes go through `ctx.settings.update` fenced by
the revision `describe` reports. The `.min`/`.max` bounds the schema can state
are stated there, so the form refuses an impossible draft in front of the
person typing it; `validateSettings` keeps the cross-field rules no single
field can express, and now runs on the section a call is about to act on
rather than at write time, because 0.1.7 has no write-time validator hook.

Browser half. `settingsScope` and the `settings.plugin.item` slot are gone. The
page renders through `plugins.row.config`, keyed `<bundle package>#<row id>`
(`dsh-recall#dsh-recall`), which is what puts a Configure control on the
plugin's row in the Plugins panel. It uses the framework's own staged form —
`SettingsFormModel` over `ctx.configForms`, drawn by `SettingsForm` and
`SettingsValueField`/`SettingsSecretField` — instead of reproducing card chrome
by hand, so the unsaved, overridden, invalid, and save states come from the
same code as every other plugin's page. All 33 settings are editable now; the
old card exposed 23. The right-sidebar tab also needed the stage-one tab TYPE
(`ctx.sidebarRightTabs.register`) that its keyed body slot is dispatched under,
so a tab can finally be opened. `--dsh-border`, defined nowhere, becomes the
product's `--dsw-alias-border-l2`.

Both halves are now pinned by tests: every field is volatile, the field list
and the schema cannot drift, the page claims the key the patch declares, and a
blank API key is never written — the secret control reports configured-and-
absent instead.

Verified against the harness rather than only in unit tests: `--dump-config-
schema` emits the volatile and secret metadata, `apply` registers `recall` on
the real tool registry, `settings.describe({redactSecrets:true})` serves
`dsh-recall` with 33 fields and the key reported set-but-redacted, and a
scratch profile boots the web app with the plugin composed and its bundle
served byte-for-byte as this checkout has it.
This commit is contained in:
NotExpectedYet committed 2026-09-26 06:37:57 +01:00
1 parent 0964819cdc
commit 040c69b57d
7 files changed
+1352 -668

No files matched your search

+131 -5
View File
@@ -3,7 +3,7 @@ import { readFile, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { test } from 'node:test'
import { apply } from '../index.js'
import { apply, Config, SETTING_KEYS, settingsSnapshot } from '../index.js'
import { makeContext, makeExec, makeStateDir, makeStubFetch, makeWorkspace } from './helpers.js'
/**
@@ -135,6 +135,7 @@ function configFor(stateDir, extra = {}) {
embeddingBatchSize: 32,
embeddingConcurrency: 2,
embeddingTimeoutMs: 5_000,
embeddingKeepAlive: '5m',
extensions: [],
exclude: [],
maxFiles: 500,
@@ -146,12 +147,16 @@ function configFor(stateDir, extra = {}) {
maxLimit: 25,
perFileLimit: 2,
excerptLines: 40,
totalExcerptLines: 400,
minSimilarity: 0.2,
syncBudgetMs: 20_000,
// These mirror the Config schema exactly. Cordis fills schema defaults
// before `apply`, and this object stands in for that validated config — so
// any key the code reads and this object omits arrives as `undefined`, which
// is how `AbortSignal.timeout(NaN)` came to throw inside the expander.
// This stands in for the config Cordis hands `apply` after schema
// resolution: every declared field present, defaults filled. A key this
// object omits arrives as `undefined` in the plugin — which is how
// `AbortSignal.timeout(NaN)` once came to throw inside the expander, and how
// the use-time `validateSettings` guard below came to reject a whole call.
// `configFor covers exactly the declared settings` in plugin.test.js keeps
// the two lists from drifting apart again.
focus: '',
focusMarkers: [],
expansionEnabled: false,
@@ -199,6 +204,39 @@ test('the plugin registers one tool whose declared schema is closed', async (t)
assert.equal(tool.isConcurrencySafe({ action: 'index' }), false)
})
test('configFor covers exactly the declared settings', () => {
// `configFor` stands in for the schema-resolved config Cordis hands `apply`.
// If it drifts from the schema, the plugin reads `undefined` where a default
// belongs — and `validateSettings`, which now runs on every call, turns that
// into a refused call rather than a confusing one. Deriving the list from the
// real schema is what makes that impossible to miss.
const resolved = settingsSnapshot(Config({}))
const fixture = configFor('/tmp/state')
assert.deepEqual(
Object.keys(fixture).sort(),
SETTING_KEYS.slice().sort(),
'configFor must name every declared setting and nothing else',
)
assert.deepEqual(
Object.keys(resolved).sort(),
SETTING_KEYS.slice().sort(),
'SETTING_KEYS must name every field the schema declares',
)
})
test('a configured section that cannot be acted on is refused with a remedy', async (t) => {
// The Host validates each field against Config; the cross-field rules have no
// schema to live in, so `execute` checks them before putting the section to
// work. The alternative — acting on it — is an unexplained empty search.
const { tool, workspace } = await setup(t, FILES, { defaultLimit: 50, maxLimit: 10 })
const value = await tool.execute({ query: 'session' }, makeExec(workspace))
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
assert.equal(value.ok, false)
assert.equal(value.errorCode, 'INVALID_SETTINGS')
assert.match(value.note, /defaultLimit \(50\) cannot exceed maxLimit \(10\)/)
assert.match(value.remedy, /Settings/)
})
test('a search returns the right file, as an excerpt rather than a whole file', async (t) => {
const { tool, workspace } = await setup(t)
const value = await tool.execute(
@@ -358,6 +396,94 @@ test('configure rejects an unknown setting by name', async (t) => {
assert.match(value.note, /nonsense/)
})
/**
* Mount the plugin with a settings provider, and record what it is asked to do.
*
* In 0.1.7 the namespace is the profile ENTRY id, and the revision the write is
* fenced with comes from `describe()` — so both are asserted rather than
* assumed, because getting either wrong writes to a row that does not exist or
* silently overwrites a concurrent edit.
*/
async function setupWithSettings(t, extra = {}, behaviour = {}) {
const workspace = await makeWorkspace(FILES)
const stateDir = await makeStateDir()
const calls = { update: [], describe: 0 }
globalThis.fetch = makeStubFetch({})
t.after(async () => {
await rm(workspace, { recursive: true, force: true })
await rm(stateDir, { recursive: true, force: true })
})
const settings = {
describe: () => {
calls.describe += 1
return [
{ ns: 'some-other-plugin', revision: 3 },
{ ns: 'dsh-recall', revision: 7 },
]
},
update: async (ns, patch, revision) => {
calls.update.push({ ns, patch, revision })
if (behaviour.reject === true) throw new Error('settings/rejected')
},
}
const { ctx, tools, dispose } = makeContext({ services: { settings } })
t.after(() => dispose())
apply(ctx, configFor(stateDir, extra))
return { tool: tools[0], workspace, calls }
}
test('configure writes to the profile entry id, fenced by the revision describe reported', async (t) => {
const { tool, workspace, calls } = await setupWithSettings(t)
const value = await tool.execute(
{ action: 'configure', config: { embeddingApiBase: 'http://elsewhere:11434' } },
makeExec(workspace),
)
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
assert.equal(value.ok, true)
assert.match(value.note, /embeddingApiBase/)
assert.deepEqual(calls.update, [
{ ns: 'dsh-recall', patch: { embeddingApiBase: 'http://elsewhere:11434' }, revision: 7 },
])
})
test('configure judges the section a patch would leave, and writes nothing it refuses', async (t) => {
// Raising `defaultLimit` alone is valid; raising it past `maxLimit` is not,
// and the contradiction is invisible to either field's schema.
const { tool, workspace, calls } = await setupWithSettings(t)
const value = await tool.execute(
{ action: 'configure', config: { defaultLimit: 99 } },
makeExec(workspace),
)
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
assert.equal(value.ok, false)
assert.equal(value.errorCode, 'INVALID_SETTING')
assert.match(value.note, /defaultLimit \(99\) cannot exceed maxLimit \(25\)/)
assert.deepEqual(calls.update, [], 'a refused patch must not reach the document')
})
test('a settings write the document refuses is reported, not thrown', async (t) => {
const { tool, workspace } = await setupWithSettings(t, {}, { reject: true })
const value = await tool.execute(
{ action: 'configure', config: { embeddingApiBase: 'http://elsewhere:11434' } },
makeExec(workspace),
)
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
assert.equal(value.ok, false)
assert.equal(value.errorCode, 'SETTINGS_WRITE_FAILED')
assert.match(value.remedy, /concurrent/)
})
test('reading settings does not need a settings provider, because volatile fields are live', async (t) => {
// The 0.1.5 shape attached a reader to the settings provider; 0.1.7 does not,
// so `apply` must not discover configured values only when one is mounted.
const { tool, workspace } = await setupWithSettings(t, { defaultLimit: 3 })
const value = await tool.execute({ query: 'session token' }, makeExec(workspace))
assert.equal(value.ok, true)
assert.ok(value.results.length <= 3, 'the configured limit must be honoured without a provider')
})
test('an unreachable endpoint fails with a remedy, not a stack trace', async (t) => {
const workspace = await makeWorkspace(FILES)
const stateDir = await makeStateDir()