profile-runtime.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. /**
  2. * The `profileRuntime` service: the booted profile's facts and the one
  3. * recomposition entry point every live change to the host tree goes through —
  4. * user patch-file reloads, bundle enable/disable, and hot install. A
  5. * recomposition composes a candidate stack, applies it through the root
  6. * include, and publishes the profile, the stack's id ownership, and its
  7. * conflicts only once the include accepted it; a rejected update leaves the
  8. * committed composition in place, which describes the tree still running.
  9. * Recompositions run one at a time: each reads the committed composition
  10. * only after the previous one settled, so a watcher firing while a bundle is
  11. * being enabled recomposes the enabled tree instead of the one before it.
  12. * Before this service the composition closure lived in the launcher and
  13. * bundle layers were frozen at boot, so nothing in the tree could learn which
  14. * profile it ran in or add a layer while running.
  15. * @module @deepseek-ai/dsh-app-boot/profile-runtime
  16. */
  17. import { Context, Service } from '@deepseek-ai/cordis'
  18. import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
  19. import type Include from '@deepseek-ai/cordis-plugin-include'
  20. import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
  21. import type { ProfilePatchReload } from '@deepseek-ai/dsh-package-manifest'
  22. import type { ComposedStack, RowConflict } from './compose-stack.ts'
  23. import type { BundleTrust, Profile, ProfileLayer } from './profile.ts'
  24. declare module '@deepseek-ai/cordis' {
  25. interface Context {
  26. /** The booted profile's facts and recomposition; provided by the profile launcher. */
  27. profileRuntime: ProfileRuntime
  28. }
  29. }
  30. /** Where one mounted row came from. */
  31. export interface RowOrigin {
  32. /** Who supplied the layer that inserted the row. */
  33. readonly trust: BundleTrust
  34. /** The bundle package that inserted the row. */
  35. readonly packageName: string
  36. /** The package's version, when its manifest declares one. */
  37. readonly version?: string
  38. }
  39. /** What the launcher hands the service. */
  40. export interface ProfileRuntimeOptions {
  41. /** The profile as booted. */
  42. profile: Profile
  43. /** Absolute path of the dsh app's package.json: the first resolution anchor for profile packages. */
  44. installAnchor: string
  45. /** The stack the tree booted with, as `compose` rendered it for `profile`. */
  46. stack: ComposedStack
  47. /** Re-read the profile from disk, re-resolving its bundle layers. */
  48. loadProfile: () => Profile
  49. /** The complete patch stack for a profile — bundle layers, user layers, overlays — with the rows it left out. */
  50. compose: (profile: Profile) => ComposedStack
  51. /** The root Include entry, once mounted. */
  52. rootEntry: () => Entry | undefined
  53. /** The user patch layers as they stand on disk (profile file, then home file). */
  54. readUserPatches: () => PatchOptions[]
  55. }
  56. /** The profile and stack the tree runs, published together once the root include accepted the stack. */
  57. interface CommittedComposition {
  58. readonly profile: Profile
  59. readonly stack: ComposedStack
  60. }
  61. /** Facts and recomposition of the booted profile. */
  62. export class ProfileRuntime extends Service {
  63. private committed: CommittedComposition
  64. /** The recomposition in flight, or a settled promise; the next one chains behind it. */
  65. private queue: Promise<void> = Promise.resolve()
  66. constructor(ctx: Context, private readonly options: ProfileRuntimeOptions) {
  67. super(ctx, 'profileRuntime')
  68. this.committed = { profile: options.profile, stack: options.stack }
  69. }
  70. /** The profile as last composed; re-read by a `recompose({ reloadBundles: true })` the include accepted. */
  71. get current(): Profile {
  72. return this.committed.profile
  73. }
  74. /** The profile name (`dsh --profile <name>`). */
  75. get profileName(): string {
  76. return this.committed.profile.name
  77. }
  78. /** Absolute path of the dsh app's package.json, the anchor profile packages resolve from. */
  79. get installAnchor(): string {
  80. return this.options.installAnchor
  81. }
  82. /** Absolute profile directory. */
  83. get dir(): string {
  84. return this.committed.profile.dir
  85. }
  86. /** Absolute path of the profile's own user patch file. */
  87. get patchPath(): string {
  88. return this.committed.profile.patchPath
  89. }
  90. /** Whether user patch files reload while the profile runs. */
  91. get patchReload(): ProfilePatchReload {
  92. return this.committed.profile.patchReload
  93. }
  94. /** The bundle layers currently composed, in application order. */
  95. get layers(): readonly ProfileLayer[] {
  96. return this.committed.profile.layers
  97. }
  98. /** The rows the current composition left out: bundles skipped over a row id and user inserts of taken ids. */
  99. get conflicts(): readonly RowConflict[] {
  100. return this.committed.stack.conflicts
  101. }
  102. /**
  103. * Where one mounted row came from.
  104. * @param rowId - the row's id as the composition declares it.
  105. * @returns the origin, or undefined for a row no bundle layer owns (a user or overlay row, or a bundle left out by a conflict).
  106. */
  107. originOf(rowId: string): RowOrigin | undefined {
  108. const layer = this.committed.stack.owners.get(rowId)
  109. if (layer === undefined) return undefined
  110. return {
  111. trust: layer.trust,
  112. packageName: layer.packageName,
  113. ...layer.version === undefined ? {} : { version: layer.version },
  114. }
  115. }
  116. /**
  117. * Row ids the user patch layers disable with a literal `disabled: true`.
  118. * A `!!js` gate in a user file stays an expression node when read from
  119. * disk, so it is a condition, not a user decision, and is left to the
  120. * composition.
  121. * @returns the ids, re-read from disk on every call.
  122. */
  123. userDisabledRowIds(): Set<string> {
  124. const ids = new Set<string>()
  125. for (const patch of this.options.readUserPatches()) {
  126. if (patch.insert !== undefined || patch.id === undefined) continue
  127. if (patch.disabled === true) ids.add(patch.id)
  128. }
  129. return ids
  130. }
  131. /**
  132. * Recompose the host tree from the profile's layers and the user patch files
  133. * as they stand now. The root Include re-applies the stack transactionally:
  134. * a row whose options changed is updated in place, a row that appeared is
  135. * created, a row that vanished is disposed, and a failure rolls the whole
  136. * update back with the previous tree still running. The candidate profile,
  137. * its ownership, and its conflicts become the committed composition only
  138. * once the update holds; until then, and after a rejection, `current`,
  139. * `layers`, `originOf`, and `conflicts` keep describing the running tree.
  140. * Calls queue: one that arrives while another is in flight starts after it
  141. * settled and reads what it committed. A rejection is that call's outcome
  142. * alone and does not stop the ones behind it.
  143. * @param options - `reloadBundles` re-reads the profile manifest first, so a
  144. * bundle enabled or installed since boot joins the stack.
  145. * @throws when the root include is not mounted, or the Loader rejected the update.
  146. */
  147. async recompose(options: { reloadBundles?: boolean } = {}): Promise<void> {
  148. const run = this.queue.then(() => this.recomposeNow(options))
  149. this.queue = run.then(() => undefined, () => undefined)
  150. return run
  151. }
  152. private async recomposeNow(options: { reloadBundles?: boolean }): Promise<void> {
  153. const entry = this.options.rootEntry()
  154. if (entry === undefined) throw new Error('profileRuntime: the root include is not mounted')
  155. const profile = options.reloadBundles === true ? this.options.loadProfile() : this.committed.profile
  156. const stack = this.options.compose(profile)
  157. const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config
  158. await entry.update({
  159. config: {
  160. ...includeConfig,
  161. patches: stack.patches,
  162. },
  163. })
  164. this.committed = { profile, stack }
  165. }
  166. }