profile-boot.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. /**
  2. * Shared profile boot for every `dsh` surface: resolve the profile, stack its
  3. * patch layers (bundle layers in `dsh.profile.bundles` order, the profile's own
  4. * `cordis.patch.yml`, `--patch` overlays, flag-derived patches, the telemetry
  5. * switch), mount the tree over the profile's empty root config, keep the
  6. * profile patch layer live, and wire fail-loud plus bounded shutdown.
  7. * @module @deepseek-ai/dsh/profile-boot
  8. */
  9. import { writeFileSync } from 'node:fs'
  10. import { join, resolve } from 'node:path'
  11. import { fileURLToPath } from 'node:url'
  12. import type { Context } from 'cordis'
  13. import type { PatchOptions } from '@cordisjs/plugin-include'
  14. import {
  15. boot,
  16. composeEntries,
  17. healProfilesModuleFallback,
  18. installFailLoud,
  19. loadOptionalPatches,
  20. loadOverlayPatches,
  21. loadProfile,
  22. PROFILE_PATCH_FILENAME,
  23. watchUserPatches,
  24. type Profile,
  25. } from '@deepseek-ai/dsh-app-boot'
  26. import { resolveDshHome } from '@deepseek-ai/dsh-paths'
  27. import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
  28. import type { HeadlessIo } from '@deepseek-ai/dsh-headless'
  29. import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'
  30. const NAME = 'dsh'
  31. /**
  32. * The home-level user patch layer (`$DSH_HOME/cordis.patch.yml`), applied
  33. * over every profile's own layer. Resolved per call, not at module load:
  34. * `$DSH_HOME` may be set by the test or launcher after import.
  35. * @returns the absolute patch-file path.
  36. */
  37. export function homePatchPath(): string {
  38. return join(resolveDshHome(), PROFILE_PATCH_FILENAME)
  39. }
  40. /** Absolute path of this dsh installation's package.json (both anchors: src/ and lib/ sit one level under apps/cli). */
  41. export const INSTALL_ANCHOR = fileURLToPath(new URL('../package.json', import.meta.url))
  42. /** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets. */
  43. const TELEMETRY_ROW_ID = 'telemetry-otel'
  44. /** The one-shot runner row a `dsh run` task requires and configures. */
  45. const HEADLESS_ROW_ID = 'headless-runner'
  46. /** The empty root entry list every profile tree patches over. */
  47. const PROFILE_ROOT_CONFIG = `# dsh profile root — an empty entry list. The tree is composed as patches:
  48. # each bundle in package.json's dsh.profile.bundles, then cordis.patch.yml, then any
  49. # --patch overlays. Edit cordis.patch.yml, not this file.
  50. []
  51. `
  52. /** Root config filename inside a profile directory. */
  53. export const PROFILE_ROOT_FILENAME = 'cordis.yml'
  54. /**
  55. * Resolve the telemetry opt-out switch into its boot patch. ANY non-empty
  56. * value (including `'0'`/`'false'`) disables: a privacy switch prefers
  57. * off-by-mistake over on-by-mistake. A composition without the telemetry row
  58. * exports nothing, so the switch is then trivially satisfied and no patch is
  59. * generated — custom profiles need not mount telemetry to run with the
  60. * switch set.
  61. * @param disabledEnv - the raw `DSH_TELEMETRY_DISABLED` value (`undefined` when unset).
  62. * @param hasRow - whether the composition carries the telemetry row.
  63. * @returns the disable patch, or `undefined` when telemetry stays enabled or is not mounted.
  64. */
  65. export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined {
  66. if ((disabledEnv ?? '') === '' || !hasRow) return undefined
  67. return { id: TELEMETRY_ROW_ID, disabled: true }
  68. }
  69. /**
  70. * Load a resolved profile for `name`: heal the shared module fallback, then
  71. * (re)write the empty root config. The root is always rewritten: the whole
  72. * composition is patch layers, and the vendored Loader's tree write-back (a
  73. * plugin self-disposing persists the current tree) can bake composed rows
  74. * into this file — which would duplicate every bundle insert on the next
  75. * boot. The file exists on disk only because the Loader needs a real include
  76. * root to anchor `baseUrl` at the profile directory (the config dump anchors
  77. * on the same file, so both compose over the identical base).
  78. * @param name - the profile name.
  79. * @param userLayer - `false` skips parsing `cordis.patch.yml` (the default dump).
  80. * @returns the loaded profile.
  81. */
  82. export function prepareProfile(name: string, userLayer = true): Profile {
  83. healProfilesModuleFallback(INSTALL_ANCHOR)
  84. const profile = loadProfile(NAME, name, INSTALL_ANCHOR, undefined, { userLayer })
  85. writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG)
  86. return profile
  87. }
  88. /** Read-only row index of a profile composition before launcher flag patches. */
  89. export type ProfileRows = ReadonlyMap<string, { name?: string; config?: unknown }>
  90. /** One profile's patch layers (application order) and the row index of its pre-flag composition. */
  91. interface ComposedProfile {
  92. profile: Profile
  93. /** Bundle layers concatenated — the part below the user layers on a live reload. */
  94. bundlePatches: PatchOptions[]
  95. /** The home-level user layer (`$DSH_HOME/cordis.patch.yml`), applied after the profile's own. */
  96. homePatches: PatchOptions[]
  97. /** Layers above the user layers on a live reload: --patch overlays, flag patches, the telemetry switch. */
  98. overlayAndFlags: PatchOptions[]
  99. /**
  100. * id → row of the pre-flag composition (bundles + user layers + overlays),
  101. * for flag merges and row checks. Flag patches must not insert rows the
  102. * launcher consults here (they only override values and insert dev glue).
  103. */
  104. rows: ProfileRows
  105. }
  106. /** The full patch stack of one composed profile, in application order. */
  107. function allPatches(composed: ComposedProfile): PatchOptions[] {
  108. return [...composed.bundlePatches, ...composed.profile.patches, ...composed.homePatches, ...composed.overlayAndFlags]
  109. }
  110. /**
  111. * Load `name` and compose its effective patch stack: bundle layers in
  112. * `dsh.profile.bundles` order, the profile's user layer, the home-level user layer
  113. * (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply to
  114. * every profile, so it outranks the per-profile layer), `--patch` overlays,
  115. * then flag patches derived from the composed rows, then the telemetry
  116. * switch.
  117. * @param name - the profile name.
  118. * @param patchFiles - `--patch` overlay paths, in argv order.
  119. * @param deriveFlagPatches - launcher hook turning composed rows into flag patches.
  120. * @returns the profile, its patch layers, and the composed row index.
  121. */
  122. function composeProfile(
  123. name: string,
  124. patchFiles: readonly string[],
  125. deriveFlagPatches: (rows: ComposedProfile['rows']) => PatchOptions[] = () => [],
  126. ): ComposedProfile {
  127. const profile = prepareProfile(name)
  128. const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? []
  129. const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)))
  130. const bundlePatches = profile.layers.flatMap(layer => layer.patches)
  131. const rows = new Map<string, { name?: string; config?: unknown }>()
  132. for (const row of composeEntries([bundlePatches, profile.patches, homePatches, overlays])) {
  133. if (typeof row.id === 'string') rows.set(row.id, row)
  134. }
  135. const overlayAndFlags = [...overlays, ...deriveFlagPatches(rows)]
  136. const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
  137. if (telemetryPatch !== undefined) overlayAndFlags.push(telemetryPatch)
  138. return { profile, bundlePatches, homePatches, overlayAndFlags, rows }
  139. }
  140. /** Options for {@link runProfile}. */
  141. export interface RunProfileOptions {
  142. /** The profile name to boot. */
  143. profile: string
  144. /** `--patch` overlay paths, in argv order. */
  145. patchFiles: readonly string[]
  146. /** Launcher hook turning the pre-flag composed rows into flag patches (the web alias's flag family). */
  147. deriveFlagPatches?: (rows: ProfileRows) => PatchOptions[]
  148. /** `dsh run` task text; requires the composition to mount the headless runner row. */
  149. task?: string
  150. /** Surface setup registered after Loader installation and before any config-tree entry mounts. */
  151. prepare?: (ctx: Context, rows: ProfileRows) => Promise<void> | void
  152. /** This run's frozen environment snapshot, provided to the tree before any entry mounts. */
  153. environment: EnvironmentSnapshot
  154. }
  155. /**
  156. * Boot one profile invocation end to end and leave process lifetime to the
  157. * mounted plugins (or to the one-shot runner when `task` is present).
  158. * @param options - profile name, overlays, flag patches, and the optional task.
  159. * @returns the settled root context and the shutdown controller.
  160. */
  161. export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> {
  162. const composed = composeProfile(options.profile, options.patchFiles, options.deriveFlagPatches)
  163. if (options.task !== undefined) {
  164. if (!composed.rows.has(HEADLESS_ROW_ID)) {
  165. throw new Error(
  166. `dsh: profile ${JSON.stringify(options.profile)} takes no task — its composition mounts no "${HEADLESS_ROW_ID}" row `
  167. + '(the headless profile does)',
  168. )
  169. }
  170. composed.overlayAndFlags.push({ id: HEADLESS_ROW_ID, config: { task: options.task } })
  171. } else if (composed.rows.has(HEADLESS_ROW_ID)) {
  172. // The inverse misuse: a one-shot composition booted without its task
  173. // would otherwise die in the runner row's schema with a raw "required"
  174. // error naming no fix.
  175. throw new Error(
  176. `dsh: profile ${JSON.stringify(options.profile)} mounts the one-shot runner and needs a task: `
  177. + `dsh run --profile ${options.profile} "<task>"`,
  178. )
  179. }
  180. const app: { current?: Context } = {}
  181. const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() })
  182. // Signals own teardown throughout the startup window, not only after boot()
  183. // settles: an inserted front door can publish readiness before sibling rows
  184. // finish mounting.
  185. process.on('SIGTERM', () => { shutdown.interrupt(options.task === undefined ? 0 : 143) })
  186. process.on('SIGINT', () => { shutdown.interrupt(130) })
  187. installFailLoud(NAME, process, async () => {
  188. await app.current?.fiber.dispose()
  189. })
  190. const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME)
  191. // Recomposition for the live user layers: bundle layers below, overlays
  192. // and flag patches above, so a user edit can never displace them. BOTH
  193. // user files are re-read per generation (the HMR watcher hands us only the
  194. // changed file's patches, which one of the reads duplicates — fresh reads
  195. // keep the two watchers from stitching in each other's stale copy).
  196. // Fresh clones per generation: the include pushes `insert` rows into the
  197. // mounted tree BY REFERENCE and later id-targeted patches mutate those
  198. // objects in place. Reusing one parsed patch object across applications
  199. // would bake a user override into the bundle's in-memory insert row, so
  200. // removing the override could never revert the row to the bundle default.
  201. const composeLive = (): PatchOptions[] => structuredClone([
  202. ...composed.bundlePatches,
  203. ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [],
  204. ...loadOptionalPatches(NAME, homePatchPath()) ?? [],
  205. ...composed.overlayAndFlags,
  206. ])
  207. // One-shot runs exit through the runner; watching would only hold the
  208. // process open after its exit request.
  209. const watchProfilePatch = options.task === undefined
  210. // Cloned for the same insert-aliasing reason as composeLive: the boot
  211. // application must not mutate the objects later reloads recompose from.
  212. const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), async (hostCtx) => {
  213. app.current = hostCtx
  214. // Before any config-tree entry mounts, so a plugin that resolves a
  215. // user-facing value at construction already sees this run's layers.
  216. hostCtx.provide(DSH_ENVIRONMENT_KEY, options.environment)
  217. if (options.task !== undefined) {
  218. const io: HeadlessIo = {
  219. stdout: process.stdout,
  220. stderr: process.stderr,
  221. exit: (code) => { void shutdown.shutdown(code) },
  222. }
  223. hostCtx.provide('headlessIo', io)
  224. }
  225. await options.prepare?.(hostCtx, composed.rows)
  226. })
  227. app.current = ctx
  228. // A surface can dispose the whole tree while startup was still in flight
  229. // (early SIGTERM); the Loader service goes with it and there is nothing to
  230. // keep live.
  231. if (watchProfilePatch && ctx.get('loader') !== undefined) {
  232. // Config-only HMR for the live profile patch layer: the web bundle
  233. // disables the shared module-reload `hmr` row (its reload lifecycle is
  234. // untested), so when the composition leaves no HMR service, mount a
  235. // watch-only instance with no module roots — cordis.patch.yml edits stay
  236. // live on every long-lived surface. A silent skip would break the
  237. // documented hot-reload contract. HMR injects the timer service, which a
  238. // bare custom profile may not mount either.
  239. if (ctx.get('hmr') === undefined) {
  240. if (ctx.get('timer') === undefined) {
  241. await ctx.loader.create({ name: '@cordisjs/plugin-timer' })
  242. }
  243. await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } })
  244. }
  245. await watchUserPatches(ctx, {
  246. binName: NAME,
  247. filename: composed.profile.patchPath,
  248. compose: composeLive,
  249. })
  250. await watchUserPatches(ctx, {
  251. binName: NAME,
  252. filename: homePatchPath(),
  253. compose: composeLive,
  254. })
  255. }
  256. return { ctx, shutdown }
  257. }