index.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. /**
  2. * File-backed settings provider. One YAML or JSON document under the user's
  3. * harness home carries every namespace section; external edits hot-publish
  4. * through the seam, and every write re-reads the document under a
  5. * cross-process writer lock before patching it as a comment-preserving
  6. * leaf-level diff.
  7. * @module @deepseek-ai/dsh-settings-file
  8. */
  9. import { Context, Service } from '@deepseek-ai/cordis'
  10. import z from '@deepseek-ai/schemastery'
  11. import { watch as chokidarWatch } from 'chokidar'
  12. import { mkdir, readFile, writeFile } from 'node:fs/promises'
  13. import { dirname, extname, join, resolve } from 'node:path'
  14. import { Document, parseDocument } from 'yaml'
  15. import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
  16. import { canonicalizeWatchPath, resolveDshHome } from '@deepseek-ai/dsh-home-paths'
  17. import { SettingsProvider, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
  18. import { deepEqualJson } from '@deepseek-ai/dsh-util-values'
  19. /** Plugin config: file location and hot-reload behavior. */
  20. export interface Config {
  21. /** Settings document path; defaults to `settings.yaml` under the harness home. */
  22. path?: string
  23. /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */
  24. dshHome?: string
  25. /** Watch the document and hot-publish external edits; defaults to true. */
  26. watch?: boolean
  27. /** Watcher write-settle window in milliseconds; defaults to 100. */
  28. debounceMs?: number
  29. }
  30. /** Document format derived from the configured file extension. */
  31. type SettingsFormat = 'yaml' | 'json'
  32. const FORMATS: Record<string, SettingsFormat> = {
  33. '.yaml': 'yaml',
  34. '.yml': 'yaml',
  35. '.json': 'json',
  36. }
  37. /** Fully resolved provider parameters; defaulting happens here, never inline. */
  38. interface ResolvedSpec {
  39. filename: string
  40. format: SettingsFormat
  41. watch: boolean
  42. debounceMs: number
  43. }
  44. /**
  45. * Resolve the runtime spec from plugin config: an explicit `path` wins,
  46. * otherwise the document lives at `<harness home>/settings.yaml`.
  47. * @param config - raw plugin config.
  48. * @returns the resolved file location, format, and watch behavior.
  49. */
  50. export function resolveSpec(config: Config): ResolvedSpec {
  51. const filename = resolve(config.path ?? join(resolveDshHome(config.dshHome), 'settings.yaml'))
  52. const format = FORMATS[extname(filename)]
  53. if (format === undefined) {
  54. throw new Error(`settings-file: extension "${extname(filename)}" is not supported (use .yaml, .yml, or .json)`)
  55. }
  56. return {
  57. filename,
  58. format,
  59. watch: config.watch ?? true,
  60. debounceMs: config.debounceMs ?? 100,
  61. }
  62. }
  63. /** Whether a parsed YAML value is a map for diffing purposes. */
  64. function isMapLike(value: unknown): value is Record<string, unknown> {
  65. return typeof value === 'object' && value !== null && !Array.isArray(value)
  66. }
  67. /**
  68. * Apply the difference between one node's stored and next value as minimal
  69. * `setIn`/`deleteIn` edits, recursing through maps, so every untouched node —
  70. * and the key node of every changed pair — keeps its comments, anchors, and
  71. * formatting. Non-map values (arrays and scalars) replace wholesale when
  72. * unequal, taking any comments inside them along.
  73. */
  74. function patchNode(document: Document, path: readonly string[], current: unknown, next: unknown): void {
  75. if (isMapLike(current) && isMapLike(next)) {
  76. for (const key of Object.keys(current)) {
  77. if (!(key in next)) document.deleteIn([...path, key])
  78. }
  79. for (const [key, value] of Object.entries(next)) {
  80. patchNode(document, [...path, key], current[key], value)
  81. }
  82. return
  83. }
  84. if (!deepEqualJson(current, next)) document.setIn([...path], next)
  85. }
  86. /** Whether a filesystem error means absence; every non-ENOENT failure must surface. */
  87. function isENOENT(error: unknown): boolean {
  88. return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
  89. }
  90. /** Whether an exclusive file create found an existing document. */
  91. function isEEXIST(error: unknown): boolean {
  92. return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'
  93. }
  94. /** File-backed settings provider (`settings.yaml`/`.json`). */
  95. export class FileSettingsProvider extends SettingsProvider {
  96. static Config: z<Config> = z.object({
  97. path: z.string(),
  98. dshHome: z.string(),
  99. watch: z.boolean().default(true),
  100. debounceMs: z.number().min(0).default(100),
  101. })
  102. private readonly spec: ResolvedSpec
  103. /**
  104. * Raw text of the last successfully parsed or persisted document;
  105. * `undefined` while the file is absent. Watcher events whose content equals
  106. * this cache are no-ops, which is also the self-write suppression.
  107. */
  108. private text: string | undefined
  109. /**
  110. * Single exclusive operation chain: watcher reloads and document writes run
  111. * one at a time in queue order (settled tail), so a write can never render
  112. * from text a concurrent reload is busy replacing, and a reload can never
  113. * read a half-committed write.
  114. */
  115. private operations: Promise<void> = Promise.resolve()
  116. /** Set at dispose: refuse new watcher events and let in-flight work no-op. */
  117. private closed = false
  118. /** Opaque read of {@link closed}: control flow cannot narrow it across awaits. */
  119. private isClosed(): boolean {
  120. return this.closed
  121. }
  122. constructor(ctx: Context, public config: Config) {
  123. super(ctx)
  124. // Programmatic construction may bypass Schemastery normalization; resolve
  125. // the same defaults in one explicit step either way.
  126. this.spec = resolveSpec(config)
  127. }
  128. /** The local document is always writable through {@link SettingsProvider.update}. */
  129. get writable(): boolean {
  130. return true
  131. }
  132. /** The resolved YAML/JSON document path exposed to local configuration surfaces. */
  133. override get documentPath(): string {
  134. return this.spec.filename
  135. }
  136. /** Materialize an absent owner-only document, then return its resolved path. */
  137. override prepareDocument(): Promise<string> {
  138. return this.enqueue(async () => {
  139. await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 })
  140. await withFileLock(this.spec.filename, async () => {
  141. try {
  142. await writeFile(this.spec.filename, '', { flag: 'wx', mode: 0o600 })
  143. } catch (error) {
  144. if (isEEXIST(error)) return
  145. throw error
  146. }
  147. this.text = ''
  148. if (!this.isClosed()) this.publish({})
  149. })
  150. return this.spec.filename
  151. })
  152. }
  153. protected async load(): Promise<Record<string, unknown>> {
  154. let text: string
  155. try {
  156. text = await readFile(this.spec.filename, 'utf8')
  157. } catch (error) {
  158. if (!isENOENT(error)) throw error
  159. this.text = undefined
  160. return {}
  161. }
  162. const doc = this.parse(text)
  163. this.text = text
  164. return doc
  165. }
  166. protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
  167. // One document backs every namespace, so writes from different namespace
  168. // queues serialize with each other and with watcher reloads on the one
  169. // operation chain: each render must see the text the previous operation
  170. // committed, or a sibling section silently vanishes from disk.
  171. return this.enqueue(() => this.persistSection(ns, section))
  172. }
  173. /** Queue one exclusive document operation behind every earlier one. */
  174. private enqueue<T>(operation: () => Promise<T>): Promise<T> {
  175. const task = this.operations.then(operation)
  176. this.operations = task.then(() => undefined, () => undefined)
  177. return task
  178. }
  179. /** Queue a reload; only an invariant violation escaping a commit can reject it. */
  180. private queueRefresh(): void {
  181. void this.enqueue(() => this.refresh()).catch((error: unknown) => {
  182. // Only an invariant violation escaping the commit path can reject a
  183. // refresh; keep the operation queue alive and surface it as an error so
  184. // one poisoned commit cannot silently end hot reloading forever.
  185. this.ctx.logger.error('settings-file: reload commit failed at %s', this.spec.filename)
  186. this.ctx.logger.error(error)
  187. })
  188. }
  189. private async persistSection(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
  190. // The writer lock's exclusive create needs the parent to exist before
  191. // writeFileAtomic gets its own chance to create it.
  192. // 0700: the harness home holds user-private documents.
  193. await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 })
  194. await withFileLock(this.spec.filename, async () => {
  195. // Read-modify-write: fold in any on-disk state this process has not
  196. // observed yet — an external edit still inside the watcher debounce
  197. // window, a change the watcher missed, or another process's write — so
  198. // the render below can never resurrect a stale document. An unparsable
  199. // on-disk document fails the write loud instead of silently overwriting
  200. // a user's manual edit.
  201. await this.reconcileFromDisk()
  202. const output = this.spec.format === 'yaml'
  203. ? this.renderYaml(ns, section)
  204. : this.renderJson(ns, section)
  205. // 0600: a document that may hold personal values is never world-readable.
  206. await writeFileAtomic(this.spec.filename, output, { mode: 0o600, dirMode: 0o700 })
  207. this.text = output
  208. })
  209. }
  210. override async* [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void> {
  211. // The base init loads and publishes; a parse failure there is a boot
  212. // failure: an existing-but-invalid document must fail loud, never be
  213. // silently ignored or overwritten.
  214. yield* super[Service.init]()
  215. const watcher = this.spec.watch
  216. ? chokidarWatch(await canonicalizeWatchPath(this.spec.filename), {
  217. ignoreInitial: true,
  218. awaitWriteFinish: {
  219. stabilityThreshold: this.spec.debounceMs,
  220. pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)),
  221. },
  222. })
  223. : undefined
  224. if (watcher !== undefined) {
  225. watcher.on('all', () => {
  226. if (this.closed) return
  227. this.queueRefresh()
  228. })
  229. watcher.on('ready', () => {
  230. // The base init's load raced the watcher's own setup: a change written
  231. // between that read and the watcher becoming active never fires an
  232. // event. One reconcile at ready closes the gap.
  233. if (this.closed) return
  234. this.queueRefresh()
  235. })
  236. watcher.on('error', (error) => {
  237. this.ctx.logger.warn('settings-file: watcher error on %s', this.spec.filename)
  238. this.ctx.logger.warn(error)
  239. })
  240. }
  241. yield async () => {
  242. // Quiesce every operation chain, even when no watcher is configured.
  243. this.closed = true
  244. await watcher?.close()
  245. await this.operations
  246. }
  247. }
  248. /** Parse one document text into raw sections, failing on a non-map root. */
  249. private parse(text: string): Record<string, unknown> {
  250. let root: unknown
  251. if (this.spec.format === 'yaml') {
  252. // `prettyErrors` is on only for `linePos`; `error.message` is never
  253. // used, because the parser quotes the offending source line and a
  254. // settings document can hold a `role('secret')` value.
  255. const document = parseDocument(text, { prettyErrors: true })
  256. if (document.errors.length > 0) {
  257. throw new Error(`settings-file: invalid document at ${this.spec.filename}: ${
  258. document.errors.map((error) => {
  259. const at = error.linePos?.[0]
  260. /* v8 ignore next -- `prettyErrors` populates linePos on every error; the guard answers its optional type */
  261. return `${error.code}${at === undefined ? '' : ` at line ${String(at.line)}, column ${String(at.col)}`}`
  262. }).join('; ')}`)
  263. }
  264. root = document.toJS() ?? {}
  265. } else {
  266. root = text.trim().length === 0 ? {} : JSON.parse(text)
  267. }
  268. if (typeof root !== 'object' || root === null || Array.isArray(root)) {
  269. throw new TypeError(`settings-file: ${this.spec.filename} must be a map of namespace sections`)
  270. }
  271. return root as Record<string, unknown>
  272. }
  273. /**
  274. * Re-read the document after a watcher event. Unchanged content (including
  275. * this provider's own writes) is a no-op; an unreadable or unparsable
  276. * document keeps the last good sections and warns — a live hot-reload must
  277. * never take the process down. An invariant violation escaping a commit is
  278. * not a reload failure and propagates to the queue's error surface.
  279. */
  280. private async refresh(): Promise<void> {
  281. if (this.closed) return
  282. try {
  283. await this.reconcileFromDisk()
  284. } catch (error) {
  285. if ((error as { code?: unknown } | null)?.code === 'INVARIANT') throw error
  286. this.ctx.logger.warn('settings-file: reload failed at %s; keeping the last good document', this.spec.filename)
  287. this.ctx.logger.warn(error)
  288. }
  289. }
  290. /**
  291. * Compare the on-disk text against the cache and publish any difference
  292. * into the seam. Absence publishes the empty document; an unreadable or
  293. * unparsable file throws, so each caller picks its policy — a reload warns
  294. * and keeps the last good document, a write fails loud.
  295. */
  296. private async reconcileFromDisk(): Promise<void> {
  297. let text: string | undefined
  298. try {
  299. text = await readFile(this.spec.filename, 'utf8')
  300. } catch (error) {
  301. if (!isENOENT(error)) throw error
  302. text = undefined
  303. }
  304. if (text === this.text || this.isClosed()) return
  305. if (text === undefined) {
  306. this.text = undefined
  307. this.publish({})
  308. return
  309. }
  310. const doc = this.parse(text)
  311. this.text = text
  312. this.publish(doc)
  313. }
  314. /**
  315. * Render the next YAML text by patching one namespace in the
  316. * comment-preserving document. The next section lands as a leaf-level diff
  317. * against the stored one — only changed values set, only removed keys
  318. * delete — so comments inside the section survive edits to their siblings,
  319. * not just comments outside it.
  320. */
  321. private renderYaml(ns: SettingsNamespace, section: Record<string, unknown>): string {
  322. if (this.text === undefined) {
  323. return new Document({ [ns]: section }).toString()
  324. }
  325. // this.text only ever caches content that parsed successfully, so this
  326. // re-parse (for the mutable comment-preserving tree) cannot fail, and
  327. // parse() already rejected any non-map root.
  328. const document = parseDocument(this.text)
  329. const root: unknown = document.toJS()
  330. patchNode(document, [ns], isMapLike(root) ? root[ns] : undefined, section)
  331. return document.toString()
  332. }
  333. /** Render the next JSON text by replacing one namespace key. */
  334. private renderJson(ns: SettingsNamespace, section: Record<string, unknown>): string {
  335. const root = this.text === undefined
  336. ? {}
  337. : this.parse(this.text)
  338. root[ns] = section
  339. return `${JSON.stringify(root, null, 2)}\n`
  340. }
  341. }
  342. export default FileSettingsProvider