config-reload.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. /** File reload and overlay behavior through the booted Include tree. */
  2. import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { afterAll, describe, expect, it } from 'vitest'
  6. import { Context } from '@deepseek-ai/cordis'
  7. import type { Include } from '@deepseek-ai/cordis-plugin-include'
  8. import { boot } from '../src/index.ts'
  9. const NAME = 'dsh-test-bin'
  10. const NOOP_PLUGIN = 'export const name = "noop"\nexport function apply() {}\n'
  11. const tempRoots: string[] = []
  12. afterAll(() => {
  13. for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true })
  14. })
  15. interface TreeFixture {
  16. ctx: Context
  17. dir: string
  18. include: Include
  19. }
  20. async function bootTree(configBody: string, files: Record<string, string> = {}): Promise<TreeFixture> {
  21. const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-'))
  22. tempRoots.push(dir)
  23. writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
  24. for (const [name, content] of Object.entries(files)) writeFileSync(join(dir, name), content)
  25. writeFileSync(join(dir, 'cordis.yml'), configBody)
  26. const ctx = await boot(NAME, join(dir, 'cordis.yml'))
  27. const entry = [...ctx.loader.entries()].find(candidate => candidate.subtree !== undefined)
  28. if (entry?.subtree === undefined) throw new Error('booted tree has no include entry')
  29. return { ctx, dir, include: entry.subtree as Include }
  30. }
  31. function entryConfig(ctx: Context, id: string): unknown {
  32. return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config
  33. }
  34. describe('include refresh with an invalid file', () => {
  35. it('writes and activates initial entries when an included file is missing', async () => {
  36. const { ctx, dir } = await bootTree([
  37. '- id: initialized',
  38. " name: 'cordis:include'",
  39. ' config:',
  40. ' path: ./created.yml',
  41. ' initial:',
  42. ' - id: noop',
  43. ' name: ./noop.mjs',
  44. ' config: { value: initial }',
  45. '',
  46. ].join('\n'))
  47. try {
  48. expect(entryConfig(ctx, 'noop')).toEqual({ value: 'initial' })
  49. expect(readFileSync(join(dir, 'created.yml'), 'utf8')).toContain('id: noop')
  50. } finally {
  51. await ctx.fiber.dispose()
  52. }
  53. })
  54. it('keeps the last good tree instead of throwing, then applies the next valid edit', async () => {
  55. const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n config:\n value: 1\n')
  56. try {
  57. expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
  58. writeFileSync(join(dir, 'cordis.yml'), 'invalid: [unclosed\n')
  59. await expect(include.refresh()).resolves.toBeUndefined()
  60. expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
  61. // An empty file parses to `undefined` without a YAML error; it must be
  62. // treated exactly like a parse failure, not crash the entry walk.
  63. writeFileSync(join(dir, 'cordis.yml'), '')
  64. await expect(include.refresh()).resolves.toBeUndefined()
  65. expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
  66. writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: 2\n')
  67. await include.refresh()
  68. await ctx.loader.await()
  69. expect(entryConfig(ctx, 'noop')).toEqual({ value: 2 })
  70. } finally {
  71. await ctx.fiber.dispose()
  72. }
  73. })
  74. })
  75. describe('include refresh with overlay patches', () => {
  76. it('re-applies entry patches and inserted entries on every re-read (parity with initial load)', async () => {
  77. const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-overlay-'))
  78. tempRoots.push(dir)
  79. writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
  80. writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n')
  81. writeFileSync(join(dir, 'cordis.yml'), [
  82. '- id: base',
  83. " name: 'cordis:include'",
  84. ' config:',
  85. ' path: ./base.yml',
  86. ' patches:',
  87. ' - id: noop',
  88. ' name: ./noop.mjs',
  89. ' config:',
  90. ' value: patched',
  91. ' - insert:',
  92. ' - id: extra',
  93. ' name: ./noop.mjs',
  94. '',
  95. ].join('\n'))
  96. const ctx = await boot(NAME, join(dir, 'cordis.yml'))
  97. try {
  98. const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === 'base')
  99. if (entry?.subtree === undefined) throw new Error('overlay tree has no base include entry')
  100. const include = entry.subtree as Include
  101. expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched' })
  102. expect(entryConfig(ctx, 'extra')).toBeUndefined()
  103. expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(true)
  104. writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: edited\n')
  105. await include.refresh()
  106. await ctx.loader.await()
  107. expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched' })
  108. expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(true)
  109. // Hot-update of the include entry's own config (the `internal/update`
  110. // path): the new patches must apply now AND stick for later re-reads —
  111. // the listener vetoes the fiber restart, so it must persist the new
  112. // config itself or the next refresh() re-applies the old overlay.
  113. await entry.update({ config: { path: './base.yml', patches: [{ id: 'noop', name: './noop.mjs', config: { value: 'patched-v2' } }] } })
  114. await ctx.loader.await()
  115. expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' })
  116. expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(false)
  117. writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: edited-2\n')
  118. await include.refresh()
  119. await ctx.loader.await()
  120. expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' })
  121. // Removing every patch must revert to the file's own values: patching
  122. // may not bake earlier patch results into the cached parse.
  123. await entry.update({ config: { path: './base.yml', patches: [] } })
  124. await ctx.loader.await()
  125. expect(entryConfig(ctx, 'noop')).toEqual({ value: 'edited-2' })
  126. } finally {
  127. await ctx.fiber.dispose()
  128. }
  129. })
  130. })
  131. describe('include patches layered over one base', () => {
  132. it('lets a later patch configure or disable a row an earlier patch inserted', async () => {
  133. // The bundle/user-layer/`--patch` composition: `dsh` includes one root
  134. // and applies each source as its own patch list at the SAME include
  135. // level, because patches never cross an include boundary. A later layer
  136. // must therefore be able to reach a row an earlier layer inserted, or
  137. // bundle-only rows would be invisible to the user's patch layer.
  138. const dir = mkdtempSync(join(tmpdir(), 'dsh-config-layered-'))
  139. tempRoots.push(dir)
  140. writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
  141. writeFileSync(join(dir, 'base.yml'), '- id: shared\n name: ./noop.mjs\n config:\n value: base\n')
  142. writeFileSync(join(dir, 'cordis.yml'), [
  143. '- id: base',
  144. " name: 'cordis:include'",
  145. ' config:',
  146. ' path: ./base.yml',
  147. ' patches:',
  148. // Layer 1 (a bundle layer): patch a base row and add two of its own.
  149. ' - id: shared',
  150. ' config:',
  151. ' value: bundle',
  152. ' - insert:',
  153. ' - id: bundle-kept',
  154. ' name: ./noop.mjs',
  155. ' config:',
  156. ' value: bundle-default',
  157. ' - id: bundle-dropped',
  158. ' name: ./noop.mjs',
  159. // Layer 2 (the user): reconfigure one inserted row and disable the other.
  160. ' - id: bundle-kept',
  161. ' config:',
  162. ' value: user',
  163. ' - id: bundle-dropped',
  164. ' disabled: true',
  165. '',
  166. ].join('\n'))
  167. const ctx = await boot(NAME, join(dir, 'cordis.yml'))
  168. try {
  169. expect(entryConfig(ctx, 'shared')).toEqual({ value: 'bundle' })
  170. expect(entryConfig(ctx, 'bundle-kept')).toEqual({ value: 'user' })
  171. const dropped = [...ctx.loader.entries()].find(entry => entry.options.id === 'bundle-dropped')
  172. expect(dropped?.options.disabled).toBe(true)
  173. expect(dropped?.fiber).toBeUndefined()
  174. } finally {
  175. await ctx.fiber.dispose()
  176. }
  177. })
  178. })
  179. describe('best-effort config failure recovery', () => {
  180. it.each([
  181. ['import', undefined, undefined],
  182. ['sync apply', 'export function apply(_ctx, config) { if (config.fail) throw new Error("reload sync failure") }\n', 3],
  183. ['async apply', 'export async function apply(_ctx, config) { await Promise.resolve(); if (config.fail) throw new Error("reload async failure") }\n', 3],
  184. ['dependency', 'export const inject = ["reloadMissing"]\nexport function apply() {}\n', 0],
  185. ] as const)('keeps siblings after a required-id %s failure during HMR', async (_kind, source, state) => {
  186. const base = '- id: good\n name: ./noop.mjs\n'
  187. const { ctx, dir, include } = await bootTree(base, {
  188. ...source === undefined ? {} : { 'failure.mjs': source },
  189. 'provider.mjs': 'export function apply(ctx) { ctx.provide("reloadMissing", true) }\n',
  190. })
  191. try {
  192. const good = [...ctx.loader.entries()].find(entry => entry.options.id === 'good')!.fiber
  193. writeFileSync(join(dir, 'cordis.yml'), base + '- id: webserver\n name: ./failure.mjs\n config: { fail: true }\n')
  194. await include.refresh()
  195. await ctx.loader.await()
  196. const failed = [...ctx.loader.entries()].find(entry => entry.options.id === 'webserver')!
  197. expect(failed.fiber?.state).toBe(state)
  198. expect(good?.state).toBe(2)
  199. expect(ctx.fiber.state).toBe(2)
  200. const recovery = `- id: webserver\n name: ./${source === undefined ? 'noop' : 'failure'}.mjs\n config: { fail: false }\n`
  201. const provider = state === 0 ? '- id: provider\n name: ./provider.mjs\n' : ''
  202. writeFileSync(join(dir, 'cordis.yml'), base + recovery + provider)
  203. await include.refresh()
  204. await ctx.loader.await()
  205. expect([...ctx.loader.entries()].find(entry => entry.options.id === 'webserver')?.fiber?.state).toBe(2)
  206. expect([...ctx.loader.entries()].find(entry => entry.options.id === 'good')?.fiber).toBe(good)
  207. } finally {
  208. await ctx.fiber.dispose()
  209. }
  210. })
  211. it('keeps the previous fiber config after schema rejection and retries a valid edit', async () => {
  212. const config = (value: number | string): string => `- id: webserver\n name: ./schema.mjs\n config: { value: ${JSON.stringify(value)} }\n`
  213. const { ctx, dir, include } = await bootTree(config(1), {
  214. 'schema.mjs': [
  215. 'export const Config = { "~standard": { version: 1, vendor: "app-boot-test", validate(config) {',
  216. ' return typeof config.value === "number" ? { value: config } : { issues: [{ message: "expected number" }] }',
  217. '} } }',
  218. 'export function apply(ctx, config) { ctx.provide("validatedValue", config.value) }',
  219. '',
  220. ].join('\n'),
  221. })
  222. try {
  223. writeFileSync(join(dir, 'cordis.yml'), config('invalid'))
  224. await include.refresh()
  225. await ctx.loader.await()
  226. const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === 'webserver')!
  227. expect(entry.options.config).toEqual({ value: 'invalid' })
  228. expect(entry.fiber?.config).toEqual({ value: 1 })
  229. expect(ctx.get('validatedValue')).toBe(1)
  230. writeFileSync(join(dir, 'cordis.yml'), config(2))
  231. await include.refresh()
  232. await ctx.loader.await()
  233. expect(entry.fiber?.config).toEqual({ value: 2 })
  234. expect(ctx.get('validatedValue')).toBe(2)
  235. } finally {
  236. await ctx.fiber.dispose()
  237. }
  238. })
  239. })
  240. describe('shipped builtins', () => {
  241. it('lets a booted composition share one isolate realm across a group of rows', async () => {
  242. // The reason `boot()` registers `cordis:group`: a composition — notably an
  243. // agent preset living outside this workspace, which cannot resolve
  244. // `@deepseek-ai/cordis-plugin-group` by name — gives a provider and its consumer one
  245. // named realm so the service stays out of the root realm while remaining
  246. // visible to the rows that need it.
  247. const { ctx } = await bootTree([
  248. '- id: realm',
  249. ' name: cordis:group',
  250. ' isolate:',
  251. ' demoRealmSvc: true',
  252. ' config:',
  253. ' - id: provider',
  254. ' name: ./provider.mjs',
  255. ' - id: consumer',
  256. ' name: ./consumer.mjs',
  257. '',
  258. ].join('\n'), {
  259. 'provider.mjs': 'export const name = "provider"\n'
  260. + 'export function apply(ctx) { ctx.effect(() => ctx.reflect.provide("demoRealmSvc", { tag: "realm" })) }\n',
  261. 'consumer.mjs': 'export const name = "consumer"\n'
  262. + 'export const inject = ["demoRealmSvc"]\n'
  263. + 'export function apply(ctx) { globalThis.__REALM_SEEN__ = ctx.get("demoRealmSvc").tag }\n',
  264. })
  265. try {
  266. expect((globalThis as { __REALM_SEEN__?: string }).__REALM_SEEN__).toBe('realm')
  267. // `provide` mints the root symbol unconditionally (cordis `reflect.ts`),
  268. // so the name IS in the root realm — pinned here because it is the half
  269. // that looks like the claim and is not. The claim is the other half: no
  270. // implementation is stored under that symbol, so the root realm cannot
  271. // resolve the service and a second composition mounting the same rows
  272. // cannot collide with this one.
  273. const rootKey = ctx.root[Context.isolate].demoRealmSvc
  274. expect(rootKey).toBeDefined()
  275. expect(ctx.reflect.store[rootKey!]).toBeUndefined()
  276. } finally {
  277. delete (globalThis as { __REALM_SEEN__?: string }).__REALM_SEEN__
  278. await ctx.fiber.dispose()
  279. }
  280. })
  281. })