index.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681
  1. /**
  2. * Shared boot glue for the app bins (`dsh`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored
  3. * `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the
  4. * optional personal overlay patches from the Harness home (`~/.dsh`), expose its path resolver to
  5. * config expressions, and drive the Cordis Loader against a leaf `cordis.yml` until the tree settles.
  6. * @module @deepseek-ai/dsh-app-boot
  7. */
  8. import { pathToFileURL } from 'node:url'
  9. import { readFileSync } from 'node:fs'
  10. import { basename, dirname, join, resolve } from 'node:path'
  11. import * as yaml from 'js-yaml'
  12. import { Context, type FiberState } from 'cordis'
  13. import Loader, { type Entry, type EntryOptions } from '@cordisjs/plugin-loader'
  14. import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@cordisjs/plugin-include'
  15. import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths'
  16. import type {} from '@cordisjs/plugin-hmr'
  17. // Side-effect type import: resolves `ctx.get('systemPrompt')` to the service.
  18. import type {} from '@deepseek-ai/dsh-system-prompt'
  19. declare module 'cordis' {
  20. interface Context {
  21. /** Harness-home path resolver available to Loader `!!js` config expressions. */
  22. dshHomePath?: typeof dshHomePath
  23. }
  24. }
  25. /**
  26. * Resolve the config to boot. Replay swaps a `cordis.yml` basename for
  27. * `cordis.snapshot.yml` in the same directory; every other mode keeps the path.
  28. * @param configPath - the requested config path (absolute, or relative to `cwd`).
  29. * @param snapshotMode - the bin's `$DSH_SNAPSHOT` value; only `'replay'` swaps the
  30. * basename.
  31. * @param cwd - the base a relative `configPath` resolves against.
  32. * @returns the absolute path of the config to boot.
  33. */
  34. export function resolveConfigPath(
  35. configPath: string, snapshotMode: string | undefined, cwd: string = process.cwd(),
  36. ): string {
  37. const absolute = resolve(cwd, configPath)
  38. if (snapshotMode !== 'replay') return absolute
  39. const dir = dirname(absolute)
  40. const replayName = basename(absolute).replace(/cordis\.ya?ml$/, 'cordis.snapshot.yml')
  41. return resolve(dir, replayName)
  42. }
  43. /**
  44. * Load the optional gitignored `.env` from `dir`. Missing files fall back to the
  45. * ambient environment; other read failures are reported through `warn`.
  46. * @param binName - the diagnostic prefix on the warn line.
  47. * @param dir - the directory whose `.env` to load.
  48. * @param warn - sink for the one-line misconfiguration diagnostic.
  49. */
  50. export function loadEnv(
  51. binName: string, dir: string = process.cwd(),
  52. warn: (line: string) => void = line => void process.stderr.write(line),
  53. ): void {
  54. try {
  55. process.loadEnvFile(resolve(dir, '.env'))
  56. } catch (error) {
  57. if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') {
  58. warn(`${binName}: failed to load .env: ${String(error)}\n`)
  59. }
  60. // ENOENT (no .env) is fine — rely on the ambient environment.
  61. }
  62. }
  63. /** File inside the Harness home holding the personal loader overlay patches. */
  64. export const PERSONAL_CONFIG_FILENAME = 'config.yaml'
  65. const bootstrapIncludes = new WeakMap<Context, Entry>()
  66. // The include's YAML dialect (`!!js` scalars become expression nodes the
  67. // Loader interpolates against each entry's context at mount time), imported
  68. // from the include itself so patch parsing and config dumping can never drift
  69. // from what the include mounts. Personal patches share it so they may
  70. // reference `process.env`.
  71. const personalPatchesSchema = entryListSchema
  72. /**
  73. * Load the optional personal overlay patches (`config.yaml` under the Harness
  74. * home). The file is a top-level YAML array of loader patch entries
  75. * (`@cordisjs/plugin-include`'s `PatchOptions`): id-targeted config overrides
  76. * and `insert` lists, with `!!js` expressions allowed. A missing file means
  77. * "no personal overlay"; an unreadable, unparsable, or non-array file throws —
  78. * a present personal config that cannot apply is a misconfiguration and must
  79. * fail loud at boot, never be silently skipped.
  80. * @param binName - the diagnostic prefix on the thrown error.
  81. * @param dir - the Harness home; defaults to {@link resolveDshHome} (`$DSH_HOME` or `~/.dsh`).
  82. * @returns the parsed patches, or `undefined` when the file does not exist.
  83. */
  84. export function loadPersonalPatches(
  85. binName: string, dir: string = resolveDshHome(),
  86. ): PatchOptions[] | undefined {
  87. const file = join(dir, PERSONAL_CONFIG_FILENAME)
  88. let content: string
  89. try {
  90. content = readFileSync(file, 'utf8')
  91. } catch (error) {
  92. if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined
  93. throw new Error(`${binName}: failed to read personal patches ${file}: ${String(error)}`)
  94. }
  95. return parsePatchList(binName, file, content, 'personal patches')
  96. }
  97. /**
  98. * Load a required overlay patch list: a surface overlay (`tui.cordis.yml`) or a
  99. * `--config <path>` overlay applied over the shared base. Same file format as
  100. * {@link loadPersonalPatches}, but a missing file throws, because the caller
  101. * named this file — its absence is a misconfiguration, not "no overlay".
  102. * @param binName - the diagnostic prefix on the thrown error.
  103. * @param file - absolute path of the overlay file.
  104. * @returns the parsed patch list.
  105. */
  106. export function loadOverlayPatches(binName: string, file: string): PatchOptions[] {
  107. let content: string
  108. try {
  109. content = readFileSync(file, 'utf8')
  110. } catch (error) {
  111. throw new Error(`${binName}: failed to read overlay ${file}: ${String(error)}`)
  112. }
  113. return parsePatchList(binName, file, content, 'overlay')
  114. }
  115. /**
  116. * Parse one loader patch list: a top-level YAML array of
  117. * `@cordisjs/plugin-include` `PatchOptions` (id-targeted config overrides and
  118. * `insert` lists, `!!js` expressions allowed). Every shape failure throws,
  119. * because a patch file that cannot be applied at all is a misconfiguration; a
  120. * single patch whose target row is absent stays a per-entry Loader warning, so
  121. * one overlay shared across surfaces does not have to match every tree.
  122. * @param binName - the diagnostic prefix on the thrown error.
  123. * @param file - the source path, quoted in errors.
  124. * @param content - the file's text.
  125. * @param label - what to call this list in errors (`personal patches`, `overlay`).
  126. * @returns the parsed patch list.
  127. */
  128. function parsePatchList(
  129. binName: string, file: string, content: string, label: string,
  130. ): PatchOptions[] {
  131. let parsed: unknown
  132. try {
  133. parsed = yaml.load(content, { schema: personalPatchesSchema })
  134. } catch (error) {
  135. throw new Error(`${binName}: failed to parse ${label} ${file}: ${String(error)}`)
  136. }
  137. if (!Array.isArray(parsed)) {
  138. throw new Error(`${binName}: ${label} ${file} must be a top-level YAML array of loader patch entries`)
  139. }
  140. parsed.forEach((entry, index) => {
  141. if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
  142. throw new Error(`${binName}: ${label} entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`)
  143. }
  144. })
  145. return parsed as PatchOptions[]
  146. }
  147. /** One overlay patch list with the label provenance comments print for it. */
  148. export interface ConfigDumpLayer {
  149. /** Source name shown in provenance comments (a file basename or path). */
  150. label: string
  151. /** The layer's patches, from {@link loadOverlayPatches} / {@link loadPersonalPatches}. */
  152. patches: PatchOptions[]
  153. }
  154. /**
  155. * Compose the effective entry list exactly as `boot()` would mount it: parse
  156. * the base config file with the include's entry-list dialect, apply every
  157. * layer's patches as ONE flattened list through the include's own patch
  158. * algorithm (`applyEntryPatches`) — the same single call `boot()` makes, so
  159. * even patch-visibility corner cases (a later layer targeting a group child a
  160. * plain config replacement introduced, which the single-pass id index never
  161. * sees) compose identically — then render the result as YAML in the same
  162. * dialect (`!!js` expressions print verbatim, unevaluated).
  163. *
  164. * Every run of rows with the same provenance is preceded by a `# ==` comment
  165. * naming the file that contributed the rows and any layers that patched them,
  166. * so the output stays a loadable YAML document while showing which section
  167. * comes from which file. Provenance is derived from single-call prefix
  168. * snapshots (base + layers 1..k), diffed positionally: the patch algorithm
  169. * only rewrites rows in place or appends, so a top-level index identifies one
  170. * row across snapshots, and a layer whose addition changes the row (config
  171. * replacement, disable, group insert) is listed as having patched it.
  172. *
  173. * A patch that matches no row is reported through `warn` with its layer
  174. * label, mirroring the Loader's boot-time warning. Earlier layers' patches
  175. * see an identical preceding state in every snapshot that includes them, so
  176. * each snapshot's warning list extends the previous one and the new tail
  177. * belongs to the added layer.
  178. * @param binName - the diagnostic prefix on read/parse errors.
  179. * @param absoluteConfigPath - the base config file `boot()` would include.
  180. * @param layers - overlay layers in application order (later wins).
  181. * @param warn - sink for skipped-patch diagnostics; defaults to stderr.
  182. * @returns the composed entry list rendered as a YAML document with
  183. * provenance comment separators.
  184. */
  185. export function renderConfigDump(
  186. binName: string,
  187. absoluteConfigPath: string,
  188. layers: ConfigDumpLayer[],
  189. warn: (line: string) => void = line => void process.stderr.write(`${line}\n`),
  190. ): string {
  191. let content: string
  192. try {
  193. content = readFileSync(absoluteConfigPath, 'utf8')
  194. } catch (error) {
  195. throw new Error(`${binName}: failed to read config ${absoluteConfigPath}: ${String(error)}`)
  196. }
  197. let parsed: unknown
  198. try {
  199. parsed = yaml.load(content, { schema: entryListSchema })
  200. } catch (error) {
  201. throw new Error(`${binName}: failed to parse config ${absoluteConfigPath}: ${String(error)}`)
  202. }
  203. if (!Array.isArray(parsed)) {
  204. throw new Error(`${binName}: config ${absoluteConfigPath} must be a top-level YAML array of entries`)
  205. }
  206. const baseLabel = basename(absoluteConfigPath)
  207. // The YAML boundary yields untyped rows; the include validates entry shape
  208. // at mount, and the dump prints whatever the file holds, so `EntryOptions`
  209. // here is structural trust in the same file `boot()` would include.
  210. const base = parsed as Parameters<typeof applyEntryPatches>[0]
  211. // snapshot_k = ONE application of layers 1..k flattened — boot's exact call
  212. // shape for that prefix. snapshot_N is therefore the mounted composition.
  213. // The patches are cloned per call: applyEntryPatches detaches the entry
  214. // list but pushes `insert` rows by reference from the patch list, so
  215. // sharing patch objects across snapshot calls would leak a later
  216. // snapshot's mutations into an earlier one's result.
  217. const snapshot = (count: number, warnings: string[]): ReturnType<typeof applyEntryPatches> => {
  218. const flattened = structuredClone(layers.slice(0, count).flatMap(layer => layer.patches))
  219. return applyEntryPatches(base, flattened, (message: string, ...args: unknown[]) => {
  220. // The include logs through cordis's printf-style logger (`%C` = code); a
  221. // dump has no logger, so substitute inline for a plain line.
  222. let index = 0
  223. warnings.push(message.replace(/%C/g, () => JSON.stringify(args[index++])))
  224. })
  225. }
  226. let previous = base
  227. let previousWarnings: string[] = []
  228. const provenance: { origin: string; patchedBy: string[] }[] = base.map(() => ({ origin: baseLabel, patchedBy: [] }))
  229. let composed = base
  230. for (let count = 1; count <= layers.length; count += 1) {
  231. const layer = layers[count - 1]
  232. /* v8 ignore next -- count iterates 1..length, so the slot exists */
  233. if (layer === undefined) continue
  234. const warnings: string[] = []
  235. composed = snapshot(count, warnings)
  236. for (const line of warnings.slice(previousWarnings.length)) {
  237. warn(`${binName}: [${layer.label}] ${line}`)
  238. }
  239. const before = previous.map(entry => JSON.stringify(entry))
  240. for (let index = 0; index < composed.length; index += 1) {
  241. if (index >= before.length) provenance.push({ origin: layer.label, patchedBy: [] })
  242. else if (JSON.stringify(composed[index]) !== before[index]) provenance[index]?.patchedBy.push(layer.label)
  243. }
  244. previous = composed
  245. previousWarnings = warnings
  246. }
  247. return groupedDump(composed, provenance)
  248. }
  249. /** Render the composed rows grouped under one provenance comment per contiguous run. */
  250. function groupedDump(
  251. composed: readonly unknown[],
  252. provenance: readonly { origin: string; patchedBy: string[] }[],
  253. ): string {
  254. const lines: string[] = []
  255. let currentLabel: string | undefined
  256. let group: unknown[] = []
  257. const flush = (): void => {
  258. if (currentLabel === undefined || group.length === 0) return
  259. lines.push(`# == ${currentLabel}`)
  260. lines.push(yaml.dump(group, { schema: entryListSchema, noRefs: true }).trimEnd())
  261. group = []
  262. }
  263. for (let index = 0; index < composed.length; index += 1) {
  264. const record = provenance[index]
  265. /* v8 ignore next -- provenance is index-aligned with composed by construction */
  266. if (record === undefined) continue
  267. const label = record.patchedBy.length === 0
  268. ? record.origin
  269. : `${record.origin}, patched by ${record.patchedBy.join(', ')}`
  270. if (label !== currentLabel) {
  271. flush()
  272. currentLabel = label
  273. }
  274. group.push(composed[index])
  275. }
  276. flush()
  277. return lines.join('\n') + '\n'
  278. }
  279. /** Options for live personal-config reconciliation. */
  280. export interface PersonalPatchWatchOptions {
  281. /** Diagnostic prefix used by {@link loadPersonalPatches}. */
  282. binName: string
  283. /** Harness home containing `config.yaml`; defaults to {@link resolveDshHome}. */
  284. dir?: string
  285. /**
  286. * Compose the full patch list for a fresh personal-overlay generation —
  287. * the same composition the app booted with, so a reload can interleave the
  288. * new personal patches between app-owned layers (surface overlay below,
  289. * profile/flag patches above). Identity when omitted: the personal overlay
  290. * is the whole patch list.
  291. */
  292. compose?: (personalPatches: PatchOptions[]) => PatchOptions[]
  293. }
  294. /**
  295. * Watch the personal overlay through Cordis HMR and transactionally reapply it to the boot include.
  296. * @param ctx - settled app context containing the root Include and an active HMR service.
  297. * @param options - diagnostic, Harness-home, and patch-composition inputs.
  298. * @returns an asynchronous disposer after the exact-path watcher is ready.
  299. * @throws when HMR or the root Include is absent, watcher setup fails, or initial path resolution fails.
  300. */
  301. export async function watchPersonalPatches(
  302. ctx: Context,
  303. options: PersonalPatchWatchOptions,
  304. ): Promise<() => Promise<void>> {
  305. const { binName, dir = resolveDshHome(), compose = (patches: PatchOptions[]) => patches } = options
  306. const hmr = ctx.get('hmr')
  307. if (hmr === undefined) throw new Error(`${binName}: personal config watching requires the Cordis HMR service`)
  308. const entry = bootstrapIncludes.get(ctx)
  309. if (entry === undefined) throw new Error(`${binName}: personal config watching requires the root Include entry`)
  310. const filename = join(dir, PERSONAL_CONFIG_FILENAME)
  311. const register = hmr.registerConfig(filename, async () => {
  312. // Re-read the include's non-patch options per refresh: a writer that
  313. // updates the root Include's other options between refreshes (none exists
  314. // today) must not have them silently reverted by a personal reload.
  315. const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config
  316. const personalPatches = loadPersonalPatches(binName, dir) ?? []
  317. const patches = compose(personalPatches)
  318. await entry.update({
  319. config: {
  320. ...includeConfig,
  321. patches,
  322. },
  323. })
  324. })
  325. try {
  326. return await register
  327. } catch (error) {
  328. // A surface can dispose the whole tree while the watcher is still opening;
  329. // the HMR effect registration then fails with INACTIVE_EFFECT. That is the
  330. // app exiting exactly as asked, not a watch failure, so return a no-op
  331. // disposer instead of crashing.
  332. if ((error as { code?: string } | null)?.code === 'INACTIVE_EFFECT') return async () => {}
  333. throw error
  334. }
  335. }
  336. /**
  337. * Mount and remember the exact root Include entry used by app boot and personal-config HMR.
  338. * @param ctx - context carrying an initialized Loader service.
  339. * @param absoluteConfigPath - absolute YAML or JSON configuration path.
  340. * @param patches - initial app and personal patches, applied in order.
  341. * @returns the created root Include entry, or `undefined` when a surface
  342. * disposed the whole tree (taking the Loader service with it) while the
  343. * transactional create was still settling entry lifecycle.
  344. */
  345. export async function mountRootInclude(
  346. ctx: Context,
  347. absoluteConfigPath: string,
  348. patches: readonly PatchOptions[] = [],
  349. ): Promise<Entry | undefined> {
  350. ctx.loader.builtins.include = Include
  351. // Pinned id: the bootstrap include is app glue, not a config row, and its
  352. // id appears in Loader failure chains — a random id would make startup
  353. // diagnostics unstable across runs (and snapshot fixtures).
  354. const rootInclude: EntryOptions = {
  355. id: 'include',
  356. name: 'cordis:include',
  357. config: {
  358. path: pathToFileURL(absoluteConfigPath).href,
  359. ...patches.length > 0 ? { patches: [...patches] } : {},
  360. },
  361. }
  362. const includeId = await ctx.loader.create(rootInclude)
  363. const loader = ctx.get('loader')
  364. if (loader === undefined) return undefined
  365. const entry = loader.resolve(includeId)
  366. bootstrapIncludes.set(ctx, entry)
  367. return entry
  368. }
  369. /**
  370. * The slice of `process` {@link installFailLoud} needs — injectable so tests
  371. * exercise the handler without registering on (or exiting) the real process.
  372. */
  373. export interface FailLoudProcess {
  374. on(event: 'unhandledRejection', handler: (err: unknown) => void): unknown
  375. off(event: 'unhandledRejection', handler: (err: unknown) => void): unknown
  376. stderr: { write(chunk: string): unknown }
  377. /**
  378. * Terminate the process. Callers treat this as the end of the run, as
  379. * `process.exit` is; a fake that returns lets the caller continue, which only
  380. * a test observes.
  381. */
  382. exit(code: number): void
  383. }
  384. // Loader rc.5 derives and drops a rejected promise after a fiber fails. Keep
  385. // exact reasons already folded into the boot diagnostic visible through the
  386. // next process rejection checkpoint so the process guard can coalesce them.
  387. const assembledActivationRejections = new Map<unknown, number>()
  388. function retainAssembledRejection(reason: unknown): void {
  389. assembledActivationRejections.set(reason, (assembledActivationRejections.get(reason) ?? 0) + 1)
  390. }
  391. function releaseAssembledRejection(reason: unknown): void {
  392. const count = assembledActivationRejections.get(reason)
  393. if (count === undefined || count === 1) {
  394. assembledActivationRejections.delete(reason)
  395. } else {
  396. assembledActivationRejections.set(reason, count - 1)
  397. }
  398. }
  399. async function observeLoaderRejectionCheckpoint(reasons: readonly unknown[]): Promise<void> {
  400. for (const reason of reasons) retainAssembledRejection(reason)
  401. try {
  402. await new Promise<void>(resolve => setImmediate(resolve))
  403. } finally {
  404. for (const reason of reasons) releaseAssembledRejection(reason)
  405. }
  406. }
  407. /**
  408. * How long {@link installFailLoud} waits for its `release` hook before exiting
  409. * anyway. A wedged disposer must delay the fatal exit, never cancel it.
  410. */
  411. export const FAIL_LOUD_RELEASE_TIMEOUT_MS = 2_000
  412. /**
  413. * Install before boot to turn a late unhandled plugin-init rejection into one
  414. * labelled stderr diagnostic and `exit(1)`. A rejection already included by
  415. * {@link assertEntriesActivated} is ignored during its process checkpoint;
  416. * every other rejection remains fatal. Stdout remains untouched for ACP; the
  417. * returned function removes the handler.
  418. *
  419. * The Loader mounts entries concurrently, so a surface that owns the terminal
  420. * can already hold it when a sibling entry rejects. Exiting straight from the
  421. * handler would strand raw mode, bracketed paste, and the keyboard protocol on
  422. * the user's shell, and leave an in-flight terminal query's reply to land as
  423. * literal text at the next prompt. `release` is the terminal owner's chance to
  424. * hand it back; it is awaited under {@link FAIL_LOUD_RELEASE_TIMEOUT_MS}, whose
  425. * timer stays referenced so a never-settling disposer cannot let Node reach an
  426. * empty event loop and exit 0 instead of failing.
  427. *
  428. * The diagnostic is written before the release so a hanging or failing disposer
  429. * cannot swallow the reason. The handler stays installed while the release runs
  430. * — removing it would let a second concurrent rejection become uncaught and kill
  431. * the process mid-teardown, stranding exactly the terminal state this restores —
  432. * so a latch keeps the first rejection the reported one and lets later
  433. * rejections (including the release's own) fall through to the pending exit.
  434. * @param binName - the diagnostic prefix on the fatal-failure line.
  435. * @param proc - the process slice to register on; tests inject a fake.
  436. * @param release - optional teardown awaited before exit, used by a
  437. * terminal-owning surface to restore the terminal. Its own failure is
  438. * swallowed because the pending fatal exit already owns the outcome.
  439. * @returns the uninstaller that removes the rejection handler.
  440. */
  441. export function installFailLoud(
  442. binName: string,
  443. proc: FailLoudProcess = process,
  444. release?: () => Promise<void> | void,
  445. ): () => void {
  446. let exiting = false
  447. const handler = (err: unknown): void => {
  448. if (assembledActivationRejections.has(err)) return
  449. // A release in flight already owns the exit. Swallow later rejections
  450. // (teardown's own included) rather than reporting a second failure over the
  451. // real one or letting Node kill the process before the terminal is back.
  452. if (exiting) return
  453. exiting = true
  454. proc.stderr.write(`${binName}: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
  455. if (release === undefined) {
  456. proc.exit(1)
  457. return
  458. }
  459. void (async () => {
  460. // Definitely assigned: the timeout promise's executor runs synchronously
  461. // while the race is being constructed, before the first await.
  462. let timer!: ReturnType<typeof setTimeout>
  463. try {
  464. await Promise.race([
  465. (async () => release())(),
  466. new Promise<void>((resolve) => {
  467. timer = setTimeout(resolve, FAIL_LOUD_RELEASE_TIMEOUT_MS)
  468. }),
  469. ])
  470. } catch {
  471. // The terminal release failed; the fatal exit below is the outcome that
  472. // matters, and no reporter runs after it.
  473. }
  474. clearTimeout(timer)
  475. proc.exit(1)
  476. })()
  477. }
  478. const uninstall = (): void => void proc.off('unhandledRejection', handler)
  479. proc.on('unhandledRejection', handler)
  480. return uninstall
  481. }
  482. /**
  483. * After the tree settles, reject entries with no fiber and name every plugin
  484. * whose module failed to resolve. Disabled entries are the only valid
  485. * fiber-less state.
  486. * @param ctx - the settled context whose loader entries to audit.
  487. * @param binName - the diagnostic prefix on the thrown error.
  488. */
  489. export function assertEntriesLoaded(ctx: Context, binName: string): void {
  490. const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled)
  491. if (failed.length > 0) {
  492. const names = failed.map(entry => entry.options.name).join(', ')
  493. throw new Error(`${binName}: plugin(s) failed to load: ${names}; Cordis startup failed because these plugin(s) could not be resolved (see the error(s) logged above)`)
  494. }
  495. }
  496. /**
  497. * Value mirrors used because Cordis's const enum has no runtime object to import.
  498. * Keep aligned with `packages/cordis/tool-cordis/src/fiber-state.ts` and
  499. * `packages/client/web/src/loader-status.ts`.
  500. */
  501. const FIBER_PENDING = 0 as FiberState.PENDING
  502. const FIBER_ACTIVE = 2 as FiberState.ACTIVE
  503. const FIBER_FAILED = 3 as FiberState.FAILED
  504. /** Render a thrown plugin value without discarding an Error's original stack. */
  505. function formatActivationError(error: unknown): string {
  506. return error instanceof Error ? error.stack ?? error.message : String(error)
  507. }
  508. /**
  509. * Reject a settled Loader tree when an enabled entry failed or remains inactive.
  510. * Plugin failures include the original thrown stack; pending entries name their
  511. * unresolved services because no plugin error exists for that state. Active
  512. * entries require no further wait; only failed fibers are awaited to recover
  513. * their private rejection reason.
  514. * @param ctx - the settled context whose Loader entries to audit.
  515. * @param binName - the diagnostic prefix on the thrown error.
  516. * @returns nothing when every enabled entry is active.
  517. * @throws after one process rejection checkpoint when an entry failed to
  518. * import, rejected during activation, or did not become active.
  519. */
  520. export async function assertEntriesActivated(ctx: Context, binName: string): Promise<void> {
  521. assertEntriesLoaded(ctx, binName)
  522. const failures: string[] = []
  523. const rejectionReasons: unknown[] = []
  524. for (const entry of ctx.loader.entries()) {
  525. const fiber = entry.fiber
  526. if (fiber === undefined || entry.disabled) continue
  527. const state = fiber.state
  528. if (state === FIBER_ACTIVE) continue
  529. if (state === FIBER_FAILED) {
  530. try {
  531. await fiber.await()
  532. } catch (error) {
  533. rejectionReasons.push(error)
  534. failures.push(`${entry.options.name}: ${formatActivationError(error)}`)
  535. }
  536. continue
  537. }
  538. if (state === FIBER_PENDING) {
  539. const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined)
  540. const subject = missing.length === 1 ? 'service' : 'services'
  541. failures.push(`${entry.options.name}: pending (waiting for ${subject}: ${missing.join(', ') || 'unknown'})`)
  542. } else {
  543. failures.push(`${entry.options.name}: fiber state ${String(state)}`)
  544. }
  545. }
  546. if (failures.length > 0) {
  547. if (rejectionReasons.length > 0) {
  548. await observeLoaderRejectionCheckpoint(rejectionReasons)
  549. }
  550. const noun = failures.length === 1 ? 'entry' : 'entries'
  551. throw new Error(`${binName}: ${String(failures.length)} ${noun} did not activate\n${failures.join('\n')}`)
  552. }
  553. }
  554. /**
  555. * Boot the Loader against `absoluteConfigPath` and return only after the whole
  556. * tree settles. Entry names load through the Loader's internal module loader
  557. * against `baseUrl` (the config directory), which may live outside
  558. * `node_modules` reach and, unbuilt, cannot load vendored source; the
  559. * bootstrap include is therefore statically imported and mounted as the
  560. * `cordis:include` builtin, loading through the ambient module pipeline
  561. * (vite/tsx/plain ESM) while the included tree's own specifiers stay
  562. * config-relative. The package build embeds Include while leaving Loader
  563. * external, so the built include tree and host share one Loader peer. Loader
  564. * settlement rejects startup failures, which `boot` wraps after disposing the
  565. * partial context; a missing fiber or never-activating entry is rejected by
  566. * the final audit, {@link assertEntriesActivated}, which rethrows a plugin's
  567. * init rejection with its original stack; later unhandled rejections remain
  568. * covered by {@link installFailLoud}. Built bins need the Loader's native
  569. * helper for bare plugin specifiers; relative specifiers do not.
  570. * @param binName - the diagnostic prefix for load-failure errors.
  571. * @param absoluteConfigPath - the config to include; must already be absolute
  572. * (see {@link resolveConfigPath}).
  573. * @param patches - optional overlay patches applied over the included tree
  574. * (see {@link loadPersonalPatches}); an empty list mounts none.
  575. * @param prepare - optional host setup run after Loader installation and before any config-tree entry mounts.
  576. * @returns the root context once every entry has started, or as soon as a
  577. * surface disposed the tree while startup was still in flight.
  578. * @throws a labelled error after disposing the partial context — `host
  579. * preparation failed` when `prepare` threw before any config-tree entry
  580. * mounted, `plugin tree failed to load` afterwards.
  581. */
  582. export async function boot(
  583. binName: string,
  584. absoluteConfigPath: string,
  585. patches?: PatchOptions[],
  586. prepare?: (ctx: Context) => Promise<void> | void,
  587. ): Promise<Context> {
  588. const ctx = new Context()
  589. // Two failure labels: `prepare` runs before any config-tree entry mounts,
  590. // so its failure is host setup, not the plugin tree.
  591. let stage = 'host preparation failed'
  592. try {
  593. ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
  594. ctx.provide('dshHomePath', dshHomePath)
  595. await ctx.plugin(Loader)
  596. await prepare?.(ctx)
  597. stage = 'plugin tree failed to load'
  598. await mountRootInclude(ctx, absoluteConfigPath, patches)
  599. // A surface can finish and dispose the whole tree while startup is still
  600. // in flight, before the last entry settles. The Loader service goes with
  601. // it, and the activation audit describes a live tree — reading `ctx.loader`
  602. // past this point would throw a TypeError over an app that exited exactly
  603. // as asked. Transactional group updates settle
  604. // lifecycle inside the mount, so the teardown can land before it returns;
  605. // re-check after every await.
  606. await ctx.get('loader')?.await()
  607. if (ctx.get('loader') === undefined) return ctx
  608. await assertEntriesActivated(ctx, binName)
  609. return ctx
  610. } catch (cause) {
  611. // Root-fiber disposal contains cleanup failures per observer (Cordis
  612. // fiber.ts hardening) and a repeated call returns the settled single-shot
  613. // result, so this await cannot reject and replace `cause`.
  614. await ctx.fiber.dispose()
  615. const detail = cause instanceof Error ? cause.message : String(cause)
  616. // The transactional Loader wraps a failing entry apply in one message per
  617. // tree layer; every layer's message is folded into `detail` above, and the
  618. // deepest cause is the plugin's own thrown error, whose stack names the
  619. // real failure site — append it so the startup diagnostic preserves the
  620. // original activation error instead of only the wrap chain.
  621. let deepest: unknown = cause
  622. while (deepest instanceof Error && deepest.cause !== undefined) deepest = deepest.cause
  623. const stack = deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : ''
  624. throw new Error(`${binName}: ${stage}: ${detail}${stack}`, { cause })
  625. }
  626. }
  627. /** Prompt-section name for the harness-source location line an app bin adds after boot. */
  628. export const HARNESS_SOURCE_SECTION = 'harness:source'
  629. /**
  630. * Add a global prompt section naming the on-disk harness source checkout while
  631. * explicitly distinguishing it from the task workspace and current working
  632. * directory. The self-referential `dsh-tool-cordis` toolset reads and edits this
  633. * checkout. Call once on the settled boot context ({@link boot}); the section
  634. * orders just after the harness identity opener (`-100`) and before the deployment
  635. * persona (`0`). A booted tree with no `systemPrompt` service has no prompt to
  636. * augment, so this is then a no-op that returns `undefined`. The section is
  637. * registered against the `systemPrompt` service's fiber, so a dev HMR reload of
  638. * that plugin drops it until the next boot.
  639. * @param ctx - the settled boot context whose global system prompt to augment.
  640. * @param sourceRoot - the absolute path to the harness checkout root.
  641. * @returns the section disposer, or `undefined` when no `systemPrompt` service is mounted.
  642. */
  643. export function addHarnessSourceSection(ctx: Context, sourceRoot: string): (() => void) | undefined {
  644. const systemPrompt = ctx.get('systemPrompt')
  645. if (systemPrompt === undefined) return undefined
  646. return systemPrompt.section({
  647. name: HARNESS_SOURCE_SECTION,
  648. order: -99,
  649. text: `The DeepSeek Harness implementation checkout is at ${sourceRoot}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`,
  650. })
  651. }