index.ts 13 KB

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