profile-boot.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  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, apply its selected patch-reload
  6. * lifecycle, 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. composeExternalLayer,
  23. healProfilesModuleFallback,
  24. installFailLoud,
  25. installRuntimeGuards,
  26. loadOptionalPatches,
  27. loadOverlayPatches,
  28. loadProfile,
  29. PROFILE_PATCH_FILENAME,
  30. ProfileRuntime,
  31. rootIncludeEntry,
  32. warnNestedFiberFailures,
  33. watchUserPatches,
  34. type Profile,
  35. type ProfileLayer,
  36. } from '@deepseek-ai/dsh-app-boot'
  37. import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
  38. import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
  39. import { DSH_LAUNCH_ENVIRONMENT_KEY, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
  40. import { provideCmdline, type AppReady } from '@deepseek-ai/dsh-cmdline'
  41. import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'
  42. const NAME = 'dsh'
  43. /** Launcher-owned readiness signal committed only after boot and host setup succeed. */
  44. function createAppReady(): { service: AppReady; commit(): void } {
  45. let ready = false
  46. const listeners = new Set<() => void>()
  47. return {
  48. service: {
  49. onReady(listener) {
  50. if (ready) {
  51. listener()
  52. return () => {}
  53. }
  54. listeners.add(listener)
  55. return () => { listeners.delete(listener) }
  56. },
  57. },
  58. commit() {
  59. if (ready) return
  60. ready = true
  61. for (const listener of [...listeners]) listener()
  62. listeners.clear()
  63. },
  64. }
  65. }
  66. /**
  67. * The home-level user patch layer (`$DSH_HOME/cordis.patch.yml`), applied
  68. * over every profile's own layer. Resolved per call, not at module load:
  69. * `$DSH_HOME` may be set by the test or launcher after import.
  70. * @returns the absolute patch-file path.
  71. */
  72. export function homePatchPath(): string {
  73. return join(resolveDshHome(), PROFILE_PATCH_FILENAME)
  74. }
  75. /** Absolute path of this dsh installation's package.json (both anchors: src/ and lib/ sit one level under apps/cli). */
  76. export const INSTALL_ANCHOR = fileURLToPath(new URL('../package.json', import.meta.url))
  77. /** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets. */
  78. const TELEMETRY_ROW_ID = 'session-telemetry-otel'
  79. /** The empty root entry list every profile tree patches over. */
  80. const PROFILE_ROOT_CONFIG = `# dsh profile root — an empty entry list. The tree is composed as patches:
  81. # each bundle in package.json's dsh.profile.bundles, then cordis.patch.yml, then any
  82. # --patch overlays. Edit cordis.patch.yml, not this file.
  83. []
  84. `
  85. /** Root config filename inside a profile directory. */
  86. export const PROFILE_ROOT_FILENAME = 'cordis.yml'
  87. /**
  88. * Resolve the telemetry opt-out switch into its boot patch. ANY non-empty
  89. * value (including `'0'`/`'false'`) disables: a privacy switch prefers
  90. * off-by-mistake over on-by-mistake. A composition without the telemetry row
  91. * exports nothing, so the switch is then trivially satisfied and no patch is
  92. * generated — custom profiles need not mount telemetry to run with the
  93. * switch set.
  94. * @param disabledEnv - the raw `DSH_TELEMETRY_DISABLED` value (`undefined` when unset).
  95. * @param hasRow - whether the composition carries the telemetry row.
  96. * @returns the disable patch, or `undefined` when no hard-disable patch is required.
  97. */
  98. export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined {
  99. if ((disabledEnv ?? '') === '' || !hasRow) return undefined
  100. return { id: TELEMETRY_ROW_ID, disabled: true }
  101. }
  102. /**
  103. * Load a resolved profile for `name` and (re)write the empty root config. The
  104. * root is always rewritten: the whole composition is patch layers, and the
  105. * vendored Loader's tree write-back (a plugin self-disposing persists the
  106. * current tree) can bake composed rows into this file — which would duplicate
  107. * every bundle insert on the next boot. The file exists on disk only because
  108. * the Loader needs a real include root to anchor `baseUrl` at the profile
  109. * directory (the config dump anchors on the same file, so both compose over
  110. * the identical base).
  111. * @param name - the profile name.
  112. * @param userLayer - `false` skips parsing `cordis.patch.yml` (the default dump).
  113. * @returns the loaded profile.
  114. */
  115. export function prepareProfile(name: string, userLayer = true): Profile {
  116. const profile = loadProfile(NAME, name, INSTALL_ANCHOR, undefined, { userLayer })
  117. writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG)
  118. return profile
  119. }
  120. /** One profile's patch layers, in application order. */
  121. interface ComposedProfile {
  122. profile: Profile
  123. /** Bundle layers concatenated — the part below the user layers on a live reload. */
  124. bundlePatches: PatchOptions[]
  125. /** The home-level user layer (`$DSH_HOME/cordis.patch.yml`), applied after the profile's own. */
  126. homePatches: PatchOptions[]
  127. /** Layers above the user layers on a live reload: `--patch` overlays and the telemetry switch. */
  128. overlays: PatchOptions[]
  129. }
  130. /** The full patch stack of one composed profile, in application order. */
  131. function allPatches(composed: ComposedProfile): PatchOptions[] {
  132. return [
  133. ...composed.bundlePatches,
  134. ...composed.profile.patches,
  135. ...composed.homePatches,
  136. ...composed.overlays,
  137. ]
  138. }
  139. /**
  140. * The patches one bundle layer contributes. A built-in layer, or an external
  141. * layer the profile stages at boot, mounts its patches as written; every other
  142. * external layer mounts as one contained, id-prefixed group.
  143. * @param layer - the resolved layer.
  144. * @returns the layer's patches in application order.
  145. */
  146. export function bundleLayerPatches(layer: ProfileLayer): PatchOptions[] {
  147. if (layer.trust === 'external' && layer.stage === 'runtime') return composeExternalLayer(layer).patches
  148. return layer.patches
  149. }
  150. /**
  151. * Load `name` and compose its effective patch stack: bundle layers in
  152. * `dsh.profile.bundles` order (a base-backed profile gets the base bundle's
  153. * platform-gated shell rows), the profile's user layer, the home-level user
  154. * layer (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply
  155. * to every profile, so it outranks the per-profile layer), `--patch` overlays,
  156. * then the telemetry switch.
  157. * @param name - the profile name.
  158. * @param patchFiles - `--patch` overlay paths, in argv order.
  159. * @returns the profile and its patch layers.
  160. */
  161. async function composeProfile(
  162. name: string,
  163. patchFiles: readonly string[],
  164. ): Promise<ComposedProfile> {
  165. const profile = prepareProfile(name)
  166. await healProfilesModuleFallback({ installAnchor: INSTALL_ANCHOR, profile })
  167. const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? []
  168. const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)))
  169. const bundlePatches = profile.layers.flatMap(bundleLayerPatches)
  170. const rows = new Map<string, EntryOptions>()
  171. for (const row of composeEntries([bundlePatches, profile.patches, homePatches, overlays])) {
  172. if (typeof row.id === 'string') rows.set(row.id, row)
  173. }
  174. const composedOverlays = [...overlays]
  175. const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
  176. if (telemetryPatch !== undefined) composedOverlays.push(telemetryPatch)
  177. return { profile, bundlePatches, homePatches, overlays: composedOverlays }
  178. }
  179. /** Options for {@link runProfile}. */
  180. export interface RunProfileOptions {
  181. /** This run's frozen environment snapshot, provided before any entry mounts. */
  182. environment: LaunchEnvironmentSnapshot
  183. /** The profile name to boot. */
  184. profile: string
  185. /** `--patch` overlay paths, in argv order. */
  186. patchFiles: readonly string[]
  187. /** The invocation's inner arguments, handed to the tree through `ctx.cmdlineArgs`. */
  188. args: readonly string[]
  189. }
  190. /**
  191. * Re-throw a watcher-setup failure unless a shutdown already owns the tree:
  192. * a signal aborted this invocation, or an app requested exit (`ctx.appExit`
  193. * from a fast one-shot) and the root's disposal rejected the in-flight setup
  194. * await. Either way the failure describes a tree that is exiting as asked,
  195. * not a broken watch.
  196. * @param ctx - the booted root context.
  197. * @param signal - this invocation's signal-shutdown fact.
  198. * @param error - the setup failure.
  199. */
  200. function suppressShutdownError(ctx: Context, signal: AbortSignal, error: unknown): void {
  201. if (signal.aborted) return
  202. if (ctx.fiber.state !== FiberState.ACTIVE || ctx.get('loader') === undefined) return
  203. throw error
  204. }
  205. /**
  206. * Boot one profile invocation end to end and leave process lifetime to the
  207. * mounted plugins (or to a one-shot runner the composition mounts).
  208. * @param options - environment snapshot, profile name, overlays, and the booted app's own arguments.
  209. * @returns the settled root context and the shutdown controller.
  210. */
  211. export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> {
  212. // Before the first plugin mounts and before anything can issue a request: Node's fetch ignores the
  213. // proxy environment on its own, so every profile would otherwise connect directly. Resolving from
  214. // the launcher's snapshot — not `process.env` — is what lets a proxy declared in a `.env` layer
  215. // work, which the NODE_USE_ENV_PROXY flag cannot do because Node samples the environment at start.
  216. const disposeProxy = await installProxyFromEnvironment(
  217. options.environment,
  218. (message) => { process.stderr.write(`${NAME}: ${message}\n`) },
  219. )
  220. const composed = await composeProfile(options.profile, options.patchFiles)
  221. const app: { current?: Context; runtime?: ProfileRuntime } = {}
  222. const appReady = createAppReady()
  223. let uninstallRuntimeGuards = (): void => {}
  224. const shutdown = createProcessShutdown(async () => {
  225. uninstallRuntimeGuards()
  226. await app.current?.fiber.dispose()
  227. await disposeProxy()
  228. })
  229. const signalShutdown = new AbortController()
  230. const interrupt = (code: number): void => {
  231. signalShutdown.abort()
  232. shutdown.interrupt(code)
  233. }
  234. // Signals own teardown throughout the startup window, not only after boot()
  235. // settles: an inserted provider can publish before sibling rows finish mounting.
  236. // SIGTERM is a supervisor's ordinary stop request and exits 0 on every
  237. // surface — the launcher does not know whether the app considered its work
  238. // complete; SIGINT is a user interrupt and reports 130.
  239. process.on('SIGTERM', () => { interrupt(0) })
  240. process.on('SIGINT', () => { interrupt(130) })
  241. const uninstallFailLoud = installFailLoud(NAME, process, async () => {
  242. await app.current?.fiber.dispose()
  243. })
  244. const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME)
  245. // Recomposition for the live user layers: bundle layers below, overlays
  246. // above, so a user edit can never displace them. Parsed app arguments are
  247. // not in here at all — they live in app-provided services that survive a
  248. // recomposition. BOTH
  249. // user files are re-read per generation (the HMR watcher hands us only the
  250. // changed file's patches, which one of the reads duplicates — fresh reads
  251. // keep the two watchers from stitching in each other's stale copy).
  252. // Fresh clones per generation: the include pushes `insert` rows into the
  253. // mounted tree BY REFERENCE and later id-targeted patches mutate those
  254. // objects in place. Reusing one parsed patch object across applications
  255. // would bake a user override into the bundle's in-memory insert row, so
  256. // removing the override could never revert the row to the bundle default.
  257. const composeFor = (profile: Profile): PatchOptions[] => structuredClone([
  258. ...profile.layers.flatMap(bundleLayerPatches),
  259. ...loadOptionalPatches(NAME, profile.patchPath) ?? [],
  260. ...loadOptionalPatches(NAME, homePatchPath()) ?? [],
  261. ...composed.overlays,
  262. ])
  263. // Once the profile runtime is mounted its profile is the current one: a
  264. // bundle enabled since boot lives only there.
  265. const composeLive = (): PatchOptions[] => composeFor(app.runtime?.current ?? composed.profile)
  266. // Cloned for the same insert-aliasing reason as composeLive: the boot
  267. // application must not mutate the objects later reloads recompose from.
  268. const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), (hostCtx) => {
  269. app.current = hostCtx
  270. // Before any config-tree entry mounts, so plugins resolve all launch-time
  271. // environment values from the same immutable provenance snapshot.
  272. hostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.environment)
  273. // The command line and bounded exit request are launcher facts available
  274. // to every app plugin that injects the argument snapshot.
  275. provideCmdline(hostCtx, {
  276. args: options.args,
  277. exit: code => void shutdown.shutdown(code),
  278. ready: appReady.service,
  279. })
  280. })
  281. app.current = ctx
  282. // The tree is up: a later unhandled rejection is a plugin's stray
  283. // continuation, not a load failure, and must not take every session down.
  284. uninstallFailLoud()
  285. uninstallRuntimeGuards = installRuntimeGuards(NAME, (line) => { process.stderr.write(`${line}\n`) })
  286. if (!signalShutdown.signal.aborted && ctx.fiber.state === FiberState.ACTIVE && ctx.get('loader') !== undefined) {
  287. warnNestedFiberFailures(ctx, NAME, (line) => { process.stderr.write(`${line}\n`) })
  288. await ctx.plugin(ProfileRuntime, {
  289. profile: composed.profile,
  290. loadProfile: () => prepareProfile(options.profile),
  291. compose: composeFor,
  292. rootEntry: () => rootIncludeEntry(ctx),
  293. readUserPatches: () => [
  294. ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [],
  295. ...loadOptionalPatches(NAME, homePatchPath()) ?? [],
  296. ],
  297. })
  298. const runtime = ctx.get('profileRuntime')
  299. if (runtime !== undefined) app.runtime = runtime
  300. }
  301. // A live-reload profile can dispose the whole tree while post-boot watcher
  302. // setup is in flight — a signal or appExit. Loader presence and fiber state
  303. // own liveness; the initial check skips a tree that already exited, and the
  304. // catch below re-checks for an exit that landed mid-setup. Startup-frozen
  305. // profiles apply every user layer above but install no HMR fallback or watcher.
  306. if (composed.profile.patchReload === 'live'
  307. && !signalShutdown.signal.aborted
  308. && ctx.fiber.state === FiberState.ACTIVE
  309. && ctx.get('loader') !== undefined) {
  310. try {
  311. // Config-only HMR for the live profile patch layer: dsh-base disables
  312. // module reload by default, so when no profile explicitly enabled that
  313. // service, mount a watch-only instance with no module roots —
  314. // cordis.patch.yml edits stay live without replacing source modules. A
  315. // silent skip would break the documented reload contract. HMR injects
  316. // the timer service, which a bare custom profile may not mount either.
  317. if (ctx.get('hmr') === undefined) {
  318. if (ctx.get('timer') === undefined) {
  319. await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-timer' })
  320. }
  321. await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-hmr', config: { root: [] } })
  322. }
  323. await watchUserPatches(ctx, {
  324. binName: NAME,
  325. filename: composed.profile.patchPath,
  326. compose: composeLive,
  327. })
  328. await watchUserPatches(ctx, {
  329. binName: NAME,
  330. filename: homePatchPath(),
  331. compose: composeLive,
  332. })
  333. } catch (error) {
  334. suppressShutdownError(ctx, signalShutdown.signal, error)
  335. }
  336. }
  337. if (!signalShutdown.signal.aborted
  338. && ctx.fiber.state === FiberState.ACTIVE
  339. && ctx.get('loader') !== undefined) {
  340. appReady.commit()
  341. }
  342. return { ctx, shutdown }
  343. }