Merge pull request #1028 from deepseek-harness/pr/dump-config
feat(cli): dsh --dump-config / --dump-default-config print the composed config tree
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/ui/app-boot/README.md
|
||||
README.md: 54f754842d9a6673ed6791b94656139f0f1be6a3
|
||||
README.zh.md: dd56084812e8241f0db24601ce2baeba51252d42
|
||||
README.md: 51bc5082512632dd493956b96c605aff47dc872e
|
||||
README.zh.md: 644d3a3613a9516cb02881fac8ba7531bffa81eb
|
||||
|
||||
@@ -14,6 +14,7 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c
|
||||
| `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
|
||||
| `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context |
|
||||
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline — the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts — and render YAML with `!!js` expressions verbatim; each run of same-provenance rows is preceded by a `# ==` comment naming the contributing file and the layers that patched it, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
|
||||
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
| `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 |
|
||||
| `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文 |
|
||||
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`),因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML,`!!js` 表达式原样保留;每段来源相同的连续行之前都有一条 `# ==` 注释,标明贡献该段的文件以及修补过它的层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取/解析/形状失败则抛出 |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 |
|
||||
| `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 |
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { basename, dirname, join, resolve } from 'node:path'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { Context, type FiberState } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
|
||||
import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
// Side-effect type import: resolves `ctx.get('systemPrompt')` to the service.
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -60,16 +60,12 @@ export function loadEnv(
|
||||
/** File inside the Harness home holding the personal loader overlay patches. */
|
||||
export const PERSONAL_CONFIG_FILENAME = 'config.yaml'
|
||||
|
||||
// The include's YAML dialect: `!!js` scalars become expression nodes the
|
||||
// Loader interpolates against each entry's context at mount time. Personal
|
||||
// patches are parsed with the same schema so they may reference `process.env`.
|
||||
// Load-only: this schema never dumps, so no `predicate`/`represent`.
|
||||
const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
|
||||
kind: 'scalar',
|
||||
resolve: data => typeof data === 'string',
|
||||
construct: data => ({ __jsExpr: String(data) }),
|
||||
})
|
||||
const personalPatchesSchema = yaml.JSON_SCHEMA.extend(jsExprType)
|
||||
// The include's YAML dialect (`!!js` scalars become expression nodes the
|
||||
// Loader interpolates against each entry's context at mount time), imported
|
||||
// from the include itself so patch parsing and config dumping can never drift
|
||||
// from what the include mounts. Personal patches share it so they may
|
||||
// reference `process.env`.
|
||||
const personalPatchesSchema = entryListSchema
|
||||
|
||||
/**
|
||||
* Load the optional personal overlay patches (`config.yaml` under the Harness
|
||||
@@ -149,6 +145,141 @@ function parsePatchList(
|
||||
return parsed as PatchOptions[]
|
||||
}
|
||||
|
||||
/** One overlay patch list with the label provenance comments print for it. */
|
||||
export interface ConfigDumpLayer {
|
||||
/** Source name shown in provenance comments (a file basename or path). */
|
||||
label: string
|
||||
/** The layer's patches, from {@link loadOverlayPatches} / {@link loadPersonalPatches}. */
|
||||
patches: PatchOptions[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the effective entry list exactly as `boot()` would mount it: parse
|
||||
* the base config file with the include's entry-list dialect, apply every
|
||||
* layer's patches as ONE flattened list through the include's own patch
|
||||
* algorithm (`applyEntryPatches`) — the same single call `boot()` makes, so
|
||||
* even patch-visibility corner cases (a later layer targeting a group child a
|
||||
* plain config replacement introduced, which the single-pass id index never
|
||||
* sees) compose identically — then render the result as YAML in the same
|
||||
* dialect (`!!js` expressions print verbatim, unevaluated).
|
||||
*
|
||||
* Every run of rows with the same provenance is preceded by a `# ==` comment
|
||||
* naming the file that contributed the rows and any layers that patched them,
|
||||
* so the output stays a loadable YAML document while showing which section
|
||||
* comes from which file. Provenance is derived from single-call prefix
|
||||
* snapshots (base + layers 1..k), diffed positionally: the patch algorithm
|
||||
* only rewrites rows in place or appends, so a top-level index identifies one
|
||||
* row across snapshots, and a layer whose addition changes the row (config
|
||||
* replacement, disable, group insert) is listed as having patched it.
|
||||
*
|
||||
* A patch that matches no row is reported through `warn` with its layer
|
||||
* label, mirroring the Loader's boot-time warning. Earlier layers' patches
|
||||
* see an identical preceding state in every snapshot that includes them, so
|
||||
* each snapshot's warning list extends the previous one and the new tail
|
||||
* belongs to the added layer.
|
||||
* @param binName - the diagnostic prefix on read/parse errors.
|
||||
* @param absoluteConfigPath - the base config file `boot()` would include.
|
||||
* @param layers - overlay layers in application order (later wins).
|
||||
* @param warn - sink for skipped-patch diagnostics; defaults to stderr.
|
||||
* @returns the composed entry list rendered as a YAML document with
|
||||
* provenance comment separators.
|
||||
*/
|
||||
export function renderConfigDump(
|
||||
binName: string,
|
||||
absoluteConfigPath: string,
|
||||
layers: ConfigDumpLayer[],
|
||||
warn: (line: string) => void = line => void process.stderr.write(`${line}\n`),
|
||||
): string {
|
||||
let content: string
|
||||
try {
|
||||
content = readFileSync(absoluteConfigPath, 'utf8')
|
||||
} catch (error) {
|
||||
throw new Error(`${binName}: failed to read config ${absoluteConfigPath}: ${String(error)}`)
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = yaml.load(content, { schema: entryListSchema })
|
||||
} catch (error) {
|
||||
throw new Error(`${binName}: failed to parse config ${absoluteConfigPath}: ${String(error)}`)
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error(`${binName}: config ${absoluteConfigPath} must be a top-level YAML array of entries`)
|
||||
}
|
||||
const baseLabel = basename(absoluteConfigPath)
|
||||
// The YAML boundary yields untyped rows; the include validates entry shape
|
||||
// at mount, and the dump prints whatever the file holds, so `EntryOptions`
|
||||
// here is structural trust in the same file `boot()` would include.
|
||||
const base = parsed as Parameters<typeof applyEntryPatches>[0]
|
||||
// snapshot_k = ONE application of layers 1..k flattened — boot's exact call
|
||||
// shape for that prefix. snapshot_N is therefore the mounted composition.
|
||||
// The patches are cloned per call: applyEntryPatches detaches the entry
|
||||
// list but pushes `insert` rows by reference from the patch list, so
|
||||
// sharing patch objects across snapshot calls would leak a later
|
||||
// snapshot's mutations into an earlier one's result.
|
||||
const snapshot = (count: number, warnings: string[]): ReturnType<typeof applyEntryPatches> => {
|
||||
const flattened = structuredClone(layers.slice(0, count).flatMap(layer => layer.patches))
|
||||
return applyEntryPatches(base, flattened, (message: string, ...args: unknown[]) => {
|
||||
// The include logs through cordis's printf-style logger (`%C` = code); a
|
||||
// dump has no logger, so substitute inline for a plain line.
|
||||
let index = 0
|
||||
warnings.push(message.replace(/%C/g, () => JSON.stringify(args[index++])))
|
||||
})
|
||||
}
|
||||
let previous = base
|
||||
let previousWarnings: string[] = []
|
||||
const provenance: { origin: string; patchedBy: string[] }[] = base.map(() => ({ origin: baseLabel, patchedBy: [] }))
|
||||
let composed = base
|
||||
for (let count = 1; count <= layers.length; count += 1) {
|
||||
const layer = layers[count - 1]
|
||||
/* v8 ignore next -- count iterates 1..length, so the slot exists */
|
||||
if (layer === undefined) continue
|
||||
const warnings: string[] = []
|
||||
composed = snapshot(count, warnings)
|
||||
for (const line of warnings.slice(previousWarnings.length)) {
|
||||
warn(`${binName}: [${layer.label}] ${line}`)
|
||||
}
|
||||
const before = previous.map(entry => JSON.stringify(entry))
|
||||
for (let index = 0; index < composed.length; index += 1) {
|
||||
if (index >= before.length) provenance.push({ origin: layer.label, patchedBy: [] })
|
||||
else if (JSON.stringify(composed[index]) !== before[index]) provenance[index]?.patchedBy.push(layer.label)
|
||||
}
|
||||
previous = composed
|
||||
previousWarnings = warnings
|
||||
}
|
||||
return groupedDump(composed, provenance)
|
||||
}
|
||||
|
||||
/** Render the composed rows grouped under one provenance comment per contiguous run. */
|
||||
function groupedDump(
|
||||
composed: readonly unknown[],
|
||||
provenance: readonly { origin: string; patchedBy: string[] }[],
|
||||
): string {
|
||||
const lines: string[] = []
|
||||
let currentLabel: string | undefined
|
||||
let group: unknown[] = []
|
||||
const flush = (): void => {
|
||||
if (currentLabel === undefined || group.length === 0) return
|
||||
lines.push(`# == ${currentLabel}`)
|
||||
lines.push(yaml.dump(group, { schema: entryListSchema, noRefs: true }).trimEnd())
|
||||
group = []
|
||||
}
|
||||
for (let index = 0; index < composed.length; index += 1) {
|
||||
const record = provenance[index]
|
||||
/* v8 ignore next -- provenance is index-aligned with composed by construction */
|
||||
if (record === undefined) continue
|
||||
const label = record.patchedBy.length === 0
|
||||
? record.origin
|
||||
: `${record.origin}, patched by ${record.patchedBy.join(', ')}`
|
||||
if (label !== currentLabel) {
|
||||
flush()
|
||||
currentLabel = label
|
||||
}
|
||||
group.push(composed[index])
|
||||
}
|
||||
flush()
|
||||
return lines.join('\n') + '\n'
|
||||
}
|
||||
|
||||
/**
|
||||
* The slice of `process` {@link installFailLoud} needs — injectable so tests
|
||||
* exercise the handler without registering on (or exiting) the real process.
|
||||
|
||||
187
packages/ui/app-boot/tests/config-dump.spec.ts
Normal file
187
packages/ui/app-boot/tests/config-dump.spec.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* `renderConfigDump` behavior: the offline composition must equal what
|
||||
* `boot()` mounts (same parser, same patch algorithm), print `!!js`
|
||||
* expressions verbatim, separate provenance runs with comment lines while
|
||||
* staying one loadable YAML document, and report skipped patches through
|
||||
* `warn` instead of failing — mirroring the Loader's boot-time warning for a
|
||||
* shared overlay whose row exists only on another surface.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { entryListSchema } from '@cordisjs/plugin-include'
|
||||
import { loadOverlayPatches, renderConfigDump } from '../src/index.ts'
|
||||
|
||||
const NAME = 'dsh-test-bin'
|
||||
|
||||
const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-config-dump-'))
|
||||
|
||||
function writeBase(dir: string): string {
|
||||
const base = join(dir, 'base.yml')
|
||||
writeFileSync(base, [
|
||||
'- id: shared',
|
||||
' name: ./noop.mjs',
|
||||
' config:',
|
||||
' value: base',
|
||||
' key: !!js process.env.DSH_DUMP_SPEC',
|
||||
'- id: untouched',
|
||||
' name: ./noop.mjs',
|
||||
'',
|
||||
].join('\n'))
|
||||
return base
|
||||
}
|
||||
|
||||
describe('renderConfigDump', () => {
|
||||
it('composes overlay layers in order, prints !!js verbatim, and labels each section with its provenance', () => {
|
||||
const dir = tmp()
|
||||
const base = writeBase(dir)
|
||||
const surface = join(dir, 'surface.yml')
|
||||
writeFileSync(surface, [
|
||||
'- id: shared',
|
||||
' config:',
|
||||
' value: surface',
|
||||
' key: !!js process.env.DSH_DUMP_SPEC',
|
||||
'- insert:',
|
||||
' - id: surface-extra',
|
||||
' name: ./noop.mjs',
|
||||
'',
|
||||
].join('\n'))
|
||||
const personal = join(dir, 'personal.yml')
|
||||
writeFileSync(personal, [
|
||||
'- id: surface-extra',
|
||||
' config:',
|
||||
' value: personal',
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
const dump = renderConfigDump(NAME, base, [
|
||||
{ label: 'surface.yml', patches: loadOverlayPatches(NAME, surface) },
|
||||
{ label: 'personal.yml', patches: loadOverlayPatches(NAME, personal) },
|
||||
], () => {})
|
||||
// Comments do not break loadability: the dump parses as one document
|
||||
// equal to what boot() would mount.
|
||||
const parsed = yaml.load(dump, { schema: entryListSchema }) as {
|
||||
id: string
|
||||
config?: Record<string, unknown>
|
||||
}[]
|
||||
expect(parsed).toEqual([
|
||||
{
|
||||
id: 'shared',
|
||||
name: './noop.mjs',
|
||||
config: { value: 'surface', key: { __jsExpr: 'process.env.DSH_DUMP_SPEC' } },
|
||||
},
|
||||
{ id: 'untouched', name: './noop.mjs' },
|
||||
{ id: 'surface-extra', name: './noop.mjs', config: { value: 'personal' } },
|
||||
])
|
||||
// Unevaluated: the expression text round-trips as a !!js scalar.
|
||||
expect(dump).toContain('!!js process.env.DSH_DUMP_SPEC')
|
||||
// Provenance separators: origin file, plus every layer that changed the
|
||||
// row; an inserted row carries the inserting layer as its origin.
|
||||
expect(dump).toContain('# == base.yml, patched by surface.yml')
|
||||
expect(dump).toContain('# == base.yml\n- id: untouched')
|
||||
expect(dump).toContain('# == surface.yml, patched by personal.yml\n- id: surface-extra')
|
||||
expect(dump.indexOf('# == base.yml, patched by surface.yml')).toBeLessThan(dump.indexOf('# == base.yml\n- id: untouched'))
|
||||
})
|
||||
|
||||
it('groups contiguous same-provenance rows under one separator', () => {
|
||||
const dir = tmp()
|
||||
const base = join(dir, 'base.yml')
|
||||
writeFileSync(base, [
|
||||
'- id: a',
|
||||
' name: ./noop.mjs',
|
||||
'- id: b',
|
||||
' name: ./noop.mjs',
|
||||
'',
|
||||
].join('\n'))
|
||||
const dump = renderConfigDump(NAME, base, [], () => {})
|
||||
expect(dump.match(/# == base\.yml/g)).toHaveLength(1)
|
||||
expect(dump).toContain('# == base.yml\n- id: a')
|
||||
})
|
||||
|
||||
it('composes all layers as one flattened patch list, exactly like boot()', () => {
|
||||
// boot() flattens every layer into ONE applyEntryPatches call, whose id
|
||||
// index sees inserted rows but NOT children introduced by a plain group
|
||||
// `config` replacement. A per-layer composition would rebuild the index
|
||||
// between layers and let the second layer patch that child — a tree the
|
||||
// real boot never mounts. Pin the single-call semantics: the child patch
|
||||
// is skipped (with the layer-labeled warning), matching boot.
|
||||
const dir = tmp()
|
||||
const base = join(dir, 'base.yml')
|
||||
writeFileSync(base, [
|
||||
'- id: g',
|
||||
' name: ./group.mjs',
|
||||
' group: true',
|
||||
' config: []',
|
||||
'',
|
||||
].join('\n'))
|
||||
const warnings: string[] = []
|
||||
const dump = renderConfigDump(NAME, base, [
|
||||
{
|
||||
label: 'a.yml',
|
||||
patches: [{ id: 'g', config: [{ id: 'child', name: './noop.mjs', config: { v: 1 } }] }],
|
||||
},
|
||||
{ label: 'b.yml', patches: [{ id: 'child', config: { v: 2 } }] },
|
||||
], line => void warnings.push(line))
|
||||
expect(warnings).toEqual([`${NAME}: [b.yml] patch: entry "child" not found`])
|
||||
const parsed = yaml.load(dump, { schema: entryListSchema }) as {
|
||||
config?: { config?: { v?: number } }[]
|
||||
}[]
|
||||
expect(parsed[0]?.config?.[0]?.config?.v).toBe(1)
|
||||
// The skipped layer did not change the row, so it is not in provenance.
|
||||
expect(dump).toContain('# == base.yml, patched by a.yml\n- id: g')
|
||||
expect(dump).not.toContain('b.yml\n- id: g')
|
||||
})
|
||||
|
||||
it('reports a patch whose target row is absent through warn with its layer label and keeps composing', () => {
|
||||
const dir = tmp()
|
||||
const base = writeBase(dir)
|
||||
const overlay = join(dir, 'overlay.yml')
|
||||
writeFileSync(overlay, [
|
||||
'- id: only-on-another-surface',
|
||||
' config:',
|
||||
' value: ignored',
|
||||
'- id: shared',
|
||||
' config:',
|
||||
' value: patched',
|
||||
'',
|
||||
].join('\n'))
|
||||
const warnings: string[] = []
|
||||
const dump = renderConfigDump(
|
||||
NAME, base,
|
||||
[{ label: 'overlay.yml', patches: loadOverlayPatches(NAME, overlay) }],
|
||||
line => void warnings.push(line),
|
||||
)
|
||||
expect(warnings).toEqual([`${NAME}: [overlay.yml] patch: entry "only-on-another-surface" not found`])
|
||||
const parsed = yaml.load(dump, { schema: entryListSchema }) as { config?: { value?: string } }[]
|
||||
expect(parsed[0]?.config?.value).toBe('patched')
|
||||
})
|
||||
|
||||
it('defaults its warn sink to one stderr line per skipped patch', () => {
|
||||
const dir = tmp()
|
||||
const base = writeBase(dir)
|
||||
const write = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
|
||||
try {
|
||||
renderConfigDump(NAME, base, [{ label: 'x.yml', patches: [{ id: 'absent', config: {} }] }])
|
||||
expect(write).toHaveBeenCalledWith(`${NAME}: [x.yml] patch: entry "absent" not found\n`)
|
||||
} finally {
|
||||
write.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('fails loud on a missing, unparsable, or non-array base config', () => {
|
||||
const dir = tmp()
|
||||
expect(() => renderConfigDump(NAME, join(dir, 'absent.yml'), [], () => {}))
|
||||
.toThrow(new RegExp(`^${NAME}: failed to read config `))
|
||||
const invalid = join(dir, 'invalid.yml')
|
||||
writeFileSync(invalid, 'invalid: [unclosed\n')
|
||||
expect(() => renderConfigDump(NAME, invalid, [], () => {}))
|
||||
.toThrow(new RegExp(`^${NAME}: failed to parse config `))
|
||||
const scalar = join(dir, 'scalar.yml')
|
||||
writeFileSync(scalar, 'id: not-a-list\n')
|
||||
expect(() => renderConfigDump(NAME, scalar, [], () => {}))
|
||||
.toThrow('must be a top-level YAML array of entries')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user