Files
deepseek-harness/vendor/include/src/index.ts
T
Turtle f290a8b851 refactor(cli)!: one shared base config with per-surface overlays
`dsh` shipped two config trees that were 43 rows the same: apps/cli/cordis.yml
composed web as 74 flat rows, while the TUI booted examples/tui-agent/cordis.yml
whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins behind a
twenty-key pass-through Config. Neither file was what its location claimed —
apps/cli hardcoded the "example" as the product default and the "demo" bundle
was the application — and every capability change had to be made twice.

- apps/cli/base.cordis.yml holds the 43 shared rows; tui.cordis.yml and
  web.cordis.yml are patch lists stating only what differs per surface
- overlays apply as SIBLING patch lists at one include level, because include
  patches never cross an include boundary. Precedence: base < surface <
  (--config | personal ~/.dsh/config.yaml) < launcher flag/profile patches
- `--config` now applies an overlay INSTEAD OF the personal one, so a demo or
  test tree never inherits the user's route; new `--config-replace` boots a file
  as the entire tree (the old `--config` behaviour). Both survive /resume
- vendor/include: index each `insert`ed row as it is added so a later patch can
  configure or disable it. Upstream built the id index once before the patch
  loop, leaving every surface-only row — the whole TUI front door — silently
  unpatchable from user config. Logged as local modification 8
- session identity moves to dsh-agent-loop's CONFIGURED_AGENT_IDENTITIES_KEY;
  dsh-tui's MAIN_SESSION_ID_KEY is deleted (only the bundle read it)
- delete examples/tui-agent, examples/cordis-agent, packages/examples/tui-demo;
  TUI tests → apps/cli/tests, cordis e2e → packages/cordis/tool-cordis/tests,
  examples/code-mode survives as an overlay leaf
- `dsh web` gains --config, threaded into AppCLIEntry as an extra overlay

Three latent defects surfaced and are fixed here: the TUI captured the optional
sessionQuery service once at construction and could permanently disable /resume
when it won the mount race; the session-store root silently reverted to a
project-local ./.sessions; --config-replace was dropped by the resume handoff.

Verified by booting each tree through the real Loader (TUI 55 entries, web 75,
zero unsettled) rather than reading YAML. All eight terminal snapshots replay
byte-identically; 14/14 PTY smoke, 112/112 snapshots, 25/25 doc-sync, hygiene
and lint clean.
2026-07-29 21:15:42 +08:00

271 lines
8.8 KiB
TypeScript

import { EntryTree, isJsExpr, type EntryOptions } from '@cordisjs/plugin-loader'
import { Context, Service } from 'cordis'
import { extname } from 'node:path'
import { access, constants, readFile, rename, writeFile } from 'node:fs/promises'
import { fileURLToPath, pathToFileURL } from 'node:url'
import * as yaml from 'js-yaml'
const JsExpr = new yaml.Type('tag:yaml.org,2002:js', {
kind: 'scalar',
resolve: (data) => typeof data === 'string',
construct: (data) => ({ __jsExpr: data }),
predicate: isJsExpr,
represent: (data) => data['__jsExpr'],
})
const schema = yaml.JSON_SCHEMA.extend(JsExpr)
const writable: Record<string, string> = {
'.json': 'application/json',
'.yaml': 'application/yaml',
'.yml': 'application/yaml',
}
const supported = new Set(Object.keys(writable))
/** Runtime patch applied to entries loaded from an included config file. */
export interface PatchOptions {
id?: string
insert?: EntryOptions[]
name?: string
config?: any
group?: boolean | null
disabled?: boolean | null
inject?: any
intercept?: any
isolate?: any
[key: string]: any
}
/** Config namespace for the file-backed include loader. */
export namespace Include {
/** Config for a file-backed loader subtree. */
export interface Config {
/** YAML or JSON path resolved from `ctx.baseUrl`. */
path: string
/** Entry list written when the file does not already exist. */
initial?: any[]
/** Runtime patches applied after reading the file. */
patches?: PatchOptions[]
/** Enables loader apply/reload/unload logs for this subtree. */
enableLogs?: boolean
}
}
/** Loader entry tree backed by a YAML or JSON file. */
export class Include extends EntryTree {
static inject = ['loader']
public filename: string
private type?: string
private readonly: boolean
private content?: string
private data?: EntryOptions[]
private writeTask?: NodeJS.Timeout
constructor(ctx: Context, public config: Include.Config) {
super(ctx)
this.enableLogs = config.enableLogs ?? ctx.fiber.entry?.parent.tree.enableLogs ?? false
this.filename = fileURLToPath(new URL(this.config.path, this.ctx.baseUrl))
const ext = extname(this.filename)
if (!supported.has(ext)) {
throw new Error(`extension "${ext}" not supported`)
}
this.type = writable[ext]
this.readonly = !this.type
this.ctx.baseUrl = new URL('.', pathToFileURL(this.filename)).href
ctx.on('internal/update', (config, _, next) => {
if (config.path !== this.config.path) return next()
// Veto the fiber restart (children update in place), but persist the new
// config ourselves — `Fiber.update` only assigns `this.config` behind
// `next()`, and a stale `this.config.patches` would make the next
// `refresh()` re-apply the old overlay.
this.config = config
this.root.update(this.applyPatches(this.data!, config.patches)).catch((error) => {
this.ctx.logger.warn('config update at %C failed', this.filename)
this.ctx.logger.warn(error)
})
})
}
private async checkAccess() {
if (!this.type) return
try {
await access(this.filename, constants.W_OK)
} catch {
this.readonly = true
}
}
private async read(forced = false) {
const content = await readFile(this.filename, 'utf8')
if (!forced && this.content === content) return false
let data: any
if (this.type === 'application/yaml') {
data = yaml.load(content, { schema })
} else if (this.type === 'application/json') {
data = JSON.parse(content)
} else {
const module = await import(/* @vite-ignore */ this.filename)
data = module.default || module
}
// An empty or truncated file (common mid-edit: editors and `sed -i` write
// through temp states) parses to `undefined`, not an error; reject every
// non-array shape here so callers see one "invalid file" signal. Content
// and data commit only on success, so an edit that is later reverted to
// the exact last good content correctly reads as "unchanged".
if (!Array.isArray(data)) {
throw new TypeError(`config file must be a top-level array of entries: ${this.filename}`)
}
this.content = content
this.data = data
await this.checkAccess()
return true
}
private applyPatches(data: EntryOptions[], patches = this.config.patches): EntryOptions[] {
// Always detach from the cached parse: patching shared entry objects would
// bake earlier patch values into `this.data`, so repeated application
// (config hot-reloads) could never revert a removed or changed patch. The
// supported extensions guarantee JSON-safe plain data, so `structuredClone`
// cannot throw here.
if (!patches?.length) return [...data]
data = structuredClone(data)
const entryMap = new Map<string, EntryOptions>()
const buildMap = (entries: EntryOptions[]) => {
for (const entry of entries) {
if (entry.id) entryMap.set(entry.id, entry)
if (entry.group && Array.isArray(entry.config)) {
buildMap(entry.config)
}
}
}
buildMap(data)
for (const patch of patches) {
const { id, insert, name, ...overrides } = patch
if (insert) {
if (id) {
const target = entryMap.get(id)
if (!target) {
this.ctx.root.logger?.('loader').warn('patch insert: entry %C not found', id)
continue
}
if (!target.group) {
this.ctx.root.logger?.('loader').warn('patch insert: entry %C is not a group', id)
continue
}
if (!Array.isArray(target.config)) target.config = []
target.config.push(...insert)
} else {
data.push(...insert)
}
// Index what this patch added so a LATER patch in the same list can
// target it. Patch lists compose one layer per source (surface overlay,
// then `--config`, then the user's), and a layer must be able to
// configure or disable a row an earlier layer inserted; without this,
// inserted rows were silently unpatchable.
buildMap(insert)
continue
}
if (!id) {
this.ctx.root.logger?.('loader').warn('patch: id is required for non-insert patches')
continue
}
const target = entryMap.get(id)
if (!target) {
this.ctx.root.logger?.('loader').warn('patch: entry %C not found', id)
continue
}
if (name && name !== target.name) {
this.ctx.root.logger?.('loader').warn(
'patch: name mismatch for %C (expected %C, got %C), skipping',
id, target.name, name,
)
continue
}
for (const [key, value] of Object.entries(overrides)) {
if (key === 'id') continue
target[key] = value
}
}
return data
}
async* [Service.init]() {
try {
await this.read()
} catch (error) {
// Only a missing file falls back to `initial` (or the not-found error):
// an existing-but-invalid file must fail loud with its real parse error,
// never be mislabelled as absent or silently overwritten.
if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') throw error
if (this.config.initial) {
this.writeFile(this.config.initial as any)
await this.read()
} else {
throw new Error(`config file not found: ${this.filename}`)
}
}
yield () => this.stop()
await this.root.update(this.applyPatches(this.data!))
}
stop() {
this.root.stop()
}
/**
* Re-read the file and refresh child entries when content changed. An
* unreadable or unparsable file logs a warning and keeps the last good
* tree: a hot-reload of a live app must never take the process down.
*/
async refresh() {
try {
if (!await this.read()) return
await this.root.update(this.applyPatches(this.data!))
} catch (error) {
this.ctx.logger.warn('config reload at %C failed; keeping the running tree', this.filename)
this.ctx.logger.warn(error)
}
}
private async _writeFile(config: EntryOptions[]) {
if (this.readonly) {
throw new Error(`cannot overwrite readonly config`)
}
if (this.type === 'application/yaml') {
this.content = yaml.dump(config, { schema })
} else if (this.type === 'application/json') {
this.content = JSON.stringify(config, null, 2)
}
await writeFile(this.filename + '.tmp', this.content!)
await rename(this.filename + '.tmp', this.filename)
}
private writeFile(config: EntryOptions[]) {
clearTimeout(this.writeTask)
this.writeTask = setTimeout(() => {
this.writeTask = undefined
this._writeFile(config)
}, 0)
}
/** Schedule a write of the current root entry data. */
write() {
this.context.emit('loader/config-update')
return this.writeFile(this.root.data)
}
}
export default Include