Files
deepseek-harness/packages/boot/config-editor/src/index.ts
T

159 lines
8.8 KiB
TypeScript

/** Profile-owned configuration edits, serialized with Loader hot reload. */
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { isDeepStrictEqual } from 'node:util'
import { Context, FiberState, Service, resolveConfig } from '@deepseek-ai/cordis'
import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
import yaml from 'js-yaml'
import type { Entry, EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
import type {} from '@deepseek-ai/dsh-hmr'
import { composeEntries, loadProfileDirectory, profilePatchPreset, readProfilePatches, reconcileProfilePatches, type ProfilePatch } from '@deepseek-ai/dsh-app-boot'
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import { isMap, isSeq, parseDocument, Scalar, visit } from 'yaml'
declare module '@deepseek-ai/cordis' {
interface Context {
/** Persistent edits to the active profile's plugin configuration. */
configEditor: ConfigEditor
}
}
function flatten(rows: EntryOptions[]): EntryOptions[] {
return rows.flatMap(row => [row, ...row.group && Array.isArray(row.config) ? flatten(row.config as EntryOptions[]) : []])
}
/** Persist Host entry configs without changing preset-scoped operations, then reconcile through Loader. */
export class ConfigEditor extends Service {
static inject = ['loader', 'profileContext']
constructor(private readonly ownerContext: Context) {
super(ownerContext, 'configEditor')
}
/** The profile patch edited by this service. */
get documentPath(): string { return this.ownerContext.profileContext.patchPath }
/** Addressable profile rows; nested Includes have independent configuration ownership.
* @returns Active entries with unique profile patch ids.
*/
entries(): Entry[] {
const candidates = [...this.ownerContext.loader.entries()].filter(entry => entry.parent.tree.ctx.fiber.entry?.id === 'include')
const counts = new Map<string, number>()
for (const entry of candidates) counts.set(entry.options.id, (counts.get(entry.options.id) ?? 0) + 1)
return candidates.filter(entry => counts.get(entry.options.id) === 1)
}
/** Read inherited and explicit profile values for the active entries.
* @returns Detached layer values alongside their Loader entries.
*/
configuration(): Array<{ entry: Entry; inherited: Record<string, unknown>; override: Record<string, unknown> }> {
const profile = this.ownerContext.profileContext
const loaded = loadProfileDirectory('dsh', profile.dir, profile.installAnchor)
const entries = this.entries()
const hostPatches = loaded.patches.filter(patch => profilePatchPreset(patch) === undefined)
// An own config key can replace inherited config even when its value is undefined.
const overridden = new Set(hostPatches.filter(patch => patch.insert === undefined && Object.hasOwn(patch, 'config')).map(patch => patch.id))
const composed = new Map<string, EntryOptions>()
if (entries.some(entry => !overridden.has(entry.options.id))) {
for (const row of flatten(composeEntries([...loaded.layers.map(layer => layer.patches), loaded.patches]))) {
if (!composed.has(row.id)) composed.set(row.id, row)
}
}
return entries.map(entry => ({
entry,
inherited: overridden.has(entry.options.id)
? this.inherited(entry, loaded)
: structuredClone((composed.get(entry.options.id)?.config ?? {}) as Record<string, unknown>),
override: structuredClone((hostPatches.findLast(
row => row.id === entry.options.id && row.config !== undefined,
)?.config ?? {}) as Record<string, unknown>),
}))
}
private inherited(entry: Entry, loaded: ReturnType<typeof loadProfileDirectory>): Record<string, unknown> {
const patches = loaded.patches.map((patch) => {
if (patch.id !== entry.options.id || patch.insert !== undefined || profilePatchPreset(patch) !== undefined) return patch
const rest = { ...patch }; Reflect.deleteProperty(rest, 'config')
return rest
})
const row = flatten(composeEntries([...loaded.layers.map(layer => layer.patches), patches])).find(row => row.id === entry.options.id)
return structuredClone((row?.config ?? {}) as Record<string, unknown>)
}
/** Validate, persist, and reconcile a plugin's next config; ordinary fields keep normal lifecycle rules.
* @param entry Current Loader entry, also used to detect replacement during the write.
* @param change Derive a raw config from the current entry and its inherited layer.
* @returns Fulfillment after Loader reconciliation completes.
*/
async edit(
entry: Entry,
change: (current: Record<string, unknown>, inherited: Record<string, unknown>) => Record<string, unknown>,
): Promise<void> {
const run = async (): Promise<void> => {
const path = this.documentPath
await withFileLock(join(this.ownerContext.profileContext.dir, 'package.json'), async () => {
if (!this.entries().includes(entry) || entry.fiber === undefined) throw new Error('Configuration entry is no longer available')
const beforePatches = readProfilePatches('dsh', this.ownerContext.profileContext)
await reconcileProfilePatches(this.ownerContext.root, beforePatches, 'dsh')
if (!this.entries().includes(entry)) throw new Error('Configuration entry changed during reload')
const current = structuredClone((entry.options.config ?? {}) as Record<string, unknown>)
const inherited = this.inherited(entry, loadProfileDirectory('dsh', this.ownerContext.profileContext.dir, this.ownerContext.profileContext.installAnchor))
const next = change(current, inherited)
const fiber = entry.fiber
if (fiber.state !== FiberState.ACTIVE) throw new Error('Configuration plugin is no longer active')
const resolved: unknown = fiber.ctx.waterfall(fiber, 'internal/config', next, () => next)
resolveConfig(fiber.runtime as NonNullable<typeof fiber.runtime>, resolved)
let before: string
try { before = await readFile(path, 'utf8') }
catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
before = '[]\n'
}
const document = parseDocument(before, {
customTags: [{ tag: 'tag:yaml.org,2002:js', resolve: (value: string) => value }],
})
if (document.errors[0] !== undefined) throw document.errors[0]
if (!isSeq(document.contents)) throw new Error('Profile patch must be a YAML sequence')
document.contents.flow = false
const index = document.contents.items.findLastIndex((item, index) => isMap(item)
&& document.getIn([index, 'id']) === entry.options.id && !item.has('insert') && !item.has('preset')
&& (!item.has('name') || document.getIn([index, 'name']) === entry.options.name))
if (isDeepStrictEqual(next, inherited)) {
for (let index = document.contents.items.length - 1; index >= 0; index--) {
const row = document.contents.items[index]
if (!isMap(row) || document.getIn([index, 'id']) !== entry.options.id || row.has('insert') || row.has('preset')) continue
row.delete('config')
if (row.items.length === Number(row.has('id')) + Number(row.has('name'))) document.delete(index)
}
} else if (index < 0) document.add(document.createNode({ id: entry.options.id, name: entry.options.name, config: next }))
else document.setIn([index, 'config'], document.createNode(next))
visit(document, { Map(_key, node) {
if (node.items.length !== 1 || typeof node.get('__jsExpr') !== 'string') return
const expression = new Scalar(node.get('__jsExpr'))
expression.tag = 'tag:yaml.org,2002:js'
return expression
} })
const profile = this.ownerContext.profileContext
const loaded = loadProfileDirectory('dsh', profile.dir, profile.installAnchor)
const patches = readProfilePatches('dsh', profile, { ...loaded, patches: yaml.load(String(document), { schema: entryListSchema }) as ProfilePatch[] })
const effective = flatten(composeEntries([patches])).find(row => row.id === entry.options.id)
if (!isDeepStrictEqual(effective?.config ?? {}, next)) {
throw new Error(`Configuration for "${entry.options.id}" is overridden by a home patch or command-line overlay`)
}
await writeFileAtomic(path, String(document), { mode: 0o600 })
try {
await reconcileProfilePatches(this.ownerContext.root, patches, 'dsh', [entry.options.id])
} catch (error) {
await writeFileAtomic(path, before, { mode: 0o600 })
await reconcileProfilePatches(this.ownerContext.root, beforePatches, 'dsh')
throw error
}
})
}
const hmr = this.ownerContext.get('hmr')
await (hmr === undefined ? run() : hmr.runExclusive(run))
}
}
export default ConfigEditor