profile-boot.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  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
  4. * own `cordis.patch.yml`, `--patch` overlays, the telemetry switch), mount the
  5. * tree over the profile's empty root config, keep the profile patch layer
  6. * live, and wire fail-loud plus bounded shutdown.
  7. *
  8. * App flags are not the launcher's business: the invocation's inner arguments
  9. * are provided to the tree through `ctx.cmdlineArgs`, where any injected app
  10. * plugin may read the same immutable snapshot.
  11. * @module @deepseek-ai/dsh/profile-boot
  12. */
  13. import { writeFileSync } from 'node:fs'
  14. import { join, resolve } from 'node:path'
  15. import { fileURLToPath } from 'node:url'
  16. import { FiberState, type Context } from '@deepseek-ai/cordis'
  17. import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
  18. import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
  19. import {
  20. boot,
  21. composeEntries,
  22. healProfilesModuleFallback,
  23. installFailLoud,
  24. loadOptionalPatches,
  25. loadOverlayPatches,
  26. loadProfile,
  27. PROFILE_PATCH_FILENAME,
  28. watchUserPatches,
  29. type Profile,
  30. } from '@deepseek-ai/dsh-app-boot'
  31. import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths'
  32. /** Shipped agent-preset root: beside this app's own config, in both source and built layouts. */
  33. const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', import.meta.url))
  34. /** Harness-home directory holding locally authored agent presets. */
  35. const USER_PRESET_DIR = '.agent-presets'
  36. import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
  37. import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
  38. import type { HeadlessIo } from '@deepseek-ai/dsh-headless'
  39. import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'
  40. import { resolveWindowsShellLayer } from './windows-shell.ts'
  41. const NAME = 'dsh'
  42. /**
  43. * The home-level user patch layer (`$DSH_HOME/cordis.patch.yml`), applied
  44. * over every profile's own layer. Resolved per call, not at module load:
  45. * `$DSH_HOME` may be set by the test or launcher after import.
  46. * @returns the absolute patch-file path.
  47. */
  48. export function homePatchPath(): string {
  49. return join(resolveDshHome(), PROFILE_PATCH_FILENAME)
  50. }
  51. /** Absolute path of this dsh installation's package.json (both anchors: src/ and lib/ sit one level under apps/cli). */
  52. export const INSTALL_ANCHOR = fileURLToPath(new URL('../package.json', import.meta.url))
  53. /** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets. */
  54. const TELEMETRY_ROW_ID = 'telemetry-otel'
  55. /** The one-shot runner row: its presence means this composition exits by itself. */
  56. const HEADLESS_ROW_ID = 'headless-runner'
  57. /** The empty root entry list every profile tree patches over. */
  58. const PROFILE_ROOT_CONFIG = `# dsh profile root — an empty entry list. The tree is composed as patches:
  59. # each bundle in package.json's dsh.profile.bundles, then cordis.patch.yml, then any
  60. # --patch overlays. Edit cordis.patch.yml, not this file.
  61. []
  62. `
  63. /** Root config filename inside a profile directory. */
  64. export const PROFILE_ROOT_FILENAME = 'cordis.yml'
  65. /**
  66. * Resolve the telemetry opt-out switch into its boot patch. ANY non-empty
  67. * value (including `'0'`/`'false'`) disables: a privacy switch prefers
  68. * off-by-mistake over on-by-mistake. A composition without the telemetry row
  69. * exports nothing, so the switch is then trivially satisfied and no patch is
  70. * generated — custom profiles need not mount telemetry to run with the
  71. * switch set.
  72. * @param disabledEnv - the raw `DSH_TELEMETRY_DISABLED` value (`undefined` when unset).
  73. * @param hasRow - whether the composition carries the telemetry row.
  74. * @returns the disable patch, or `undefined` when telemetry stays enabled or is not mounted.
  75. */
  76. export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined {
  77. if ((disabledEnv ?? '') === '' || !hasRow) return undefined
  78. return { id: TELEMETRY_ROW_ID, disabled: true }
  79. }
  80. /**
  81. * Load a resolved profile for `name`: heal the shared module fallback, then
  82. * (re)write the empty root config. The root is always rewritten: the whole
  83. * composition is patch layers, and the vendored Loader's tree write-back (a
  84. * plugin self-disposing persists the current tree) can bake composed rows
  85. * into this file — which would duplicate every bundle insert on the next
  86. * boot. The file exists on disk only because the Loader needs a real include
  87. * root to anchor `baseUrl` at the profile directory (the config dump anchors
  88. * on the same file, so both compose over the identical base).
  89. * @param name - the profile name.
  90. * @param userLayer - `false` skips parsing `cordis.patch.yml` (the default dump).
  91. * @returns the loaded profile.
  92. */
  93. export function prepareProfile(name: string, userLayer = true): Profile {
  94. healProfilesModuleFallback(INSTALL_ANCHOR)
  95. const profile = loadProfile(NAME, name, INSTALL_ANCHOR, undefined, { userLayer })
  96. writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG)
  97. return profile
  98. }
  99. /** One profile's patch layers (application order) and the row index of its pre-flag composition. */
  100. interface ComposedProfile {
  101. profile: Profile
  102. /** Bundle layers concatenated — the part below the user layers on a live reload. */
  103. bundlePatches: PatchOptions[]
  104. /** The win32 shell platform layer (the base bundle's `windows.cordis.patch.yml`), between bundles and user layers. */
  105. windowsShellPatches: PatchOptions[]
  106. /** The home-level user layer (`$DSH_HOME/cordis.patch.yml`), applied after the profile's own. */
  107. homePatches: PatchOptions[]
  108. /** Layers above the user layers on a live reload: `--patch` overlays and the telemetry switch. */
  109. overlays: PatchOptions[]
  110. /**
  111. * id → row of the composed tree (bundles + user layers + overlays), for the
  112. * launcher's own row checks.
  113. */
  114. rows: ReadonlyMap<string, EntryOptions>
  115. }
  116. /** The full patch stack of one composed profile, in application order. */
  117. function allPatches(composed: ComposedProfile): PatchOptions[] {
  118. return [
  119. ...composed.bundlePatches,
  120. ...composed.windowsShellPatches,
  121. ...composed.profile.patches,
  122. ...composed.homePatches,
  123. ...composed.overlays,
  124. ]
  125. }
  126. /**
  127. * Load `name` and compose its effective patch stack: bundle layers in
  128. * `dsh.profile.bundles` order, the win32 shell platform layer (when the host
  129. * is Windows), the profile's user layer, the home-level user layer
  130. * (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply to
  131. * every profile, so it outranks the per-profile layer), `--patch` overlays,
  132. * then the telemetry switch.
  133. * @param name - the profile name.
  134. * @param patchFiles - `--patch` overlay paths, in argv order.
  135. * @returns the profile, its patch layers, and the composed row index.
  136. */
  137. function composeProfile(
  138. name: string,
  139. patchFiles: readonly string[],
  140. ): ComposedProfile {
  141. const profile = prepareProfile(name)
  142. const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? []
  143. const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)))
  144. const bundlePatches = profile.layers.flatMap(layer => layer.patches)
  145. const windowsShellPatches = resolveWindowsShellLayer(process.platform, profile.layers, NAME)?.patches ?? []
  146. const rows = new Map<string, EntryOptions>()
  147. for (const row of composeEntries([bundlePatches, windowsShellPatches, profile.patches, homePatches, overlays])) {
  148. if (typeof row.id === 'string') rows.set(row.id, row)
  149. }
  150. const composedOverlays = [...overlays]
  151. // Preset roots belong to every dsh composition that mounts the roster.
  152. if (rows.has('agent-presets')) {
  153. composedOverlays.push({
  154. id: 'agent-presets',
  155. config: {
  156. ...(rows.get('agent-presets')?.config ?? {}) as Record<string, unknown>,
  157. roots: [
  158. { path: SHIPPED_PRESET_ROOT, trust: 'system' },
  159. { path: dshHomePath(USER_PRESET_DIR), trust: 'user' },
  160. ],
  161. },
  162. })
  163. }
  164. const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
  165. if (telemetryPatch !== undefined) composedOverlays.push(telemetryPatch)
  166. return { profile, bundlePatches, windowsShellPatches, homePatches, overlays: composedOverlays, rows }
  167. }
  168. /** Options for {@link runProfile}. */
  169. export interface RunProfileOptions {
  170. /** This run's frozen environment snapshot, provided before any entry mounts. */
  171. environment: EnvironmentSnapshot
  172. /** The profile name to boot. */
  173. profile: string
  174. /** `--patch` overlay paths, in argv order. */
  175. patchFiles: readonly string[]
  176. /** The invocation's inner arguments, handed to the tree through `ctx.cmdlineArgs`. */
  177. args: readonly string[]
  178. }
  179. /** Re-throw setup failures unless this invocation's signal already owns shutdown. */
  180. function suppressSignalShutdownError(signal: AbortSignal, error: unknown): void {
  181. if (!signal.aborted) throw error
  182. }
  183. /**
  184. * Boot one profile invocation end to end and leave process lifetime to the
  185. * mounted plugins (or to a one-shot runner the composition mounts).
  186. * @param options - environment snapshot, profile name, overlays, and the booted app's own arguments.
  187. * @returns the settled root context and the shutdown controller.
  188. */
  189. export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> {
  190. const composed = composeProfile(options.profile, options.patchFiles)
  191. // A one-shot composition ends by itself, which changes what a signal means
  192. // and makes watching the user's patch layer pointless.
  193. const headlessRow = composed.rows.get(HEADLESS_ROW_ID)
  194. const oneShot = headlessRow !== undefined && headlessRow.disabled !== true
  195. const app: { current?: Context } = {}
  196. const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() })
  197. const signalShutdown = new AbortController()
  198. const interrupt = (code: number): void => {
  199. signalShutdown.abort()
  200. shutdown.interrupt(code)
  201. }
  202. // Signals own teardown throughout the startup window, not only after boot()
  203. // settles: an inserted provider can publish before sibling rows finish mounting.
  204. process.on('SIGTERM', () => { interrupt(oneShot ? 143 : 0) })
  205. process.on('SIGINT', () => { interrupt(130) })
  206. installFailLoud(NAME, process, async () => {
  207. await app.current?.fiber.dispose()
  208. })
  209. const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME)
  210. // Recomposition for the live user layers: bundle layers below, overlays
  211. // above, so a user edit can never displace them. Parsed app arguments are
  212. // not in here at all — they live in app-provided services that survive a
  213. // recomposition. BOTH
  214. // user files are re-read per generation (the HMR watcher hands us only the
  215. // changed file's patches, which one of the reads duplicates — fresh reads
  216. // keep the two watchers from stitching in each other's stale copy).
  217. // Fresh clones per generation: the include pushes `insert` rows into the
  218. // mounted tree BY REFERENCE and later id-targeted patches mutate those
  219. // objects in place. Reusing one parsed patch object across applications
  220. // would bake a user override into the bundle's in-memory insert row, so
  221. // removing the override could never revert the row to the bundle default.
  222. const composeLive = (): PatchOptions[] => structuredClone([
  223. ...composed.bundlePatches,
  224. ...composed.windowsShellPatches,
  225. ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [],
  226. ...loadOptionalPatches(NAME, homePatchPath()) ?? [],
  227. ...composed.overlays,
  228. ])
  229. // One-shot runs exit through the runner; watching would only hold the
  230. // process open after its exit request.
  231. const watchProfilePatch = !oneShot
  232. // Cloned for the same insert-aliasing reason as composeLive: the boot
  233. // application must not mutate the objects later reloads recompose from.
  234. const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), (hostCtx) => {
  235. app.current = hostCtx
  236. // Before any config-tree entry mounts, so plugins resolve all launch-time
  237. // environment values from the same immutable provenance snapshot.
  238. hostCtx.provide(DSH_ENVIRONMENT_KEY, options.environment)
  239. // The command line and bounded exit request are launcher facts available
  240. // to every app plugin that injects the argument snapshot.
  241. provideCmdline(hostCtx, {
  242. args: options.args,
  243. exit: code => void shutdown.shutdown(code),
  244. })
  245. if (oneShot) {
  246. const io: HeadlessIo = {
  247. stdout: process.stdout,
  248. stderr: process.stderr,
  249. exit: (code) => { void shutdown.shutdown(code) },
  250. }
  251. hostCtx.provide('headlessIo', io)
  252. }
  253. })
  254. app.current = ctx
  255. // A surface can dispose the whole tree while boot or this post-boot watcher
  256. // setup is still in flight. Loader presence and fiber state own
  257. // liveness; the local signal fact distinguishes that expected exit race
  258. // from a real HMR error.
  259. if (watchProfilePatch
  260. && !signalShutdown.signal.aborted
  261. && ctx.fiber.state === FiberState.ACTIVE
  262. && ctx.get('loader') !== undefined) {
  263. try {
  264. // Config-only HMR for the live profile patch layer: the web bundle
  265. // disables the shared module-reload `hmr` row (its reload lifecycle is
  266. // untested), so when the composition leaves no HMR service, mount a
  267. // watch-only instance with no module roots — cordis.patch.yml edits stay
  268. // live on every long-lived surface. A silent skip would break the
  269. // documented hot-reload contract. HMR injects the timer service, which a
  270. // bare custom profile may not mount either.
  271. if (ctx.get('hmr') === undefined) {
  272. if (ctx.get('timer') === undefined) {
  273. await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-timer' })
  274. }
  275. await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-hmr', config: { root: [] } })
  276. }
  277. await watchUserPatches(ctx, {
  278. binName: NAME,
  279. filename: composed.profile.patchPath,
  280. compose: composeLive,
  281. })
  282. await watchUserPatches(ctx, {
  283. binName: NAME,
  284. filename: homePatchPath(),
  285. compose: composeLive,
  286. })
  287. } catch (error) {
  288. suppressSignalShutdownError(signalShutdown.signal, error)
  289. }
  290. }
  291. return { ctx, shutdown }
  292. }