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:
1 parent
0964819cdc
commit
040c69b57d
7 files changed
+1352
-668
No files matched your search
+80
-19
@@ -1,15 +1,21 @@
|
||||
import { strict as assert } from 'node:assert'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { Config, SETTING_KEYS, validateSettings } from '../index.js'
|
||||
import { Config, SETTING_KEYS, configValue, settingsSnapshot, validateSettings } from '../index.js'
|
||||
|
||||
/**
|
||||
* The load-time contract.
|
||||
* The load-time contract, and the shape settings arrive in.
|
||||
*
|
||||
* `apply` never runs when Cordis rejects a plugin's `Config`, so these are the
|
||||
* only failures a plugin cannot report from inside itself — the profile simply
|
||||
* refuses to boot. The rest of the suite calls `apply` directly and would never
|
||||
* notice, which is exactly why this file exists separately.
|
||||
*
|
||||
* There are two contracts here, not one. The first is validation. The second is
|
||||
* that a field marked `volatile()` arrives as a live reference rather than a
|
||||
* value — so a schema edit changes both what the plugin validates against and
|
||||
* how it must be read. Both are asserted, because getting the second wrong
|
||||
* looks like a config of `undefined`s rather than like an error.
|
||||
*/
|
||||
|
||||
test('Config is a Standard Schema, which is what Cordis requires', () => {
|
||||
@@ -17,29 +23,35 @@ test('Config is a Standard Schema, which is what Cordis requires', () => {
|
||||
assert.ok(Config['~standard'] !== undefined, 'Config must implement the Standard Schema interface')
|
||||
})
|
||||
|
||||
/** The section as the plugin reads it: schema-resolved, then unwrapped. */
|
||||
function resolve(patch = {}) {
|
||||
return settingsSnapshot(Config(patch))
|
||||
}
|
||||
|
||||
test('Config fills every field from an empty input, so apply can rely on defaults', () => {
|
||||
const filled = Config({})
|
||||
for (const key of SETTING_KEYS) {
|
||||
assert.ok(key in filled, `${key} has no default and would be undefined in apply`)
|
||||
}
|
||||
assert.equal(filled.embeddingApiBase, 'http://127.0.0.1:11434')
|
||||
assert.equal(filled.embeddingDimensions, 768)
|
||||
assert.equal(filled.syncBudgetMs, 20_000)
|
||||
assert.deepEqual(filled.exclude, [])
|
||||
assert.deepEqual(filled.extensions, [])
|
||||
const resolved = settingsSnapshot(filled)
|
||||
assert.equal(resolved.embeddingApiBase, 'http://127.0.0.1:11434')
|
||||
assert.equal(resolved.embeddingDimensions, 768)
|
||||
assert.equal(resolved.syncBudgetMs, 20_000)
|
||||
assert.deepEqual(resolved.exclude, [])
|
||||
assert.deepEqual(resolved.extensions, [])
|
||||
})
|
||||
|
||||
test('Config keeps an explicit value and fills the rest', () => {
|
||||
const filled = Config({ embeddingApiBase: 'http://10.50.1.2:11434', embeddingConcurrency: 8 })
|
||||
assert.equal(filled.embeddingApiBase, 'http://10.50.1.2:11434')
|
||||
assert.equal(filled.embeddingConcurrency, 8)
|
||||
assert.equal(filled.embeddingBatchSize, 16)
|
||||
assert.equal(filled.embeddingTimeoutMs, 30_000)
|
||||
assert.equal(filled.embeddingKeepAlive, '5m')
|
||||
const resolved = resolve({ embeddingApiBase: 'http://10.50.1.2:11434', embeddingConcurrency: 8 })
|
||||
assert.equal(resolved.embeddingApiBase, 'http://10.50.1.2:11434')
|
||||
assert.equal(resolved.embeddingConcurrency, 8)
|
||||
assert.equal(resolved.embeddingBatchSize, 16)
|
||||
assert.equal(resolved.embeddingTimeoutMs, 30_000)
|
||||
assert.equal(resolved.embeddingKeepAlive, '5m')
|
||||
// The per-excerpt cap equals the chunk cap, so chunks arrive whole.
|
||||
assert.equal(filled.excerptLines, 80)
|
||||
assert.equal(filled.totalExcerptLines, 400)
|
||||
assert.equal(filled.maxChunkLines, 80)
|
||||
assert.equal(resolved.excerptLines, 80)
|
||||
assert.equal(resolved.totalExcerptLines, 400)
|
||||
assert.equal(resolved.maxChunkLines, 80)
|
||||
})
|
||||
|
||||
test('a wrongly typed value fails loudly at load rather than silently in apply', () => {
|
||||
@@ -48,6 +60,48 @@ test('a wrongly typed value fails loudly at load rather than silently in apply',
|
||||
assert.throws(() => Config({ embeddingApiBase: { host: 'localhost' } }))
|
||||
})
|
||||
|
||||
test('a value outside a field range is refused by the schema, not just by validateSettings', () => {
|
||||
// These bounds are duplicated in validateSettings on purpose, but the copy
|
||||
// that matters most is this one: it is what the generated settings form
|
||||
// validates a draft against before the Host is ever asked to store it.
|
||||
assert.throws(() => Config({ minSimilarity: 1.5 }), /<= 1/)
|
||||
assert.throws(() => Config({ chunkLines: 0 }), />= 1/)
|
||||
assert.throws(() => Config({ overlapLines: -1 }), />= 0/)
|
||||
assert.throws(() => Config({ embeddingDimensions: -1 }), />= 1/)
|
||||
})
|
||||
|
||||
test('every declared field is volatile, or the settings surface is not served', () => {
|
||||
// `ctx.settings.describe` skips an entry with no volatile field, so this is
|
||||
// the difference between a configuration page and no configuration page —
|
||||
// not between a full page and an empty one.
|
||||
const envelope = JSON.parse(JSON.stringify(Config.toJSON()))
|
||||
const root = envelope.refs[String(envelope.uid)]
|
||||
const notVolatile = Object.entries(root.dict)
|
||||
.filter(([, uid]) => envelope.refs[String(uid)].meta?.volatile !== true)
|
||||
.map(([key]) => key)
|
||||
assert.deepEqual(notVolatile, [], 'every field must be volatile')
|
||||
})
|
||||
|
||||
test('a volatile field reads through configValue; a plain one is handed back', () => {
|
||||
const filled = Config({ embeddingApiBase: 'http://stub' })
|
||||
assert.equal(typeof filled.embeddingApiBase, 'object', 'a volatile field arrives as a reference')
|
||||
assert.equal(configValue(filled.embeddingApiBase), 'http://stub')
|
||||
// A test (or any caller) that hands `apply` a plain resolved object must keep
|
||||
// working: the same reader serves both shapes.
|
||||
assert.equal(configValue('http://plain'), 'http://plain')
|
||||
assert.equal(configValue(undefined), undefined)
|
||||
assert.equal(configValue(0), 0)
|
||||
})
|
||||
|
||||
test('a snapshot tracks a live edit, because volatile references update in place', () => {
|
||||
const filled = Config({ embeddingConcurrency: 2 })
|
||||
assert.equal(settingsSnapshot(filled).embeddingConcurrency, 2)
|
||||
// This is what the Loader does on a volatile-only config change: write the new
|
||||
// value into the reference the running instance already holds.
|
||||
filled.embeddingConcurrency[Object.getOwnPropertySymbols(filled.embeddingConcurrency)[0]](8)
|
||||
assert.equal(settingsSnapshot(filled).embeddingConcurrency, 8)
|
||||
})
|
||||
|
||||
test('every declared setting is a real schema key, so none is dead config', () => {
|
||||
const filled = Config({})
|
||||
for (const key of SETTING_KEYS) {
|
||||
@@ -60,13 +114,20 @@ test('every declared setting is a real schema key, so none is dead config', () =
|
||||
}
|
||||
})
|
||||
|
||||
/** The defaults plus an override, as the settings provider would resolve them. */
|
||||
/**
|
||||
* A plain section with an override laid over the resolved defaults.
|
||||
*
|
||||
* Deliberately built WITHOUT going back through `Config`: these tests are about
|
||||
* the rules `validateSettings` owns, and half of them are also schema bounds —
|
||||
* so parsing them would fail at the schema and never reach the rule under test.
|
||||
* The schema's own refusal of the same values is asserted separately above.
|
||||
*/
|
||||
function settingsWith(patch) {
|
||||
return { ...Config({}), ...patch }
|
||||
return { ...settingsSnapshot(Config({})), ...patch }
|
||||
}
|
||||
|
||||
test('validateSettings accepts the defaults', () => {
|
||||
assert.doesNotThrow(() => validateSettings(Config({})))
|
||||
assert.doesNotThrow(() => validateSettings(resolve()))
|
||||
})
|
||||
|
||||
test('validateSettings rejects an endpoint that is not a URL', () => {
|
||||
|
||||
Reference in new issue
Block a user