Files
deepseek-harness/packages/ui/app-boot/tests/config-reload.spec.ts
Yichen Jiang 8ddc53f7a0 feat(cli)!: complete --config on every surface and delete the personal overlay
$DSH_HOME/config.yaml was an implicit composition layer: if the file existed,
every launch applied an arbitrary Loader patch graph over the shipped tree,
kept live by a dedicated HMR watcher. Three costs came from the implicitness,
not the capability. A patch replaces its target row's whole config, so a file
written months ago pins that row to the field set it knew and every default
the shipped tree later adds silently stops applying. It competed with the
typed settings namespaces llm-deepseek and llm-pi-ai already register, so
which one wins was a function of layer order rather than meaning. And the
explicit escape hatch it was supposedly redundant with did not exist on every
surface: dsh -p, dsh meta, and dsh upgrade all rejected --config, so for them
the implicit file was the only composition route at all.

Complete the explicit layer first: --config and --config-replace now work on
every booting surface. A headless --config-replace tree must still mount a
webserver row, because that surface reaches its own agent over the same HTTP
gateway the browser uses; AppCLIEntry names that contract in the failure
instead of reporting a bare missing service.

Then delete the implicit one. PERSONAL_CONFIG_FILENAME, loadPersonalPatches,
watchPersonalPatches, and the config-only HMR row mounted for it are gone; a
file left at that path is inert, and --dump-config no longer reads the Harness
home. --config therefore stops *replacing* the personal overlay and simply
*is* the user overlay.

No migration: a user who wants the old behavior names the same file
(dsh --config ~/.dsh/config.yaml), which a shell alias makes permanent.
2026-08-04 15:25:04 +08:00

390 lines
16 KiB
TypeScript

/**
* Transactional config replacement through the booted Include and Loader tree.
* HMR contains rejected refreshes; direct callers receive the error after the
* previous generation has been retained or restored.
*/
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import type { Context } from 'cordis'
import type { Include } from '@cordisjs/plugin-include'
import { Group } from '@cordisjs/plugin-loader'
import { boot } from '../src/index.ts'
const NAME = 'dsh-test-bin'
const NOOP_PLUGIN = 'export const name = "noop"\nexport function apply() {}\n'
interface TreeFixture {
ctx: Context
dir: string
include: Include
}
async function bootTree(configBody: string, files: Record<string, string> = {}): Promise<TreeFixture> {
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-'))
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
for (const [name, content] of Object.entries(files)) writeFileSync(join(dir, name), content)
writeFileSync(join(dir, 'cordis.yml'), configBody)
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
const entry = [...ctx.loader.entries()].find(candidate => candidate.subtree !== undefined)
if (entry?.subtree === undefined) throw new Error('booted tree has no include entry')
return { ctx, dir, include: entry.subtree as Include }
}
function entryConfig(ctx: Context, id: string): unknown {
return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config
}
function entryById(ctx: Context, id: string) {
const entry = [...ctx.loader.entries()].find(entry => entry.options.id === id)
if (!entry) throw new Error(`missing loader entry ${id}`)
return entry
}
function plugin(name: string, body = ''): string {
return `export default function ${name}(_ctx, config = {}) { ${body} }\n`
}
async function expectUpdateFailure(task: Promise<void>, stage: string): Promise<void> {
try {
await task
} catch (error) {
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toContain(`failed to ${stage} loader entry`)
return
}
throw new Error(`expected loader update to fail during ${stage}`)
}
describe('include refresh with an invalid file', () => {
it('rejects while keeping the last good tree, then applies the next valid edit', async () => {
const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n config:\n value: 1\n')
try {
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
writeFileSync(join(dir, 'cordis.yml'), 'invalid: [unclosed\n')
await expect(include.refresh()).rejects.toThrow('failed to parse config file')
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
// An empty file parses to `undefined` without a YAML error; it must be
// treated exactly like a parse failure, not crash the entry walk.
writeFileSync(join(dir, 'cordis.yml'), '')
await expect(include.refresh()).rejects.toThrow('failed to validate config file')
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: 2\n')
await include.refresh()
await ctx.loader.await()
expect(entryConfig(ctx, 'noop')).toEqual({ value: 2 })
} finally {
await ctx.fiber.dispose()
}
})
})
describe('loader entry replacement', () => {
it('imports a changed name before replacing the running plugin', async () => {
const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', {
'old.mjs': plugin('oldPlugin'),
'new.mjs': plugin('newPlugin'),
})
try {
const entry = entryById(ctx, 'target')
await entry.update({ name: './new.mjs' })
expect(entry.options.name).toBe('./new.mjs')
expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options)
expect(entry.fiber?.runtime?.callback.name).toBe('newPlugin')
expect(entry.options.disabled).toBeUndefined()
await entry.fiber?.await()
} finally {
await ctx.fiber.dispose()
}
})
it('retains the running plugin when the replacement cannot be imported', async () => {
const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', {
'old.mjs': plugin('oldPlugin'),
})
try {
const entry = entryById(ctx, 'target')
const fiber = entry.fiber
await expectUpdateFailure(entry.update({ name: './missing.mjs' }), 'import')
expect(entry.options.name).toBe('./old.mjs')
expect(entry.fiber === fiber).toBe(true)
await fiber?.await()
} finally {
await ctx.fiber.dispose()
}
})
it('restores the previous plugin after replacement application fails', async () => {
const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', {
'old.mjs': plugin('oldPlugin'),
'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'),
})
try {
const entry = entryById(ctx, 'target')
const previous = entry.fiber
await expectUpdateFailure(entry.update({ name: './bad.mjs' }), 'apply')
expect(entry.options.name).toBe('./old.mjs')
expect(entry.fiber === previous).toBe(false)
expect(entry.fiber?.runtime?.callback.name).toBe('oldPlugin')
expect(entry.options.disabled).toBeUndefined()
await entry.fiber?.await()
} finally {
await ctx.fiber.dispose()
}
})
it('restores the previous config when an in-place restart fails', async () => {
const { ctx } = await bootTree('- id: target\n name: ./configurable.mjs\n config:\n fail: false\n', {
'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'),
})
try {
const entry = entryById(ctx, 'target')
const fiber = entry.fiber
await expectUpdateFailure(entry.update({ config: { fail: true } }), 'apply')
expect(entry.options.config).toEqual({ fail: false })
expect(entry.fiber === fiber).toBe(true)
await fiber?.await()
} finally {
await ctx.fiber.dispose()
}
})
it('does not persist a failed direct fiber update', async () => {
const { ctx } = await bootTree('- id: target\n name: ./configurable.mjs\n config:\n fail: false\n', {
'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'),
})
try {
const entry = entryById(ctx, 'target')
const fiber = entry.fiber
if (!fiber) throw new Error('target entry has no fiber')
await expect(fiber.update({ fail: true })).rejects.toThrow('candidate config failed')
expect(entry.options.config).toEqual({ fail: false })
expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options)
} finally {
await ctx.fiber.dispose()
}
})
})
describe('loader tree replacement', () => {
it('rolls back earlier updates and additions when a later entry fails', async () => {
const { ctx, dir, include } = await bootTree([
'- id: existing',
' name: ./configurable.mjs',
' config:',
' value: old',
'',
].join('\n'), {
'configurable.mjs': plugin('configurablePlugin'),
'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'),
})
try {
writeFileSync(join(dir, 'cordis.yml'), [
'- id: existing',
' name: ./configurable.mjs',
' config:',
' value: candidate',
'- id: added',
' name: ./noop.mjs',
'- id: bad',
' name: ./bad.mjs',
'',
].join('\n'))
await expect(include.refresh()).rejects.toThrow('failed to apply loader entry bad')
expect(entryConfig(ctx, 'existing')).toEqual({ value: 'old' })
expect([...ctx.loader.entries()].some(entry => entry.options.id === 'added')).toBe(false)
expect([...ctx.loader.entries()].some(entry => entry.options.id === 'bad')).toBe(false)
writeFileSync(join(dir, 'cordis.yml'), [
'- id: existing',
' name: ./configurable.mjs',
' config:',
' value: committed',
'- id: added',
' name: ./noop.mjs',
'',
].join('\n'))
await include.refresh()
expect(entryConfig(ctx, 'existing')).toEqual({ value: 'committed' })
expect(entryById(ctx, 'added').fiber).toBeDefined()
} finally {
await ctx.fiber.dispose()
}
})
it('stops and restores descendants when an ancestor group is disabled and re-enabled', async () => {
const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n')
ctx.loader.builtins.group = Group
try {
const config = (disabled: boolean) => [
'- id: parent',
' name: cordis:group',
' group: true',
` disabled: ${disabled}`,
' config:',
' - id: child',
' name: ./noop.mjs',
'',
].join('\n')
writeFileSync(join(dir, 'cordis.yml'), config(false))
await include.refresh()
expect(entryById(ctx, 'child').fiber).toBeDefined()
writeFileSync(join(dir, 'cordis.yml'), config(true))
await include.refresh()
expect(entryById(ctx, 'child').fiber).toBeUndefined()
writeFileSync(join(dir, 'cordis.yml'), config(false))
await include.refresh()
expect(entryById(ctx, 'child').fiber).toBeDefined()
} finally {
await ctx.fiber.dispose()
}
})
it('restores a programmatic entry move when its update fails', async () => {
const { ctx } = await bootTree('- id: noop\n name: ./noop.mjs\n', {
'movable.mjs': plugin('movablePlugin', 'if (config.fail) throw new Error("candidate config failed")'),
})
ctx.loader.builtins.group = Group
try {
const groupId = await ctx.loader.create({ name: 'cordis:group', group: true, config: [] })
const targetId = await ctx.loader.create({ name: './movable.mjs', config: { fail: false } })
const target = entryById(ctx, targetId)
const source = target.parent
const sourceIndex = source.data.indexOf(target.options)
const destination = entryById(ctx, groupId).subgroup
if (!destination) throw new Error('created loader group has no subgroup')
await expectUpdateFailure(
ctx.loader.update(targetId, { config: { fail: true } }, groupId),
'apply',
)
expect(target.parent).toBe(source)
expect(Object.getPrototypeOf(target.ctx)).toBe(source.ctx)
expect(source.data.indexOf(target.options)).toBe(sourceIndex)
expect(destination.data).not.toContain(target.options)
expect(target.options.config).toEqual({ fail: false })
} finally {
await ctx.fiber.dispose()
}
})
})
describe('include refresh with overlay patches', () => {
it('re-applies entry patches and inserted entries on every re-read (parity with initial load)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-overlay-'))
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n')
writeFileSync(join(dir, 'cordis.yml'), [
'- id: base',
" name: 'cordis:include'",
' config:',
' path: ./base.yml',
' patches:',
' - id: noop',
' name: ./noop.mjs',
' config:',
' value: patched',
' - insert:',
' - id: extra',
' name: ./noop.mjs',
'',
].join('\n'))
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
try {
const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === 'base')
if (entry?.subtree === undefined) throw new Error('overlay tree has no base include entry')
const include = entry.subtree as Include
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched' })
expect(entryConfig(ctx, 'extra')).toBeUndefined()
expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(true)
writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: edited\n')
await include.refresh()
await ctx.loader.await()
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched' })
expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(true)
// Hot-update of the include entry's own config (the `internal/update`
// path): the new patches must apply now AND stick for later re-reads —
// the listener vetoes the fiber restart, so it must persist the new
// config itself or the next refresh() re-applies the old overlay.
await entry.update({ config: { path: './base.yml', patches: [{ id: 'noop', name: './noop.mjs', config: { value: 'patched-v2' } }] } })
await ctx.loader.await()
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' })
expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(false)
writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: edited-2\n')
await include.refresh()
await ctx.loader.await()
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' })
// Omitting the patch list must remove the overlay rather than reuse the
// Include's previous config through a default parameter.
await entry.update({ config: { path: './base.yml' } })
await ctx.loader.await()
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'edited-2' })
} finally {
await ctx.fiber.dispose()
}
})
})
describe('include patches layered over one base', () => {
it('lets a later patch configure or disable a row an earlier patch inserted', async () => {
// The surface/`--config` composition: `dsh` includes one shared base and
// applies each source as its own patch list at the SAME include level,
// because patches never cross an include boundary. A later layer must
// therefore be able to reach a row an earlier layer inserted — otherwise
// every surface-only row (the whole TUI front door) would be invisible to
// the user's `--config` overlay.
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-layered-'))
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
writeFileSync(join(dir, 'base.yml'), '- id: shared\n name: ./noop.mjs\n config:\n value: base\n')
writeFileSync(join(dir, 'cordis.yml'), [
'- id: base',
" name: 'cordis:include'",
' config:',
' path: ./base.yml',
' patches:',
// Layer 1 (a surface overlay): patch a base row and add two of its own.
' - id: shared',
' config:',
' value: surface',
' - insert:',
' - id: surface-kept',
' name: ./noop.mjs',
' config:',
' value: surface-default',
' - id: surface-dropped',
' name: ./noop.mjs',
// Layer 2 (the user): reconfigure one inserted row and disable the other.
' - id: surface-kept',
' config:',
' value: user',
' - id: surface-dropped',
' disabled: true',
'',
].join('\n'))
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
try {
expect(entryConfig(ctx, 'shared')).toEqual({ value: 'surface' })
expect(entryConfig(ctx, 'surface-kept')).toEqual({ value: 'user' })
const dropped = [...ctx.loader.entries()].find(entry => entry.options.id === 'surface-dropped')
expect(dropped?.options.disabled).toBe(true)
expect(dropped?.fiber).toBeUndefined()
} finally {
await ctx.fiber.dispose()
}
})
})