@camstack/server 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/{src/agent-status-page.ts → dist/agent-status-page.js} +30 -45
  2. package/dist/api/addon-upload.js +441 -0
  3. package/dist/api/addons-custom.router.js +91 -0
  4. package/dist/api/auth-whoami.js +55 -0
  5. package/dist/api/bridge-addons.router.js +109 -0
  6. package/dist/api/capabilities.router.js +229 -0
  7. package/dist/api/core/addon-settings.router.js +117 -0
  8. package/dist/api/core/agents.router.js +73 -0
  9. package/dist/api/core/auth.router.js +286 -0
  10. package/dist/api/core/bulk-update-coordinator.js +229 -0
  11. package/dist/api/core/cap-providers.js +1124 -0
  12. package/dist/api/core/capabilities.router.js +138 -0
  13. package/dist/api/core/collection-preference.js +17 -0
  14. package/dist/api/core/event-bus-proxy.router.js +45 -0
  15. package/dist/api/core/hwaccel.router.js +91 -0
  16. package/dist/api/core/live-events.router.js +61 -0
  17. package/dist/api/core/logs.router.js +172 -0
  18. package/dist/api/core/notifications.router.js +67 -0
  19. package/dist/api/core/repl.router.js +35 -0
  20. package/dist/api/core/settings-backend.router.js +121 -0
  21. package/dist/api/core/stream-probe.router.js +58 -0
  22. package/dist/api/core/system-events.router.js +100 -0
  23. package/dist/api/health/health.routes.js +68 -0
  24. package/{src/api/oauth2/consent-page.ts → dist/api/oauth2/consent-page.js} +11 -20
  25. package/dist/api/oauth2/oauth2-routes.js +219 -0
  26. package/dist/api/trpc/cap-mount-helpers.js +194 -0
  27. package/dist/api/trpc/cap-route-error-formatter.js +133 -0
  28. package/dist/api/trpc/client-ip.js +147 -0
  29. package/dist/api/trpc/core-cap-bridge.js +115 -0
  30. package/dist/api/trpc/generated-cap-mounts.js +388 -0
  31. package/dist/api/trpc/generated-cap-routers.js +7635 -0
  32. package/dist/api/trpc/scope-access.js +93 -0
  33. package/dist/api/trpc/trpc.context.js +184 -0
  34. package/dist/api/trpc/trpc.middleware.js +139 -0
  35. package/dist/api/trpc/trpc.router.js +188 -0
  36. package/dist/auth/session-cookie.js +47 -0
  37. package/dist/boot/boot-config.js +241 -0
  38. package/dist/boot/integration-id-backfill.js +76 -0
  39. package/dist/boot/post-boot.service.js +85 -0
  40. package/dist/core/addon/addon-call-gateway.js +99 -0
  41. package/dist/core/addon/addon-package.service.js +1560 -0
  42. package/dist/core/addon/addon-registry.service.js +2739 -0
  43. package/{src/core/addon/addon-row-manifest.ts → dist/core/addon/addon-row-manifest.js} +5 -5
  44. package/dist/core/addon/addon-search.service.js +62 -0
  45. package/dist/core/addon/addon-settings-provider.js +102 -0
  46. package/dist/core/addon/addon.tokens.js +5 -0
  47. package/dist/core/addon-bridge/addon-bridge.service.js +145 -0
  48. package/dist/core/addon-pages/addon-pages.service.js +107 -0
  49. package/dist/core/addon-widgets/addon-widgets.service.js +120 -0
  50. package/dist/core/agent/agent-registry.service.js +477 -0
  51. package/dist/core/auth/auth.service.js +10 -0
  52. package/dist/core/capability/capability.service.js +58 -0
  53. package/dist/core/config/config.schema.js +7 -0
  54. package/dist/core/config/config.service.js +10 -0
  55. package/dist/core/events/event-bus.service.js +83 -0
  56. package/dist/core/feature/feature.service.js +10 -0
  57. package/dist/core/lifecycle/lifecycle-state-machine.js +6 -0
  58. package/dist/core/logging/log-ring-buffer.js +6 -0
  59. package/dist/core/logging/logging.service.js +130 -0
  60. package/dist/core/logging/scoped-logger.js +6 -0
  61. package/dist/core/moleculer/cap-call-fn.js +50 -0
  62. package/dist/core/moleculer/cap-route-authority.js +122 -0
  63. package/dist/core/moleculer/moleculer.service.js +898 -0
  64. package/dist/core/network/network-quality.service.js +7 -0
  65. package/dist/core/notification/notification-wrapper.service.js +33 -0
  66. package/dist/core/notification/toast-wrapper.service.js +25 -0
  67. package/dist/core/provider/provider.tokens.js +4 -0
  68. package/dist/core/repl/repl-engine.service.js +140 -0
  69. package/dist/core/storage/fs-storage-backend.js +6 -0
  70. package/dist/core/storage/storage-location-manager.js +6 -0
  71. package/dist/core/storage/storage.service.js +7 -0
  72. package/dist/core/streaming/stream-probe.service.js +209 -0
  73. package/dist/core/topology/topology-emitter.service.js +106 -0
  74. package/dist/launcher.js +325 -0
  75. package/dist/main.js +1098 -0
  76. package/dist/manual-boot.js +227 -0
  77. package/package.json +5 -1
  78. package/src/__tests__/addon-install-e2e.test.ts +0 -74
  79. package/src/__tests__/addon-pages-e2e.test.ts +0 -200
  80. package/src/__tests__/addon-route-session.test.ts +0 -17
  81. package/src/__tests__/addon-settings-router.spec.ts +0 -67
  82. package/src/__tests__/addon-upload.spec.ts +0 -475
  83. package/src/__tests__/agent-registry.spec.ts +0 -179
  84. package/src/__tests__/agent-status-page.spec.ts +0 -82
  85. package/src/__tests__/auth-session-cookie.test.ts +0 -48
  86. package/src/__tests__/bulk-update-coordinator.spec.ts +0 -303
  87. package/src/__tests__/cap-ownership-authority.spec.ts +0 -431
  88. package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +0 -206
  89. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +0 -37
  90. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +0 -110
  91. package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +0 -292
  92. package/src/__tests__/cap-providers-bulk-update.spec.ts +0 -408
  93. package/src/__tests__/cap-route-adapter.spec.ts +0 -302
  94. package/src/__tests__/cap-routers/_meta.spec.ts +0 -199
  95. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +0 -115
  96. package/src/__tests__/cap-routers/broker-routing.router.spec.ts +0 -177
  97. package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +0 -125
  98. package/src/__tests__/cap-routers/capabilities-node.spec.ts +0 -68
  99. package/src/__tests__/cap-routers/device-link-overlay.spec.ts +0 -137
  100. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +0 -194
  101. package/src/__tests__/cap-routers/harness.ts +0 -163
  102. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +0 -133
  103. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +0 -64
  104. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +0 -159
  105. package/src/__tests__/cap-routers/settings-store.router.spec.ts +0 -291
  106. package/src/__tests__/capability-e2e.test.ts +0 -384
  107. package/src/__tests__/cli-e2e.test.ts +0 -150
  108. package/src/__tests__/core-cap-bridge.spec.ts +0 -91
  109. package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +0 -40
  110. package/src/__tests__/device-settings-contribution-dispatch.spec.ts +0 -280
  111. package/src/__tests__/embedded-deps-e2e.test.ts +0 -125
  112. package/src/__tests__/event-bus-proxy-router.spec.ts +0 -75
  113. package/src/__tests__/fixtures/mock-analysis-addon-a.ts +0 -37
  114. package/src/__tests__/fixtures/mock-analysis-addon-b.ts +0 -37
  115. package/src/__tests__/fixtures/mock-log-addon.ts +0 -37
  116. package/src/__tests__/fixtures/mock-storage-addon.ts +0 -40
  117. package/src/__tests__/framework-allowlist.spec.ts +0 -96
  118. package/src/__tests__/framework-installer-defer-restart.spec.ts +0 -165
  119. package/src/__tests__/https-e2e.test.ts +0 -124
  120. package/src/__tests__/lifecycle-e2e.test.ts +0 -189
  121. package/src/__tests__/live-events-subscription.spec.ts +0 -149
  122. package/src/__tests__/moleculer/uds-readiness.spec.ts +0 -150
  123. package/src/__tests__/moleculer/uds-topology.spec.ts +0 -418
  124. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +0 -383
  125. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +0 -273
  126. package/src/__tests__/native-cap-route.spec.ts +0 -427
  127. package/src/__tests__/oauth2-account-linking.spec.ts +0 -867
  128. package/src/__tests__/post-boot-restart.spec.ts +0 -161
  129. package/src/__tests__/singleton-contention.test.ts +0 -499
  130. package/src/__tests__/streaming-diagnostic.test.ts +0 -615
  131. package/src/__tests__/streaming-scale.test.ts +0 -314
  132. package/src/__tests__/uds-addon-call-wiring.spec.ts +0 -242
  133. package/src/__tests__/uds-log-ingest.spec.ts +0 -183
  134. package/src/api/__tests__/addons-custom.spec.ts +0 -148
  135. package/src/api/__tests__/capabilities.router.test.ts +0 -56
  136. package/src/api/addon-upload.ts +0 -529
  137. package/src/api/addons-custom.router.ts +0 -101
  138. package/src/api/auth-whoami.ts +0 -101
  139. package/src/api/bridge-addons.router.ts +0 -122
  140. package/src/api/capabilities.router.ts +0 -265
  141. package/src/api/core/__tests__/auth-router-totp.spec.ts +0 -297
  142. package/src/api/core/__tests__/integration-markers.spec.ts +0 -10
  143. package/src/api/core/addon-settings.router.ts +0 -127
  144. package/src/api/core/agents.router.ts +0 -86
  145. package/src/api/core/auth.router.ts +0 -322
  146. package/src/api/core/bulk-update-coordinator.ts +0 -305
  147. package/src/api/core/cap-providers.ts +0 -1339
  148. package/src/api/core/capabilities.router.ts +0 -149
  149. package/src/api/core/collection-preference.ts +0 -40
  150. package/src/api/core/event-bus-proxy.router.ts +0 -45
  151. package/src/api/core/hwaccel.router.ts +0 -108
  152. package/src/api/core/live-events.router.ts +0 -67
  153. package/src/api/core/logs.router.ts +0 -195
  154. package/src/api/core/notifications.router.ts +0 -66
  155. package/src/api/core/repl.router.ts +0 -39
  156. package/src/api/core/settings-backend.router.ts +0 -140
  157. package/src/api/core/stream-probe.router.ts +0 -57
  158. package/src/api/core/system-events.router.ts +0 -125
  159. package/src/api/health/health.routes.ts +0 -117
  160. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +0 -62
  161. package/src/api/oauth2/oauth2-routes.ts +0 -281
  162. package/src/api/trpc/__tests__/client-ip.spec.ts +0 -146
  163. package/src/api/trpc/__tests__/scope-access-device.spec.ts +0 -268
  164. package/src/api/trpc/__tests__/scope-access.spec.ts +0 -102
  165. package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +0 -136
  166. package/src/api/trpc/cap-mount-helpers.ts +0 -245
  167. package/src/api/trpc/cap-route-error-formatter.ts +0 -171
  168. package/src/api/trpc/client-ip.ts +0 -147
  169. package/src/api/trpc/core-cap-bridge.ts +0 -154
  170. package/src/api/trpc/generated-cap-mounts.ts +0 -1240
  171. package/src/api/trpc/generated-cap-routers.ts +0 -11523
  172. package/src/api/trpc/scope-access.ts +0 -110
  173. package/src/api/trpc/trpc.context.ts +0 -258
  174. package/src/api/trpc/trpc.middleware.ts +0 -146
  175. package/src/api/trpc/trpc.router.ts +0 -389
  176. package/src/auth/session-cookie.ts +0 -54
  177. package/src/boot/__tests__/integration-id-backfill.spec.ts +0 -131
  178. package/src/boot/boot-config.ts +0 -259
  179. package/src/boot/integration-id-backfill.ts +0 -109
  180. package/src/boot/post-boot.service.ts +0 -105
  181. package/src/core/addon/__tests__/addon-registry-capability.test.ts +0 -62
  182. package/src/core/addon/__tests__/addon-row-manifest.spec.ts +0 -62
  183. package/src/core/addon/addon-call-gateway.ts +0 -171
  184. package/src/core/addon/addon-package.service.ts +0 -1787
  185. package/src/core/addon/addon-registry.service.ts +0 -3130
  186. package/src/core/addon/addon-search.service.ts +0 -91
  187. package/src/core/addon/addon-settings-provider.ts +0 -220
  188. package/src/core/addon/addon.tokens.ts +0 -2
  189. package/src/core/addon-bridge/addon-bridge.service.ts +0 -130
  190. package/src/core/addon-pages/addon-pages.service.spec.ts +0 -117
  191. package/src/core/addon-pages/addon-pages.service.ts +0 -82
  192. package/src/core/addon-widgets/addon-widgets.service.ts +0 -95
  193. package/src/core/agent/agent-registry.service.ts +0 -529
  194. package/src/core/auth/auth.service.spec.ts +0 -86
  195. package/src/core/auth/auth.service.ts +0 -8
  196. package/src/core/capability/capability.service.ts +0 -66
  197. package/src/core/config/config.schema.ts +0 -3
  198. package/src/core/config/config.service.spec.ts +0 -175
  199. package/src/core/config/config.service.ts +0 -7
  200. package/src/core/events/event-bus.service.spec.ts +0 -235
  201. package/src/core/events/event-bus.service.ts +0 -89
  202. package/src/core/feature/feature.service.spec.ts +0 -99
  203. package/src/core/feature/feature.service.ts +0 -8
  204. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +0 -166
  205. package/src/core/lifecycle/lifecycle-state-machine.ts +0 -3
  206. package/src/core/logging/log-ring-buffer.ts +0 -3
  207. package/src/core/logging/logging.service.spec.ts +0 -287
  208. package/src/core/logging/logging.service.ts +0 -143
  209. package/src/core/logging/scoped-logger.ts +0 -3
  210. package/src/core/moleculer/cap-call-fn.spec.ts +0 -173
  211. package/src/core/moleculer/cap-call-fn.ts +0 -107
  212. package/src/core/moleculer/cap-route-authority.ts +0 -194
  213. package/src/core/moleculer/moleculer.service.ts +0 -1072
  214. package/src/core/network/network-quality.service.spec.ts +0 -53
  215. package/src/core/network/network-quality.service.ts +0 -5
  216. package/src/core/notification/notification-wrapper.service.ts +0 -34
  217. package/src/core/notification/toast-wrapper.service.ts +0 -27
  218. package/src/core/provider/provider.tokens.ts +0 -1
  219. package/src/core/repl/repl-engine.service.spec.ts +0 -444
  220. package/src/core/repl/repl-engine.service.ts +0 -155
  221. package/src/core/storage/fs-storage-backend.spec.ts +0 -70
  222. package/src/core/storage/fs-storage-backend.ts +0 -3
  223. package/src/core/storage/storage-location-manager.spec.ts +0 -130
  224. package/src/core/storage/storage-location-manager.ts +0 -3
  225. package/src/core/storage/storage.service.spec.ts +0 -73
  226. package/src/core/storage/storage.service.ts +0 -3
  227. package/src/core/streaming/stream-probe.service.ts +0 -221
  228. package/src/core/topology/topology-emitter.service.ts +0 -105
  229. package/src/launcher.ts +0 -314
  230. package/src/main.ts +0 -1245
  231. package/src/manual-boot.ts +0 -301
  232. package/tsconfig.build.json +0 -8
  233. package/tsconfig.json +0 -33
  234. package/vitest.config.ts +0 -26
@@ -1,3130 +0,0 @@
1
- /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access -- pre-existing lint debt across this 2200-line orchestration class. The flagged sites (StorageService.setLocationManager / setSettingsBackend, LoggingService.addDestination, RouteRegistry, etc.) are typed as `unknown` by their owning services to break circular construction-order dependencies; runtime contracts are validated structurally. Tracked separately; do not amend in unrelated edits. */
2
- import * as os from 'node:os'
3
- import { ConfigService } from '../config/config.service'
4
- import { overlayDeclaration } from './addon-row-manifest'
5
- import { LoggingService } from '../logging/logging.service'
6
- import { EventBusService } from '../events/event-bus.service'
7
- import { StorageService } from '../storage/storage.service'
8
- import { StreamProbeService } from '../streaming/stream-probe.service'
9
-
10
- import { CapabilityService } from '../capability/capability.service'
11
- import {
12
- EventCategory,
13
- createDeviceProxy,
14
- normalizeAddonInitResult,
15
- errMsg,
16
- isAgentOnlyPlacement,
17
- resolveRunnerId,
18
- resolveAddonPlacement,
19
- } from '@camstack/types'
20
- import type {
21
- AddonDeclaration,
22
- CapabilityDeclaration,
23
- CapabilityDefinition,
24
- InferProvider,
25
- RunnerPlan,
26
- RunnerAddonPlacement,
27
- } from '@camstack/types'
28
- import type { ICamstackAddon, AddonContext, InternalAddonContext } from '@camstack/types'
29
- import type { IScopedLogger } from '@camstack/types'
30
- import {
31
- CapabilityRegistry,
32
- CustomActionRegistry,
33
- INFRA_CAPABILITIES,
34
- AddonLoader,
35
- AddonHealthMonitor,
36
- DeviceRegistry,
37
- adaptBrokerToCluster,
38
- createAddonService,
39
- createBrokerDeviceManagerApi,
40
- createKernelHwAccel,
41
- validateProviderRegistrations,
42
- CapabilityHandle,
43
- scopeKey,
44
- describeProviderKindDrift,
45
- type ISettingsStore,
46
- type AddonSettingsView,
47
- type AddonHealthSnapshot,
48
- } from '@camstack/kernel'
49
- import { localProviderLink, brokerTransportLink } from '@camstack/kernel'
50
- import { createTRPCClient } from '@trpc/client'
51
- import { MoleculerService } from '../moleculer/moleculer.service'
52
- import { AddonDepsManager } from '@camstack/kernel'
53
- import type { SavedDevice, ReadinessScope } from '@camstack/types'
54
- import { IntegrationRegistry } from '@camstack/core'
55
- import type { IStorageProvider as INewStorageProvider, ISettingsBackend } from '@camstack/types'
56
- import { AddonRouteRegistry, DataPlaneRegistry } from '@camstack/core'
57
- import { randomUUID } from 'node:crypto'
58
- import * as path from 'node:path'
59
- import * as fs from 'node:fs'
60
- import { pathToFileURL } from 'node:url'
61
- import { createAddonSettingsProvider } from './addon-settings-provider.js'
62
- import { AddonCallGateway } from './addon-call-gateway.js'
63
- import { addonSettingsCapability } from '@camstack/types'
64
- import { DisposerChain } from '@camstack/types'
65
-
66
- type AddonSource = 'core' | 'installed'
67
-
68
- /**
69
- * Local narrowing of the Moleculer ServiceBroker surface we actually use.
70
- * See the `broker` getter docstring below for why this is necessary.
71
- */
72
- interface BrokerLike {
73
- call<T = unknown>(
74
- action: string,
75
- params?: unknown,
76
- opts?: { nodeID?: string; timeout?: number },
77
- ): Promise<T>
78
- nodeID: string
79
- createService(schema: unknown): unknown
80
- }
81
-
82
- /**
83
- * Type predicate: true when an `ISettingsBackend` also satisfies the
84
- * sync `ISettingsStore` contract (system/addon/device key-value ops)
85
- * that ConfigManager expects. The default `SqliteSettingsBackend` in
86
- * `@camstack/core` implements both surfaces; forked addon backends may
87
- * not, in which case we skip the ConfigService wiring.
88
- */
89
- function isSettingsStore(backend: ISettingsBackend): backend is ISettingsBackend & ISettingsStore {
90
- const required: readonly (keyof ISettingsStore)[] = [
91
- 'getSystem',
92
- 'setSystem',
93
- 'getAllSystem',
94
- 'getAllAddon',
95
- 'setAllAddon',
96
- 'getAllProvider',
97
- 'setProvider',
98
- 'getAllDevice',
99
- 'setDevice',
100
- 'getAddonDevice',
101
- 'setAddonDevice',
102
- 'clearAddonDevice',
103
- ]
104
- for (const key of required) {
105
- if (typeof Reflect.get(backend, key) !== 'function') return false
106
- }
107
- return true
108
- }
109
-
110
- /**
111
- * The bridge-dispatch contract on an `addon-routes` provider. The operator-
112
- * facing `IAddonRouteProvider` interface declares only `id` + `getRoutes`; the
113
- * UDS proxy (and `buildAddonRouteProvider`) also expose `invoke`, which the
114
- * forked-routes bridge calls per request. Narrowed via `isAddonRoutesInvoker`.
115
- */
116
- interface AddonRoutesInvoker {
117
- invoke(
118
- input: import('@camstack/types').AddonRouteInvokeRequest,
119
- ): Promise<import('@camstack/types').AddonRouteReplyEnvelope>
120
- }
121
-
122
- /** Structural guard: true when an `addon-routes` provider also exposes `invoke`. */
123
- function isAddonRoutesInvoker<T extends object>(provider: T): provider is T & AddonRoutesInvoker {
124
- return typeof Reflect.get(provider, 'invoke') === 'function'
125
- }
126
-
127
- const ROUTE_METHODS: readonly import('@camstack/types').IAddonHttpRoute['method'][] = [
128
- 'GET',
129
- 'POST',
130
- 'PUT',
131
- 'DELETE',
132
- ]
133
- const ROUTE_ACCESS: readonly import('@camstack/types').RouteAccess[] = [
134
- 'public',
135
- 'authenticated',
136
- 'admin',
137
- ]
138
-
139
- function asRouteMethod(value: string): import('@camstack/types').IAddonHttpRoute['method'] {
140
- const upper = value.toUpperCase()
141
- for (const m of ROUTE_METHODS) if (m === upper) return m
142
- throw new Error(`addon-routes: unsupported HTTP method "${value}"`)
143
- }
144
-
145
- function asRouteAccess(value: unknown): import('@camstack/types').RouteAccess {
146
- if (typeof value === 'string') {
147
- for (const a of ROUTE_ACCESS) if (a === value) return a
148
- }
149
- // Default to the most restrictive sensible default the original mount used.
150
- return 'public'
151
- }
152
-
153
- /**
154
- * Parse the wire-boundary `unknown` returned by `callAddonOnChild(...,
155
- * {target:'routes'})` into typed route descriptors. The child sends route
156
- * descriptors WITHOUT handlers (functions don't cross MsgPack); only
157
- * `method`/`path`/`access`/`description` are present.
158
- */
159
- interface ParsedRouteDescriptor {
160
- readonly method: import('@camstack/types').IAddonHttpRoute['method']
161
- readonly path: string
162
- readonly access: import('@camstack/types').RouteAccess
163
- readonly description?: string
164
- }
165
-
166
- function parseSerializableRouteDescriptors(raw: unknown): readonly ParsedRouteDescriptor[] {
167
- if (!Array.isArray(raw)) {
168
- throw new Error('addon-routes: child returned a non-array route descriptor set')
169
- }
170
- return raw.map((entry: unknown): ParsedRouteDescriptor => {
171
- if (entry === null || typeof entry !== 'object') {
172
- throw new Error('addon-routes: route descriptor is not an object')
173
- }
174
- const method = Reflect.get(entry, 'method')
175
- const path = Reflect.get(entry, 'path')
176
- if (typeof method !== 'string' || typeof path !== 'string') {
177
- throw new Error('addon-routes: route descriptor missing method/path')
178
- }
179
- const description = Reflect.get(entry, 'description')
180
- return {
181
- method: asRouteMethod(method),
182
- path,
183
- access: asRouteAccess(Reflect.get(entry, 'access')),
184
- ...(typeof description === 'string' ? { description } : {}),
185
- }
186
- })
187
- }
188
-
189
- /**
190
- * Parse the wire-boundary `unknown` from `callForked(..., {target:'data-planes'})`
191
- * into typed data-plane endpoint descriptors (`prefix/access/baseUrl/secret`).
192
- * Pure data — the addon's `ctx.dataPlane` facility produced them.
193
- */
194
- function parseDataPlaneEndpoints(
195
- raw: unknown,
196
- ): readonly import('@camstack/types').AddonDataPlaneEndpoint[] {
197
- if (!Array.isArray(raw)) {
198
- throw new Error('data-planes: child returned a non-array endpoint set')
199
- }
200
- return raw.map((entry: unknown): import('@camstack/types').AddonDataPlaneEndpoint => {
201
- if (entry === null || typeof entry !== 'object') {
202
- throw new Error('data-planes: endpoint descriptor is not an object')
203
- }
204
- const prefix = Reflect.get(entry, 'prefix')
205
- const baseUrl = Reflect.get(entry, 'baseUrl')
206
- const secret = Reflect.get(entry, 'secret')
207
- if (typeof prefix !== 'string' || typeof baseUrl !== 'string' || typeof secret !== 'string') {
208
- throw new Error('data-planes: endpoint descriptor missing prefix/baseUrl/secret')
209
- }
210
- return { prefix, access: asRouteAccess(Reflect.get(entry, 'access')), baseUrl, secret }
211
- })
212
- }
213
-
214
- interface AddonEntry {
215
- readonly addon: ICamstackAddon
216
- initialized: boolean
217
- source: AddonSource
218
- /** npm package name from package.json (e.g. '@camstack/addon-detection-pipeline') */
219
- packageName: string
220
- /** npm package version from package.json */
221
- packageVersion: string
222
- /** Human-readable package name from camstack.displayName */
223
- packageDisplayName?: string
224
- /** Optional bundle metadata when the package ships multiple addon entries. */
225
- bundle?: { displayName: string; description?: string; icon?: string }
226
- /** Capabilities declared in package.json camstack.addons (source of truth) */
227
- declaredCapabilities: readonly CapabilityDeclaration[]
228
- /** Addon directory on disk */
229
- addonDir?: string
230
- /** Full package.json declaration (AddonDeclaration from @camstack/types) */
231
- declaration?: AddonDeclaration
232
- }
233
-
234
- // Phase 11 (settings redesign): `stripValue` helper and the legacy
235
- // `getAddonConfigSchema / getAddonConfig / updateAddonConfig` adapter
236
- // shim that consumed it were deleted. All settings flow through the
237
- // new `getAddonSettings / getGlobalSettings / getDeviceSettings`
238
- // endpoints on the `addon-settings` singleton capability.
239
-
240
- export class AddonRegistryService {
241
- private readonly addonEntries = new Map<string, AddonEntry>()
242
- private readonly capabilityRegistry: CapabilityRegistry
243
- /** Single router for addon-level calls (routes / custom / settings). */
244
- private addonCallGateway!: AddonCallGateway
245
- // Task 7.1: hub-wide registry of addon custom actions. Populated on
246
- // each addon's initialize() (in-process path); Task 7.2 will dispatch
247
- // through this from the `api.addons.custom` tRPC procedure.
248
- private readonly customActionRegistry = new CustomActionRegistry()
249
- /**
250
- * AddonIds whose group-runner disconnect is operator-initiated (update /
251
- * restart / uninstall). The Moleculer `$node.disconnected` handler skips
252
- * these so the AddonCard doesn't flash a spurious "Failed to load" health
253
- * banner during the kill→respawn window. The set is cleared as soon as
254
- * `restartAddon` completes (success or failure) or by a 90s safety timer.
255
- */
256
- private readonly restartingAddons = new Map<string, NodeJS.Timeout>()
257
- private readonly logger: IScopedLogger
258
- private addonLoader!: AddonLoader
259
- private healthMonitor!: AddonHealthMonitor
260
- private addonRouteRegistry: AddonRouteRegistry | null = null
261
- private dataPlaneRegistry: DataPlaneRegistry | null = null
262
-
263
- // Broker-routed AddonApi proxy — every addon's `ctx.api` resolves
264
- // to this. Calls go through `broker.call('${addonId}.${capName}.${method}')`
265
- // which Moleculer routes to whichever process actually hosts the
266
- // capability (in-process services on the hub, forked workers, or
267
- // remote agents). The hub uses the exact same transport as agents
268
- // and forked workers — `ctx.api` is uniform across all deployment
269
- // shapes. Lazily constructed so tests that don't wire the broker
270
- // still instantiate the service.
271
- private brokerApi: import('@camstack/types').AddonApi | null = null
272
- /**
273
- * In-process tRPC client over the broker — same `AddonApi` shape every
274
- * addon sees via `ctx.api`. Exposed as public so the REPL service can
275
- * build a `SystemManager` from it without crossing the network boundary.
276
- */
277
- public getBrokerApi(): import('@camstack/types').AddonApi {
278
- if (!this.brokerApi) {
279
- const reg = this.capabilityRegistry
280
- const resolver = {
281
- getByName: (capName: string): unknown | null =>
282
- reg.getSingleton(capName) ?? (reg.getAllProviders(capName)[0] as unknown) ?? null,
283
- }
284
- const client: unknown = createTRPCClient({
285
- links: [localProviderLink(resolver), brokerTransportLink(this.moleculer.broker)],
286
- })
287
- this.brokerApi = client as import('@camstack/types').AddonApi
288
- }
289
- return this.brokerApi
290
- }
291
-
292
- // Common settings resolver (3-level: defaults → global → per-device).
293
- // Lazily constructed on first call to `getSettingsResolver()` so tests
294
- // that don't exercise the settings API can instantiate the service
295
- // without wiring ConfigService into the resolver.
296
-
297
- // Active capability providers (set by consumers when capabilities are wired)
298
- private activeStorageProvider: INewStorageProvider | null = null
299
- private activeSettingsBackend: ISettingsBackend | null = null
300
- private integrationRegistry: import('@camstack/core').IntegrationRegistry | null = null
301
-
302
- // Device architecture — in-memory registry of live IDevice instances.
303
- // Persistence is owned by the `device-manager` capability addon.
304
- private readonly deviceRegistry = new DeviceRegistry()
305
-
306
- /**
307
- * Typed accessor for the Moleculer broker. The Moleculer `index.d.ts`
308
- * chains through `eventemitter2`, whose package.json has no `types`
309
- * field; under `moduleResolution: node` typescript-eslint's parser
310
- * loses the type chain even though `tsc` accepts it via `skipLibCheck`.
311
- * Going through `this.moleculer.broker` directly produces "type cannot
312
- * be resolved" errors at every call site. The local `BrokerLike`
313
- * interface narrows down to the surface we actually use, and the
314
- * double-cast forces the parser to materialize types locally so
315
- * `this.broker.call(...)` / `this.broker.nodeID` lint clean.
316
- */
317
- private get broker(): BrokerLike {
318
- return this.moleculer.broker as unknown as BrokerLike
319
- }
320
-
321
- constructor(
322
- private readonly loggingService: LoggingService,
323
- private readonly eventBusService: EventBusService,
324
- private readonly configService: ConfigService,
325
- private readonly storageService: StorageService,
326
- private readonly capabilityService: CapabilityService,
327
- private readonly moleculer: MoleculerService,
328
- private readonly streamProbe: StreamProbeService,
329
- ) {
330
- this.logger = this.loggingService.createLogger('AddonRegistry')
331
- this.addonLoader = new AddonLoader(this.loggingService.createLogger('AddonLoader'))
332
-
333
- // Kernel-level addon health monitor — drives the 5-min boot grace
334
- // window + aggressive auto-retry loop (60s/120s/300s, never gives
335
- // up). Failures and recoveries are recorded by the load + init
336
- // paths; the monitor's tick loop calls `tryReloadPackage` to
337
- // reattempt failed addons. Operator visibility comes through
338
- // AddonLoadFailed / AddonLoadRecovered events consumed by
339
- // AlertCenter (see addon-health-monitor.ts spec).
340
- this.healthMonitor = new AddonHealthMonitor({
341
- eventBus: this.eventBusService as unknown as import('@camstack/types').IEventBus,
342
- logger: this.loggingService.createLogger('AddonHealthMonitor'),
343
- retryFn: (packageName) => this.tryReloadPackage(packageName),
344
- })
345
-
346
- // Create capability registry with config reader for singleton preferences
347
- this.capabilityRegistry = new CapabilityRegistry(
348
- this.loggingService.createLogger('CapabilityRegistry'),
349
- this.eventBusService,
350
- )
351
- this.capabilityRegistry.setConfigReader((capability: string): string | undefined => {
352
- try {
353
- return this.configService.get<string>(`capabilities.singleton.${capability}`) ?? undefined
354
- } catch (err) {
355
- this.logger.debug('settings-store not wired yet during early boot', {
356
- meta: { capability, error: errMsg(err) },
357
- })
358
- return undefined
359
- }
360
- })
361
-
362
- this.capabilityRegistry.setNodeConfigReader(
363
- (capability: string, nodeId: string): string | undefined => {
364
- try {
365
- return (
366
- this.configService.get<string>(`capabilities.singletonNode.${capability}.${nodeId}`) ??
367
- undefined
368
- )
369
- } catch (err) {
370
- this.logger.debug('settings-store not wired yet during early boot', {
371
- meta: { capability, nodeId, error: errMsg(err) },
372
- })
373
- return undefined
374
- }
375
- },
376
- )
377
-
378
- // Collection-provider enable/disable persistence. Reads the same
379
- // `capabilities.collection.<cap>` ConfigService key the `capabilities`
380
- // router writes (`JSON.stringify({ disabled: string[] })`), so a
381
- // collection provider an operator disabled survives a hub reboot.
382
- this.capabilityRegistry.setCollectionConfigReader(
383
- (capability: string): readonly string[] | undefined => {
384
- try {
385
- const raw = this.configService.get<string>(`capabilities.collection.${capability}`)
386
- if (!raw) return undefined
387
- const parsed: unknown = JSON.parse(raw)
388
- if (
389
- typeof parsed === 'object' &&
390
- parsed !== null &&
391
- 'disabled' in parsed &&
392
- Array.isArray((parsed as { disabled: unknown }).disabled)
393
- ) {
394
- const disabled = (parsed as { disabled: unknown[] }).disabled
395
- return disabled.filter((id): id is string => typeof id === 'string')
396
- }
397
- return undefined
398
- } catch (err) {
399
- this.logger.debug('settings-store not wired yet or malformed collection preference', {
400
- meta: { capability, error: errMsg(err) },
401
- })
402
- return undefined
403
- }
404
- },
405
- )
406
-
407
- // Wire the registry into the CapabilityService so all server services can use it
408
- this.capabilityService.setRegistry(this.capabilityRegistry)
409
-
410
- // Register kernel-provided infrastructure capabilities so addons
411
- // can resolve them via the capability registry like any other cap.
412
- // These are kernel pseudo-caps (no tRPC methods, object-reference only),
413
- // so we declare them with empty method maps before registering providers.
414
- this.capabilityRegistry.declareCapability({
415
- name: 'device-registry',
416
- scope: 'system',
417
- mode: 'singleton',
418
- methods: {},
419
- })
420
- this.capabilityRegistry.declareCapability({
421
- name: 'cluster-broker',
422
- scope: 'system',
423
- mode: 'singleton',
424
- methods: {},
425
- })
426
- this.capabilityRegistry.registerProvider('device-registry', '$kernel', this.deviceRegistry)
427
- this.capabilityRegistry.registerProvider('cluster-broker', '$kernel', {
428
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- moleculer types unresolvable; see `broker` getter docstring
429
- broker: this.moleculer.broker,
430
- })
431
- }
432
-
433
- async onModuleInit(): Promise<void> {
434
- // Register the addon-settings singleton provider — replaces the
435
- // former `$addonHost` Moleculer service. The provider resolves
436
- // addonId → local addon instance (hub) or remote Moleculer call.
437
- this.capabilityRegistry.declareCapability(addonSettingsCapability)
438
- // The SINGLE router for addon-level calls (routes / custom / settings).
439
- // It classifies in-process | hub-local-child (UDS) | remote-agent
440
- // (Moleculer) in ONE place — `resolveNode` reports only the BASE node
441
- // (the hub for every hub-local addon, forked or not); the gateway's
442
- // `isChildKnown` distinguishes a forked UDS child from an in-process
443
- // builtin. This is the fix for forked-addon settings: they were forced
444
- // down a dead `<addonId>.settings.*` Moleculer path because the old
445
- // `resolveNode` marked them non-local; now they route over UDS like
446
- // their routes + custom-actions already do.
447
- this.addonCallGateway = new AddonCallGateway({
448
- hubNodeId: this.broker.nodeID,
449
- resolveNode: (addonId) => {
450
- const entry = this.addonEntries.get(addonId)
451
- // Every addon in `addonEntries` is hub-local — report the hub node;
452
- // forked-vs-in-process is decided by `isChildKnown` in the gateway.
453
- return entry ? this.broker.nodeID : 'hub'
454
- },
455
- getChildRegistry: () => this.moleculer.childRegistry,
456
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- moleculer types unresolvable; see `broker` getter docstring
457
- broker: this.moleculer.broker,
458
- })
459
- const settingsProvider = createAddonSettingsProvider({
460
- getAddon: (addonId) => {
461
- const entry = this.addonEntries.get(addonId)
462
- return entry?.addon ?? null
463
- },
464
- gateway: this.addonCallGateway,
465
- })
466
- this.capabilityRegistry.registerProvider('addon-settings', '$hub', settingsProvider)
467
-
468
- // Wire capability consumer actions via EventBus
469
- this.wireCapabilityConsumers()
470
-
471
- // Subscribe to capability.binding-changed so hub-side capability
472
- // overrides take effect on the fly. The orchestrator addon emits
473
- // these when the operator swaps the addon implementing a cap on a
474
- // node; we only react when the target node is the hub's own nodeID.
475
- this.eventBusService.subscribe({ category: 'capability.binding-changed' }, (event) => {
476
- const data = (event.data ?? {}) as {
477
- nodeId?: string
478
- capName?: string
479
- addonId?: string | null
480
- }
481
- if (!data.capName) return
482
- const localNodeId = this.broker.nodeID
483
- if (data.nodeId && data.nodeId !== localNodeId) return
484
- this.capabilityRegistry.setSingletonActiveAddon(data.capName, data.addonId ?? null)
485
- })
486
-
487
- // 2. Load all addons from data/addons/ directory (including core builtins)
488
- const dataDir = path.resolve(process.env.CAMSTACK_DATA ?? 'camstack-data')
489
- const addonsDir = path.resolve(dataDir, 'addons')
490
-
491
- await this.addonLoader.loadFromDirectory(addonsDir)
492
-
493
- // Hand pre-init load failures (broken package.json, missing
494
- // dist, import-time errors) to the health monitor so its retry
495
- // loop and post-grace alerting cover them. Failures during the
496
- // init phase below are recorded separately.
497
- for (const fail of this.addonLoader.listLoadFailures()) {
498
- this.healthMonitor.recordFailure(fail.packageName, fail.error, fail.addonId)
499
- }
500
-
501
- const loadedAddons = this.addonLoader.listAddons()
502
-
503
- for (const registered of loadedAddons) {
504
- // Skip agent-only addons — they never run on the hub, only on remote
505
- // agents that opt in via `execution.placement: 'agent-only'`.
506
- if (isAgentOnlyPlacement(registered.declaration)) {
507
- this.logger.info('Skipping agent-only addon on hub', {
508
- tags: { addonId: registered.declaration.id },
509
- meta: { packageName: registered.packageName },
510
- })
511
- continue
512
- }
513
- if (registered.declaration.capabilities) {
514
- for (const cap of registered.declaration.capabilities) {
515
- this.capabilityRegistry.declareFromManifest(cap, registered.declaration.id)
516
- }
517
- }
518
- // Create and store addon instance — cast from @camstack/types to server's
519
- // local ICamstackAddon (structurally compatible at runtime).
520
- // Wrap in try/catch so a single broken addon doesn't prevent the rest from loading.
521
- try {
522
- const addon = this.addonLoader.createInstance(registered.declaration.id)
523
- // Capabilities come from package.json declaration (source of truth),
524
- // merged with any declared in the addon class manifest.
525
- const declCaps = (registered.declaration.capabilities ?? []).map(
526
- (c: string | CapabilityDeclaration) =>
527
- typeof c === 'string' ? { name: c, mode: 'singleton' as const } : c,
528
- )
529
- this.addonEntries.set(registered.declaration.id, {
530
- addon,
531
- initialized: false,
532
- source: 'installed',
533
- packageName: registered.packageName,
534
- packageVersion: registered.packageVersion,
535
- packageDisplayName: registered.packageDisplayName,
536
- ...(registered.bundle !== undefined ? { bundle: registered.bundle } : {}),
537
- declaredCapabilities: declCaps,
538
- addonDir: path.join(addonsDir, registered.packageName),
539
- declaration: registered.declaration,
540
- })
541
- } catch (err) {
542
- const msg = errMsg(err)
543
- this.logger.error('Failed to create instance of addon', {
544
- tags: { addonId: registered.declaration.id },
545
- meta: { error: msg },
546
- })
547
- }
548
- }
549
-
550
- this.logger.info('Loaded addons from directory', {
551
- meta: { count: loadedAddons.length, addonsDir },
552
- })
553
- // Platform probing is no longer orchestrated by the backend. The
554
- // `platform-probe` capability (shipped as a core builtin under
555
- // `@camstack/core/dist/builtins/platform-probe/` since Phase B of
556
- // the bundles refactor) exposes hardware + scored backends via
557
- // `ctx.api.platformProbe.*`; inference addons auto-configure
558
- // inside their own `onInitialize`.
559
-
560
- // 4. Initialize all installed addons in correct order (installed = active)
561
- const allIds = [...this.addonEntries.keys()]
562
-
563
- // Mark registry as ready BEFORE any addon initializes. Addons' onInitialize
564
- // may call ctx.api.<cap>.* which routes through localProviderLink; that
565
- // resolver depends on getAllProviders/getSingleton which gate on `_ready`.
566
- // Without this, Phase 1 and Phase 2 addons would all fall through to the
567
- // Moleculer broker transport and fail with "service not registered".
568
- this.capabilityRegistry.ready()
569
-
570
- // Native-cap cross-process bridge: when a hub consumer resolves a native
571
- // provider for a device whose IDevice lives in a forked worker, we
572
- // return a proxy that routes calls to the correct transport.
573
- //
574
- // G2 — single ownership authority: the CapRouteResolver is consulted FIRST
575
- // for hub-local native caps. If the resolver's snapshot identifies a
576
- // hub-local-uds route for (capName, deviceId), we build the proxy directly —
577
- // no `resolveNativeCapOwnerSync` consult needed for this branch. The resolver's
578
- // `hubLocalChildProvides(capName, deviceId)` is the canonical hub-local authority
579
- // (fed by the same LocalChildRegistry that owns UDS dispatch), so the old
580
- // `owner.nodeId.startsWith(hubNodeId + '/')` gate is replaced by the resolver's
581
- // own verdict.
582
- //
583
- // `resolveNativeCapOwnerSync` is only consulted for the REMOTE branch: when the
584
- // resolver throws no-provider for the hub node (no hub-local-uds child owns the cap),
585
- // the fallback queries `resolveNativeCapOwnerSync` to find a remote owner. That
586
- // method carries data (push-fed `remoteNativeCaps` from `DeviceBindingsChanged`
587
- // events) that the resolver's `NodeCapAuthority` doesn't see, so it stays as the
588
- // remote-branch source. If `resolveNativeCapOwnerSync` also returns null, the cap
589
- // genuinely has no cross-process provider and the fallback returns null — wrappers
590
- // cleanly fall through to their own secondary strategy.
591
- {
592
- const { buildNativeCapProxy } = await import('@camstack/kernel')
593
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- moleculer types unresolvable; see `broker` getter docstring
594
- const broker = this.moleculer.broker
595
- this.capabilityRegistry.setNativeFallback(
596
- (capName: string, deviceId: number): unknown | null => {
597
- const resolver = this.moleculer.capRouteResolver
598
- const hubNodeId = this.moleculer.nodeId
599
-
600
- // 1. Hub-local path: the resolver's hub-local-child authority is the
601
- // single source of truth for whether a forked hub-local UDS child
602
- // owns (capName, deviceId) — via hubLocalChildProvides +
603
- // getHubLocalChildId (both deviceId-aware, M1).
604
- //
605
- // We deliberately use `resolveHubLocalUdsRoute`, NOT
606
- // `resolveCapRoute`: this fallback's contract is to reach the
607
- // NATIVE provider in the forked vendor child. `resolveCapRoute`
608
- // gives Priority 1 to `hub-in-process`, so a cap that ALSO has an
609
- // in-hub provider for the same name (a wrapper — today `snapshot`)
610
- // would classify as `hub-in-process` (the wrapper) and never reach
611
- // the hub-local-uds native. That made the wrapper's
612
- // `getNativeProvider` return null and silently fall through to its
613
- // secondary strategy (e.g. snapshot's ffmpeg frame grab), so the
614
- // vendor native snapshot was never invoked. `resolveHubLocalUdsRoute`
615
- // skips the in-process branch and returns the native child route.
616
- if (resolver !== null) {
617
- const route = resolver.resolveHubLocalUdsRoute(capName, deviceId)
618
- if (route !== null) {
619
- const childId = route.childId
620
- const target: Record<string, (input: unknown) => Promise<unknown>> = {}
621
- return new Proxy(target, {
622
- get(_target, property): ((input: unknown) => Promise<unknown>) | undefined {
623
- if (typeof property !== 'string') return undefined
624
- return (input: unknown): Promise<unknown> => {
625
- const mergedInput =
626
- typeof input === 'object' && input !== null
627
- ? { ...input, deviceId }
628
- : { deviceId }
629
- // Re-resolve at call time so a child that respawned under a
630
- // new runner id is still reachable (route is cheap to build).
631
- const r = resolver.resolveHubLocalUdsRoute(capName, deviceId) ?? {
632
- kind: 'hub-local-uds' as const,
633
- capName,
634
- childId,
635
- }
636
- return resolver.dispatch(r, property, mergedInput)
637
- }
638
- },
639
- })
640
- }
641
- // No hub-local child owns (capName, deviceId). Fall through to the
642
- // remote branch — resolveNativeCapOwnerSync is still consulted there
643
- // and may find a remote owner for this (capName, deviceId).
644
- }
645
-
646
- // 2. Remote path: resolver has no hub-local route for this (capName, deviceId).
647
- // Consult resolveNativeCapOwnerSync — it includes push-fed remoteNativeCaps
648
- // (DeviceBindingsChanged events from forked workers) that the resolver's
649
- // NodeCapAuthority (backed by HubNodeRegistry) doesn't carry.
650
- const dm = this.capabilityRegistry.getSingleton<{
651
- resolveNativeCapOwnerSync?: (
652
- capName: string,
653
- deviceId: number,
654
- ) => { addonId: string; nodeId: string } | null
655
- }>('device-manager')
656
- const owner = dm?.resolveNativeCapOwnerSync?.(capName, deviceId) ?? null
657
- if (!owner) return null
658
-
659
- // Guard: skip hub-local owners here — they were already handled (or skipped
660
- // because the resolver was null / not initialised yet). Returning null avoids
661
- // building a Moleculer proxy that points at a UDS-only child.
662
- if (owner.nodeId.startsWith(`${hubNodeId}/`)) return null
663
-
664
- // Remote native cap → Moleculer with native-provider infix.
665
- // buildNativeCapProxy uses the action `${addonId}.native-provider.${cap}.${method}`.
666
- return buildNativeCapProxy(broker, owner.addonId, capName, deviceId)
667
- },
668
- )
669
- }
670
-
671
- // Runner spawn: every non-core, placement-eligible addon runs in its
672
- // OWN forked runner subprocess by default (base-layer D2/D9 — one
673
- // addon, one process). No shipped addon declares a shared
674
- // `execution.group`: Phase 5 dissolved the `pipeline` media group
675
- // once frames travel as shm `FrameHandle`s and audio over tRPC.
676
- // `buildAddonGroupPlan` keys the plan by runner id (the addon id);
677
- // cross-runner / cross-node cap calls travel over Moleculer TCP.
678
- // `resolveRunnerId` still collapses a shared `execution.group` if
679
- // one were declared — the mechanism stays available as an explicit
680
- // opt-in for future co-location needs.
681
- //
682
- // `@camstack/core` builtins and `placement: 'agent-only'` addons are
683
- // excluded from the plan (filtered inside `buildAddonGroupPlan`).
684
- // A failed runner spawn is logged and the addon surfaces as
685
- // `addon.error` — there is NO in-process-on-the-hub fallback (it
686
- // would violate one-addon-one-process). Retry policy for a failed
687
- // spawn is governed by the kernel circuit-breaker.
688
- {
689
- const plan = this.buildAddonGroupPlan(allIds)
690
- for (const [runnerId, runnerAddons] of plan) {
691
- try {
692
- await this.initializeAddonGroup(runnerId, runnerAddons)
693
- for (const { addonId } of runnerAddons) {
694
- this.wireCapabilities(addonId)
695
- }
696
- } catch (error: unknown) {
697
- const msg = errMsg(error)
698
- for (const { addonId } of runnerAddons) {
699
- this.emitAddonLifecycleEvent('addon.error', addonId, {
700
- error: msg,
701
- phase: 'init',
702
- })
703
- }
704
- this.logger.error('Runner spawn failed — addons on this runner will be skipped', {
705
- meta: { runnerId, addonIds: runnerAddons.map((a) => a.addonId), error: msg },
706
- })
707
- }
708
- }
709
- }
710
-
711
- // In-process boot for `@camstack/core` builtins. Core builtins stay
712
- // resident on the hub process — they provide the storage / settings
713
- // / logging infrastructure every forked runner depends on, reachable
714
- // via the hub broker, so `buildAddonGroupPlan` excludes them from
715
- // the runner plan above. They boot in two ordered passes:
716
- // infrastructure caps first (`INFRA_CAPABILITIES`,
717
- // storage→settings→logging), then the remaining builtins in
718
- // capability-dependency order.
719
- //
720
- // A core builtin is identified by `packageName === "@camstack/core"`,
721
- // NOT by the absence of an `execution` declaration — a builtin may
722
- // still declare `execution` (e.g. `platform-probe`); the package
723
- // boundary is the selection criterion.
724
- const isCoreBuiltin = (id: string): boolean =>
725
- this.addonEntries.get(id)?.packageName === '@camstack/core'
726
-
727
- // Pass 1 — infrastructure builtins. A failed REQUIRED infra cap
728
- // aborts boot: nothing downstream can run without storage/settings.
729
- for (const infra of INFRA_CAPABILITIES) {
730
- const addonId = this.findAddonForCapability(infra.name, allIds)
731
- if (addonId) {
732
- const entry = this.addonEntries.get(addonId)
733
- // Only core builtins boot in-process here. A non-core addon that
734
- // happens to provide an infra cap (e.g. an alternate
735
- // storage-provider) lives in its own runner — never in-process.
736
- if (!entry || entry.initialized || !isCoreBuiltin(addonId)) continue
737
- try {
738
- await this.initializeAddon(addonId)
739
- this.wireCapabilities(addonId)
740
- } catch (error: unknown) {
741
- const msg = errMsg(error)
742
- this.emitAddonLifecycleEvent('addon.error', addonId, {
743
- error: msg,
744
- phase: 'init',
745
- })
746
- if (infra.required) {
747
- throw new Error(`Required infrastructure addon "${addonId}" failed: ${msg}`, {
748
- cause: error,
749
- })
750
- }
751
- this.logger.warn('Optional infra addon failed -- continuing', {
752
- tags: { addonId },
753
- meta: { error: msg },
754
- })
755
- }
756
- } else if (infra.required) {
757
- throw new Error(`No addon provides required infrastructure capability "${infra.name}"`)
758
- }
759
- }
760
-
761
- // Pass 2 — remaining core builtins, in capability-dependency order.
762
- const bootOrder = this.capabilityRegistry.getBootOrder()
763
- const infraNames = new Set(INFRA_CAPABILITIES.map((c) => c.name))
764
-
765
- for (const capName of bootOrder) {
766
- if (infraNames.has(capName)) continue // Handled in pass 1
767
-
768
- for (const id of allIds) {
769
- const entry = this.addonEntries.get(id)
770
- if (!entry || entry.initialized || !isCoreBuiltin(id)) continue
771
-
772
- // Check if this addon provides this capability
773
- const provides = this.getAddonCapabilities(entry.addon)
774
- if (!provides.some((c) => c.name === capName)) continue
775
-
776
- try {
777
- await this.initializeAddon(id)
778
- this.wireCapabilities(id)
779
- } catch (error: unknown) {
780
- const msg = errMsg(error)
781
- this.emitAddonLifecycleEvent('addon.error', id, {
782
- error: msg,
783
- phase: 'init',
784
- })
785
- this.logger.error('Core builtin failed to initialize -- skipping', {
786
- tags: { addonId: id },
787
- meta: { error: msg },
788
- })
789
- }
790
- }
791
- }
792
-
793
- // Pass 3 — core builtins that declare no capabilities at all.
794
- for (const id of allIds) {
795
- const entry = this.addonEntries.get(id)
796
- if (entry && !entry.initialized && isCoreBuiltin(id)) {
797
- try {
798
- await this.initializeAddon(id)
799
- this.wireCapabilities(id)
800
- } catch (error: unknown) {
801
- const msg = errMsg(error)
802
- this.emitAddonLifecycleEvent('addon.error', id, {
803
- error: msg,
804
- phase: 'init',
805
- })
806
- this.logger.error('Core builtin failed to initialize -- skipping', {
807
- tags: { addonId: id },
808
- meta: { error: msg },
809
- })
810
- }
811
- }
812
- }
813
-
814
- const initializedIds = [...this.addonEntries.entries()]
815
- .filter(([, e]) => e.initialized)
816
- .map(([id]) => id)
817
-
818
- this.logger.info('Addons initialized', {
819
- meta: { initializedCount: initializedIds.length, totalCount: this.addonEntries.size },
820
- })
821
-
822
- // Health snapshot: every addon entry that survived the loader is
823
- // either initialized (record success) or failed init (record
824
- // failure with the captured error). Pre-load failures were already
825
- // recorded above from `addonLoader.listLoadFailures()`.
826
- for (const [id, entry] of this.addonEntries.entries()) {
827
- if (entry.initialized) {
828
- this.healthMonitor.recordSuccess(entry.packageName, id)
829
- } else {
830
- // Init failed (caught by either Phase 1 or Phase 2 catch
831
- // blocks above). The catch handlers don't carry the error
832
- // forward to here, so we synthesize a generic one — the real
833
- // error is in the addon log. Operators see the alert + can
834
- // open the per-addon logs to investigate.
835
- this.healthMonitor.recordFailure(
836
- entry.packageName,
837
- new Error(`Addon "${id}" failed to initialize — see addon logs for details`),
838
- id,
839
- )
840
- }
841
- }
842
-
843
- // Start the kernel-level retry tick (30s). Fires immediately
844
- // on the first interval boundary; the 5-min boot grace window
845
- // suppresses alerts until the system has had time to stabilize.
846
- this.healthMonitor.start()
847
-
848
- // Group-runner / forked-addon crash detection (point 5 of the
849
- // operator's spec — "ogni fork/moleculer deve essere considerato").
850
- // Moleculer's process-service.ts already handles respawn with
851
- // exponential backoff; we hook the broker's localBus events to
852
- // GIVE VISIBILITY of those crashes through the same monitor —
853
- // every crash records a failure, every reconnect records a
854
- // recovery. The respawn itself stays in process-service.ts.
855
- //
856
- // Runner node ids look like `hub/<runnerId>` where `runnerId` is the
857
- // addon id for a solo runner (the common case — one addon, one
858
- // process) or the co-location group name for a shared runner (e.g.
859
- // `hub/pipeline`). We map a disconnected/connected node back to the
860
- // underlying addons by walking `addonEntries` for any addon whose
861
- // resolved runner id matches the event's nodeId — every addon on
862
- // that runner gets a recordFailure / recordSuccess.
863
- type LocalBusEvent = { node: { id: string } }
864
- type LocalBus = { on: (event: string, handler: (payload: LocalBusEvent) => void) => void }
865
- const localBus = (this.moleculer.broker as unknown as { localBus?: LocalBus }).localBus
866
- if (localBus) {
867
- localBus.on('$node.disconnected', (payload) => {
868
- const nodeId = payload.node?.id
869
- if (!nodeId || nodeId === this.broker.nodeID) return
870
- for (const [id, entry] of this.addonEntries.entries()) {
871
- if (!entry.declaration) continue
872
- const runnerId = resolveRunnerId(entry.declaration, id)
873
- if (`${this.broker.nodeID}/${runnerId}` !== nodeId) continue
874
- // Skip operator-initiated restarts — `restartAddon` already
875
- // waits for caps to re-register and surfaces its own failure
876
- // path. Recording a transient failure here would flash the
877
- // "Failed to load" banner on AddonCard during a routine update.
878
- if (this.restartingAddons.has(id)) continue
879
- // Record one failure per addon on the disconnected runner.
880
- // The error message references the nodeId — the operator
881
- // can correlate with process-service.ts respawn logs.
882
- this.healthMonitor.recordFailure(
883
- entry.packageName,
884
- new Error(`Addon runner ${nodeId} disconnected`),
885
- id,
886
- )
887
- }
888
- })
889
- localBus.on('$node.connected', (payload) => {
890
- const nodeId = payload.node?.id
891
- if (!nodeId || nodeId === this.broker.nodeID) return
892
- for (const [id, entry] of this.addonEntries.entries()) {
893
- if (!entry.declaration) continue
894
- const runnerId = resolveRunnerId(entry.declaration, id)
895
- if (`${this.broker.nodeID}/${runnerId}` !== nodeId) continue
896
- this.healthMonitor.recordSuccess(entry.packageName, id)
897
- }
898
- })
899
- }
900
-
901
- this.eventBusService.emit({
902
- id: randomUUID(),
903
- timestamp: new Date(),
904
- source: { type: 'core', id: 'addon-registry' },
905
- category: EventCategory.SystemAddonsReady,
906
- data: { activeAddons: initializedIds },
907
- })
908
- }
909
-
910
- /**
911
- * Reload a single package — invoked by AddonHealthMonitor's retry
912
- * loop and by the operator-facing `addons.retryLoad` tRPC procedure.
913
- *
914
- * Two paths:
915
- * 1. Package not yet in the registry (e.g. import failed at boot
916
- * time): re-walk the addons dir, pick up any new package, run
917
- * `initializeAddon` for each contained addon.
918
- * 2. Package already in the registry but its addon entries failed
919
- * init: call `restartAddon` for each addon in that package.
920
- *
921
- * On success the monitor automatically records the addon healthy
922
- * (no explicit recordSuccess needed — the `attemptRetry` no-throw
923
- * fast path handles it). On failure this method throws and the
924
- * monitor's catch path schedules the next retry.
925
- */
926
- async tryReloadPackage(packageName: string): Promise<void> {
927
- const dataDir = path.resolve(process.env.CAMSTACK_DATA ?? 'camstack-data')
928
- const addonsDir = path.resolve(dataDir, 'addons')
929
- const addonDir = path.join(addonsDir, packageName)
930
-
931
- if (!fs.existsSync(addonDir)) {
932
- throw new Error(`Package directory missing: ${addonDir}`)
933
- }
934
-
935
- // Branch 1: package known to the registry already → restart each
936
- // addon entry. `restartAddon` rebuilds AddonContext + runs init
937
- // again; on success the entry's `initialized` flag flips back to
938
- // true and the monitor's auto-resolve path records success.
939
- const knownAddonIds: string[] = []
940
- for (const [id, entry] of this.addonEntries.entries()) {
941
- if (entry.packageName === packageName) {
942
- knownAddonIds.push(id)
943
- }
944
- }
945
-
946
- if (knownAddonIds.length > 0) {
947
- const errors: string[] = []
948
- for (const id of knownAddonIds) {
949
- try {
950
- await this.restartAddon(id)
951
- this.healthMonitor.recordSuccess(packageName, id)
952
- } catch (err) {
953
- errors.push(`${id}: ${errMsg(err)}`)
954
- this.healthMonitor.recordFailure(packageName, err, id)
955
- }
956
- }
957
- if (errors.length > 0) {
958
- throw new Error(`tryReloadPackage failed for ${packageName}: ${errors.join('; ')}`)
959
- }
960
- return
961
- }
962
-
963
- // Branch 2: package not yet known → run loadNewAddons which
964
- // walks disk, instantiates new entries, and initializes them.
965
- // It records its own loaded/failed in the return; we re-throw
966
- // when our package is in the failed list so the monitor knows.
967
- const result = await this.loadNewAddons()
968
- if (result.failed.length > 0) {
969
- const failed = result.failed.join(', ')
970
- throw new Error(`loadNewAddons reported failures: ${failed}`)
971
- }
972
- if (result.loaded.length === 0) {
973
- throw new Error(
974
- `No new addons loaded for ${packageName} — package may still have a broken manifest`,
975
- )
976
- }
977
- // Match loaded addons to this package + record success.
978
- for (const id of result.loaded) {
979
- const entry = this.addonEntries.get(id)
980
- if (entry?.packageName === packageName) {
981
- this.healthMonitor.recordSuccess(packageName, id)
982
- }
983
- }
984
- }
985
-
986
- /** Health snapshot — exposed to the addons.list tRPC procedure. */
987
- getAddonHealthSnapshot(): readonly AddonHealthSnapshot[] {
988
- return this.healthMonitor.getHealthSnapshot()
989
- }
990
-
991
- /** Manual user-triggered retry (resets retry counter). */
992
- async retryAddonLoad(packageName: string): Promise<void> {
993
- return this.healthMonitor.retryNow(packageName)
994
- }
995
-
996
- async onModuleDestroy(): Promise<void> {
997
- this.logger.info('Shutting down all addons…')
998
- this.healthMonitor.stop()
999
- await this.shutdownAll()
1000
- this.logger.info('All addons shut down')
1001
- }
1002
-
1003
- /** Set the AddonRouteRegistry for wiring addon-routes capabilities */
1004
- setAddonRouteRegistry(registry: AddonRouteRegistry): void {
1005
- this.addonRouteRegistry = registry
1006
- }
1007
-
1008
- /** Set the DataPlaneRegistry the hub's `/addon/:id/*` dispatch reverse-proxies
1009
- * against (addon HTTP data-planes). */
1010
- setDataPlaneRegistry(registry: DataPlaneRegistry): void {
1011
- this.dataPlaneRegistry = registry
1012
- }
1013
-
1014
- /**
1015
- * Pull a forked addon's HTTP data-plane endpoints over UDS and register them so
1016
- * the hub can reverse-proxy `/addon/<addonId>/<prefix>/*` to the addon's own
1017
- * listener. Idempotent (replace-all); an addon with no data-plane registers an
1018
- * empty set (cleared). Called off the capabilities-changed signal — a data-plane
1019
- * isn't a cap, but the child is UDS-reachable and has served its endpoints by
1020
- * the time any of its caps register.
1021
- */
1022
- private async mountAddonDataPlanes(addonId: string): Promise<void> {
1023
- const registry = this.dataPlaneRegistry
1024
- if (!registry) return
1025
- const entry = this.addonEntries.get(addonId)
1026
- const childRegistry = this.moleculer.childRegistry
1027
- // Forked addons only for now — co-located builtins would publish into the
1028
- // registry directly via a hub-side sink (deferred; no builtin serves a
1029
- // data-plane yet).
1030
- if (!entry || !this.isForkedAddonEntry(entry)) return
1031
- if (childRegistry === null || !childRegistry.isChildKnown(addonId)) return
1032
-
1033
- const raw = await this.addonCallGateway.callForked(addonId, { target: 'data-planes' })
1034
- const endpoints = parseDataPlaneEndpoints(raw)
1035
- registry.registerAddon(addonId, endpoints)
1036
- if (endpoints.length > 0) {
1037
- this.logger.info('Addon data-planes mounted (reverse-proxy)', {
1038
- meta: { phase: 'v2', addonId, prefixes: endpoints.map((e) => e.prefix) },
1039
- })
1040
- }
1041
- }
1042
-
1043
- /**
1044
- * Called after app.init() when the tRPC router is available.
1045
- * No-op now: addon `ctx.api` resolves to a broker-routed proxy, so
1046
- * the direct tRPC caller is no longer constructed. Kept for backward
1047
- * compat with `main.ts`'s bootstrap order.
1048
- */
1049
- async setAppRouter(_router: unknown): Promise<void> {
1050
- this.logger.debug(
1051
- 'setAppRouter called — broker-routed addon API in use, no direct caller needed',
1052
- )
1053
- }
1054
-
1055
- /** Get the CapabilityRegistry for external consumers to register */
1056
- getCapabilityRegistry(): CapabilityRegistry {
1057
- return this.capabilityRegistry
1058
- }
1059
-
1060
- /** Get the CustomActionRegistry — Task 7.2 will use this from the `api.addons.custom` tRPC procedure. */
1061
- getCustomActionRegistry(): CustomActionRegistry {
1062
- return this.customActionRegistry
1063
- }
1064
-
1065
- getDeviceRegistry(): DeviceRegistry {
1066
- return this.deviceRegistry
1067
- }
1068
-
1069
- /** Load persisted collection disabled-lists from settings-store into the registry */
1070
- private loadCollectionPreferences(): void {
1071
- // TODO: implement CapabilityRegistry.loadDisabledProviders() to restore persisted preferences
1072
- }
1073
-
1074
- /**
1075
- * Returns the IntegrationRegistry filtered to exclude orphaned integrations
1076
- * (where the addon is not currently installed). Orphaned data stays in DB
1077
- * and reconnects automatically when the addon is reinstalled.
1078
- */
1079
- getIntegrationRegistry(): import('@camstack/types').IIntegrationRegistry | null {
1080
- if (!this.integrationRegistry) return null
1081
- return this.createFilteredRegistry(this.integrationRegistry)
1082
- }
1083
-
1084
- /**
1085
- * Return the currently-active settings backend (the `settings-store`
1086
- * capability provider). Null if no provider has been registered yet
1087
- * (e.g. during early boot before builtins are loaded). Used by the
1088
- * multi-level addon settings router to read/write addon-device
1089
- * overrides directly.
1090
- */
1091
- getSettingsBackend(): ISettingsBackend | null {
1092
- return this.activeSettingsBackend
1093
- }
1094
-
1095
- /** Get the raw (unfiltered) registry — only for internal addon wiring */
1096
- getRawIntegrationRegistry(): import('@camstack/types').IIntegrationRegistry | null {
1097
- return this.integrationRegistry
1098
- }
1099
-
1100
- private createFilteredRegistry(
1101
- raw: import('@camstack/types').IIntegrationRegistry,
1102
- ): import('@camstack/types').IIntegrationRegistry {
1103
- const installedAddonIds = new Set(this.addonEntries.keys())
1104
-
1105
- // Build set of integration IDs whose addon IS installed
1106
- // Cache per call — lightweight, called infrequently
1107
- let activeIntegrationIds: Set<string> | null = null
1108
- const ensureActiveIds = async () => {
1109
- if (activeIntegrationIds) return activeIntegrationIds
1110
- const all = await raw.listIntegrations()
1111
- activeIntegrationIds = new Set(
1112
- all.filter((i) => installedAddonIds.has(i.addonId)).map((i) => i.id),
1113
- )
1114
- return activeIntegrationIds
1115
- }
1116
-
1117
- return {
1118
- // Integrations: filter out orphaned
1119
- createIntegration: (input) => raw.createIntegration(input),
1120
- getIntegration: async (id) => {
1121
- const i = await raw.getIntegration(id)
1122
- return i && installedAddonIds.has(i.addonId) ? i : null
1123
- },
1124
- getIntegrationByAddonId: async (addonId) => {
1125
- if (!installedAddonIds.has(addonId)) return null
1126
- return raw.getIntegrationByAddonId(addonId)
1127
- },
1128
- listIntegrations: async () => {
1129
- const all = await raw.listIntegrations()
1130
- return all.filter((i) => installedAddonIds.has(i.addonId))
1131
- },
1132
- updateIntegration: (id, updates) => raw.updateIntegration(id, updates),
1133
- deleteIntegration: (id) => raw.deleteIntegration(id),
1134
-
1135
- // Integration settings: passthrough (already gated by getIntegration)
1136
- getIntegrationSettings: (id) => raw.getIntegrationSettings(id),
1137
- setIntegrationSetting: (id, key, value) => raw.setIntegrationSetting(id, key, value),
1138
- setIntegrationSettings: (id, settings) => raw.setIntegrationSettings(id, settings),
1139
-
1140
- // Devices: filter out devices belonging to orphaned integrations
1141
- createDevice: (input) => raw.createDevice(input),
1142
- getDevice: async (id) => {
1143
- const d = await raw.getDevice(id)
1144
- if (!d) return null
1145
- const ids = await ensureActiveIds()
1146
- return ids.has(d.integrationId) ? d : null
1147
- },
1148
- getDeviceByStableId: async (stableId) => {
1149
- const d = await raw.getDeviceByStableId(stableId)
1150
- if (!d) return null
1151
- const ids = await ensureActiveIds()
1152
- return ids.has(d.integrationId) ? d : null
1153
- },
1154
- listDevices: async (integrationId) => {
1155
- const devices = await raw.listDevices(integrationId)
1156
- const ids = await ensureActiveIds()
1157
- return devices.filter((d) => ids.has(d.integrationId))
1158
- },
1159
- listCameras: async () => {
1160
- const cameras = await raw.listCameras()
1161
- const ids = await ensureActiveIds()
1162
- return cameras.filter((d) => ids.has(d.integrationId))
1163
- },
1164
- updateDevice: (id, updates) => raw.updateDevice(id, updates),
1165
- deleteDevice: (id) => raw.deleteDevice(id),
1166
-
1167
- // Device settings: passthrough
1168
- getDeviceSettings: (id) => raw.getDeviceSettings(id),
1169
- setDeviceSetting: (id, key, value) => raw.setDeviceSetting(id, key, value),
1170
- setDeviceSettings: (id, settings) => raw.setDeviceSettings(id, settings),
1171
- }
1172
- }
1173
-
1174
- // InferenceCapabilitiesService removed — now lives in pipeline-executor addon.
1175
- // Use capabilityRegistry.getSingleton('pipeline-executor') instead.
1176
-
1177
- /** Log a standardized lifecycle line for addon start/restart */
1178
- private logAddonLifecycle(
1179
- event: 'started' | 'restarted',
1180
- id: string,
1181
- mode: 'in-process' | 'isolated',
1182
- ): void {
1183
- const entry = this.addonEntries.get(id)
1184
- if (!entry) return
1185
- const agentName = process.env.CAMSTACK_AGENT_NAME ?? 'hub'
1186
- const platform = `${os.platform()}/${os.arch()}`
1187
- const message = `Addon ${event} — v${entry.packageVersion} (${entry.packageName}), ${mode}, ${platform}, agent: ${agentName}`
1188
- // Log under AddonRegistry scope
1189
- this.logger.info(message, {
1190
- tags: { addonId: id, agentId: agentName },
1191
- meta: { phase: 'lifecycle' },
1192
- })
1193
- // Also log under the addon's own tagged logger so it appears in the per-addon
1194
- // log viewer. No scope — brand bracket shows the addon id.
1195
- const addonLogger = this.loggingService.createLogger()
1196
- addonLogger.info(message, { tags: { addonId: id, agentId: agentName } })
1197
- }
1198
-
1199
- // Platform probing and inference-config resolution have moved into the
1200
- // `platform-probe` capability. Addons call `ctx.api.platformProbe.*`
1201
- // during their own initialize(). The backend no longer loops addons
1202
- // looking for `getModelRequirements` / `configure` — those hooks were
1203
- // removed from the ICamstackAddon contract.
1204
-
1205
- registerAddon(
1206
- addon: ICamstackAddon,
1207
- source: AddonSource = 'installed',
1208
- packageName?: string,
1209
- packageVersion?: string,
1210
- ): void {
1211
- const manifest = addon.manifest
1212
- if (!manifest) {
1213
- throw new Error(
1214
- 'Cannot register addon without manifest — was it created via AddonLoader.createInstance()?',
1215
- )
1216
- }
1217
- this.addonEntries.set(manifest.id, {
1218
- addon,
1219
- initialized: false,
1220
- source,
1221
- packageName: packageName ?? manifest.name ?? manifest.id,
1222
- packageVersion: packageVersion ?? manifest.version ?? '0.0.0',
1223
- declaredCapabilities: this.getAddonCapabilities(addon),
1224
- })
1225
- }
1226
-
1227
- async initializeAddon(id: string): Promise<void> {
1228
- const entry = this.addonEntries.get(id)
1229
- if (!entry) {
1230
- throw new Error(`Addon "${id}" is not registered`)
1231
- }
1232
-
1233
- if (!entry.addon?.manifest?.id) {
1234
- throw new Error(`Addon "${id}" has no manifest.id — check the addon class default export`)
1235
- }
1236
-
1237
- // Decide whether this addon should boot in its own forked runner
1238
- // subprocess. The runner-spawn pass during initial boot already
1239
- // spawned every eligible addon; `initializeAddon` is the per-addon
1240
- // entry point reached by the hot-load / restart flows (and by the
1241
- // Pass 1/2/3 in-process boot for `@camstack/core` builtins). Fork
1242
- // only when the addon declares an `execution` block, ships an
1243
- // on-disk `addonDir`, and is not `agent-only`; otherwise (only
1244
- // `@camstack/core` builtins reach the in-process path below) it
1245
- // boots in-process on the hub.
1246
- if (this.isForkedAddonEntry(entry)) {
1247
- // D5: one-addon-one-process. A forked addon ALWAYS boots in its
1248
- // own runner — there is NO in-process-on-the-hub fallback. If the
1249
- // runner spawn fails, the addon is `failed`, surfaced as
1250
- // `addon.error` + recorded on the health monitor; it does not
1251
- // silently run on the hub. (Task 7's circuit breaker governs the
1252
- // retry policy on top of this.)
1253
- try {
1254
- await this.broker.call('$process.spawnRunner', {
1255
- runnerId: id,
1256
- addons: [{ addonId: id, addonDir: entry.addonDir }],
1257
- })
1258
- } catch (err) {
1259
- const msg = errMsg(err)
1260
- this.logger.error('Failed to spawn isolated runner for addon', {
1261
- tags: { addonId: id },
1262
- meta: { error: msg },
1263
- })
1264
- this.emitAddonLifecycleEvent('addon.error', id, {
1265
- error: msg,
1266
- action: 'initialize',
1267
- })
1268
- this.healthMonitor.recordFailure(entry.packageName, err, id)
1269
- throw new Error(`Failed to spawn runner for addon "${id}": ${msg}`, { cause: err })
1270
- }
1271
-
1272
- // Provider registration for forkable addons is delegated to the
1273
- // `CapabilityBridge` (see `MoleculerService.onProviderConnected`).
1274
- // When the spawned child announces its Moleculer service via
1275
- // NODE_INFO, the bridge builds a proxy from the capability
1276
- // definition (`{ id, nodeId, ...methods }`) and registers it.
1277
- //
1278
- // Custom actions: read the catalog fresh from the on-disk bundle
1279
- // and register it against the hub-side `CustomActionRegistry`.
1280
- // `registerForkedAddonCustomActions` re-`import()`s the entry
1281
- // (cache-busted), so it covers the hot-load path — where the addon
1282
- // was registered via `freshLoader` and `this.addonLoader`'s cached
1283
- // `module` namespace is stale or absent. Dispatch routes through
1284
- // `broker.call('<addonId>.custom.<action>')`, the only divergence
1285
- // vs in-process being the transport, exactly like cap methods.
1286
- await this.registerForkedAddonCustomActions(
1287
- id,
1288
- // `isForkedAddonEntry` narrowed `entry.declaration` to non-null.
1289
- resolveRunnerId(entry.declaration, id),
1290
- )
1291
-
1292
- entry.initialized = true
1293
- this.logger.info('Addon spawned as isolated process', { tags: { addonId: id } })
1294
- this.emitAddonLifecycleEvent('addon.started', id)
1295
- return
1296
- }
1297
-
1298
- // In-process initialization — capture providers from initialize() return value
1299
- const context = await this.createAddonContext(entry.addon)
1300
- const capturedProviders = new Map<string, unknown>()
1301
- // Device-manager needs privileged access to CapabilityRegistry — it
1302
- // resolves native addon ids for getBindings, lists registered wrappers,
1303
- // etc. Inject before initialize() runs so the addon's onInitialize can
1304
- // rely on it.
1305
- const initResult = normalizeAddonInitResult(await entry.addon.initialize(context))
1306
- // NOTE: `postBrokerStart()` is deliberately NOT called here. It used to
1307
- // fire right after `initialize()` and would emit readiness events whose
1308
- // subscribers (remote workers) immediately called `ctx.api.<addon>.*` —
1309
- // hitting "Service not found" because `broker.createService()` had not
1310
- // been called yet. See below: postBrokerStart fires after the Moleculer
1311
- // service mount so the advertised service is live before readiness.
1312
-
1313
- for (const reg of initResult?.providers ?? []) {
1314
- const capName = reg.capability.name
1315
- capturedProviders.set(capName, reg.provider)
1316
- context.registerProvider(capName, reg.provider)
1317
- // D4: wrapper behaviour is read from the cap DEFINITION. The runtime
1318
- // ProviderRegistration.kind/defaultActive are deprecated hints — if an
1319
- // addon still sets them and they disagree, warn (the cap def wins).
1320
- const kindDrift = describeProviderKindDrift(capName, reg.capability, {
1321
- // eslint-disable-next-line @typescript-eslint/no-deprecated -- this IS the drift detector; it reads the deprecated hint on purpose
1322
- kind: reg.kind,
1323
- // eslint-disable-next-line @typescript-eslint/no-deprecated -- this IS the drift detector; it reads the deprecated hint on purpose
1324
- defaultActive: reg.defaultActive,
1325
- })
1326
- if (kindDrift) {
1327
- this.logger.warn(kindDrift, {
1328
- tags: { addonId: id },
1329
- meta: { capability: capName },
1330
- })
1331
- }
1332
- if (reg.capability.kind === 'wrapper') {
1333
- this.capabilityRegistry.registerWrapper(capName, id, {
1334
- defaultActive: reg.capability.defaultActive === true,
1335
- })
1336
- }
1337
- }
1338
-
1339
- // Task 7.1: register system-level custom actions if the addon declares any.
1340
- if (initResult?.customActions && initResult.actionHandlers) {
1341
- const handlers = initResult.actionHandlers
1342
- try {
1343
- this.customActionRegistry.registerAddon(id, initResult.customActions, (action, input) => {
1344
- const fn = handlers[action]
1345
- if (!fn) throw new Error(`addon '${id}' has no handler for custom action '${action}'`)
1346
- return fn(input)
1347
- })
1348
- } catch (err) {
1349
- this.logger.error('Failed to register custom actions for addon', {
1350
- tags: { addonId: id },
1351
- meta: { error: errMsg(err) },
1352
- })
1353
- throw err
1354
- }
1355
- }
1356
-
1357
- // Validate all declared capabilities have providers registered.
1358
- // Include caps registered via `context.registerProvider()` side-effects
1359
- // (e.g. sub-components of an addon calling ctx.registerProvider directly
1360
- // without returning via ProviderRegistration[]), by reading back from the
1361
- // CapabilityRegistry for this addonId. This keeps the validation accurate
1362
- // without forcing addons into a single return-path pattern.
1363
- if (entry.declaration) {
1364
- for (const cap of entry.declaration.capabilities ?? []) {
1365
- const capName = typeof cap === 'string' ? cap : cap.name
1366
- if (capturedProviders.has(capName)) continue
1367
- const provider = this.capabilityRegistry.getProviderByAddon(capName, id)
1368
- if (provider) capturedProviders.set(capName, provider)
1369
- }
1370
- validateProviderRegistrations(id, entry.declaration, capturedProviders, this.logger)
1371
- }
1372
-
1373
- await this.restoreAddonDevices(id, entry.addon)
1374
- entry.initialized = true
1375
-
1376
- // Register as Moleculer service for remote discoverability
1377
- if (entry.declaration) {
1378
- try {
1379
- // Resolve method names from CapabilityDefinitions registered in the registry
1380
- const methodResolver = (capName: string): readonly string[] => {
1381
- const def = this.capabilityRegistry.getDefinition(capName)
1382
- if (!def?.methods) return []
1383
- return Object.keys(def.methods)
1384
- }
1385
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- createAddonService return type unresolvable due to upstream eventemitter2/moleculer type chain
1386
- const schema = createAddonService(entry.addon, entry.declaration!, {
1387
- methodResolver,
1388
- providers: capturedProviders,
1389
- })
1390
- this.broker.createService(schema)
1391
- } catch (err) {
1392
- this.logger.warn('Failed to register Moleculer service', {
1393
- tags: { addonId: id },
1394
- meta: { error: errMsg(err) },
1395
- })
1396
- }
1397
- }
1398
-
1399
- // Post-broker-start readiness — emitted AFTER the Moleculer service is
1400
- // mounted so that any remote subscriber reacting to a `ready` event can
1401
- // immediately call `ctx.api.<addon>.*` without hitting a phantom
1402
- // service window. Hub addons with `autoEmitReadiness=true` emit here;
1403
- // addons that manage their own readiness override `autoEmitReadiness`.
1404
- entry.addon.postBrokerStart()
1405
-
1406
- this.logger.info('Addon initialized in-process', {
1407
- tags: { addonId: id },
1408
- meta: { packageName: entry.packageName },
1409
- })
1410
- this.logAddonLifecycle('started', id, 'in-process')
1411
- this.emitAddonLifecycleEvent('addon.started', id)
1412
- }
1413
-
1414
- /**
1415
- * Restore persisted devices for an addon after it has been initialized.
1416
- * Calls addon.restoreDevices() with the list of devices saved to the DB.
1417
- * No-op if the addon does not implement restoreDevices() or stores are not available.
1418
- */
1419
- private async restoreAddonDevices(addonId: string, addon: ICamstackAddon): Promise<void> {
1420
- if (typeof addon.restoreDevices !== 'function') return
1421
-
1422
- // Route through the device-persistence capability instead of
1423
- // accessing DeviceStore/ConfigStore directly. This keeps
1424
- // AddonRegistryService decoupled from the persistence layer —
1425
- // the capability addon owns the stores.
1426
- const api = this.getBrokerApi()
1427
- try {
1428
- const listResult: unknown = await (
1429
- Reflect.get(Reflect.get(api, 'deviceManager') as object, 'listPersistedByAddon') as {
1430
- query: (input: unknown) => Promise<unknown>
1431
- }
1432
- ).query({ addonId })
1433
-
1434
- const rows = Array.isArray(listResult)
1435
- ? (listResult as Array<{
1436
- id: number
1437
- stableId: string
1438
- type: string
1439
- name: string
1440
- location?: string | null
1441
- disabled?: boolean
1442
- parentDeviceId: number | null
1443
- }>)
1444
- : []
1445
- if (rows.length === 0) return
1446
-
1447
- const savedDevices: SavedDevice[] = await Promise.all(
1448
- rows.map(async (row) => {
1449
- const configResult: unknown = await (
1450
- Reflect.get(Reflect.get(api, 'deviceManager') as object, 'loadConfig') as {
1451
- query: (input: unknown) => Promise<unknown>
1452
- }
1453
- ).query({ deviceId: row.id })
1454
-
1455
- return {
1456
- id: row.id,
1457
- stableId: row.stableId,
1458
- type: row.type as import('@camstack/types').DeviceType,
1459
- name: row.name,
1460
- location: row.location ?? null,
1461
- disabled: row.disabled ?? false,
1462
- parentDeviceId: row.parentDeviceId,
1463
- config: (configResult ?? {}) as Record<string, unknown>,
1464
- }
1465
- }),
1466
- )
1467
-
1468
- await addon.restoreDevices!(savedDevices)
1469
- this.logger.info('Restored devices for addon', {
1470
- tags: { addonId },
1471
- meta: { count: savedDevices.length },
1472
- })
1473
- } catch (err) {
1474
- this.logger.error('restoreDevices failed for addon', {
1475
- tags: { addonId },
1476
- meta: { error: errMsg(err) },
1477
- })
1478
- }
1479
- }
1480
-
1481
- /**
1482
- * Refresh the package version for all addons from a given package.
1483
- * Call this after an update to ensure the UI shows the new version.
1484
- */
1485
- refreshPackageVersion(packageName: string, newVersion: string): void {
1486
- for (const [, entry] of this.addonEntries) {
1487
- if (entry.packageName === packageName) {
1488
- entry.packageVersion = newVersion
1489
- this.logger.info('Updated addon packageVersion', {
1490
- tags: { addonId: entry.addon.manifest!.id },
1491
- meta: { newVersion },
1492
- })
1493
- }
1494
- }
1495
- }
1496
-
1497
- /** Emit addon.uninstalled lifecycle event for all addons belonging to a package */
1498
- emitUninstallEvent(packageName: string): void {
1499
- for (const [id, entry] of this.addonEntries) {
1500
- if (entry.packageName === packageName) {
1501
- this.emitAddonLifecycleEvent('addon.uninstalled', id)
1502
- }
1503
- }
1504
- // Drop the package from the health monitor so its retry loop
1505
- // doesn't keep trying to reload an addon the operator explicitly
1506
- // removed. Matching `clearLoadFailures` purges any pre-init
1507
- // failures still tracked by the addon-loader.
1508
- this.healthMonitor.forget(packageName)
1509
- this.addonLoader.clearLoadFailures(packageName)
1510
- }
1511
-
1512
- /** Emit addon.updated lifecycle event for all addons belonging to a package */
1513
- emitUpdateEvent(packageName: string, fromVersion: string, toVersion: string): void {
1514
- for (const [id, entry] of this.addonEntries) {
1515
- if (entry.packageName === packageName) {
1516
- this.emitAddonLifecycleEvent('addon.updated', id, {
1517
- fromVersion,
1518
- toVersion,
1519
- })
1520
- }
1521
- }
1522
- }
1523
-
1524
- /**
1525
- * Restart an addon by ID.
1526
- * Shuts down, unregisters capabilities, re-initializes, and re-wires.
1527
- */
1528
- async restartAddon(addonId: string): Promise<{ success: boolean; error?: string }> {
1529
- const entry = this.addonEntries.get(addonId)
1530
- if (!entry) {
1531
- return { success: false, error: `Addon "${addonId}" not found` }
1532
- }
1533
-
1534
- this.logger.info('Addon restarting...', { tags: { addonId }, meta: { phase: 'lifecycle' } })
1535
-
1536
- // Suppress the "Failed to load" health banner during operator-initiated
1537
- // restarts. The Moleculer `$node.disconnected` handler skips entries
1538
- // present in `restartingAddons`; a 90s safety timer clears the flag
1539
- // even if the restart path throws before the finally block runs.
1540
- const existingTimer = this.restartingAddons.get(addonId)
1541
- if (existingTimer) clearTimeout(existingTimer)
1542
- const safetyTimer = setTimeout(() => {
1543
- this.restartingAddons.delete(addonId)
1544
- }, 90_000)
1545
- this.restartingAddons.set(addonId, safetyTimer)
1546
-
1547
- try {
1548
- // Group-runner-hosted addon — delegate to $process.restart for the group
1549
- if (this.isForkedAddonEntry(entry)) {
1550
- const result = (await this.broker.call('$process.restart', {
1551
- name: addonId,
1552
- })) as { success: boolean; reason?: string }
1553
- if (!result.success) {
1554
- throw new Error(`Process restart failed: ${result.reason ?? 'unknown'}`)
1555
- }
1556
-
1557
- // $process.restart resolves as soon as the child is respawned, not when its
1558
- // capabilities are re-registered. Callers (integrations.create, UI forms) may
1559
- // immediately try to route to the provider and hit a transient null. Block here
1560
- // until every declared capability is back on the registry so the restart is
1561
- // observable-consistent from the caller's perspective.
1562
- const declared = entry.declaredCapabilities
1563
- if (declared.length > 0) {
1564
- // Group-runner restarts pay a multi-step cost: child fork
1565
- // (~1s) → addon init (Python pool warmup ~5-10s, or a CLI
1566
- // subprocess probe for tailscale-ingress etc.) → capability
1567
- // advertisement to hub via Moleculer service discovery
1568
- // (~5s on cold start). Any fixed ceiling is wrong — a slow
1569
- // restart that DID eventually succeed produces a misleading
1570
- // "did not re-register in time" error. Wait indefinitely: if
1571
- // the group-runner crashed for real, `$node.disconnected`
1572
- // surfaces it through the health monitor; the operator can
1573
- // always click Cancel on the UI mutation.
1574
- const waits = declared.map((cap) =>
1575
- this.capabilityRegistry.waitForProvider(cap.name, addonId, Number.POSITIVE_INFINITY),
1576
- )
1577
- const settled = await Promise.all(waits)
1578
- const missing = declared
1579
- .map((cap, i) => (settled[i] == null ? cap.name : null))
1580
- .filter((name): name is string => name !== null)
1581
- if (missing.length > 0) {
1582
- throw new Error(
1583
- `Addon "${addonId}" restarted but did not re-register capabilities in time: ${missing.join(', ')}`,
1584
- )
1585
- }
1586
- }
1587
-
1588
- // Re-register the addon's custom-action catalog. `$process.restart`
1589
- // respawns the group child and `CapabilityBridge` re-registers cap
1590
- // providers — but custom actions live in the hub-side
1591
- // `CustomActionRegistry`, which the restart path never touched.
1592
- // Without this, every hot-update of a group-hosted addon silently
1593
- // drops its custom actions (the catalog is only registered once,
1594
- // at boot, in `initializeAddonGroup`). Reads a fresh catalog from
1595
- // the just-updated on-disk bundle.
1596
- const runnerId = resolveRunnerId(entry.declaration!, addonId)
1597
- await this.registerForkedAddonCustomActions(addonId, runnerId)
1598
-
1599
- this.logAddonLifecycle('restarted', addonId, 'isolated')
1600
- this.emitAddonLifecycleEvent('addon.restarted', addonId)
1601
- return { success: true }
1602
- }
1603
-
1604
- // D5: a forked addon ALWAYS restarts via `$process.restart` above
1605
- // (process restart — no in-process hot-reload of a live forked
1606
- // addon). This branch is reached only by `@camstack/core` builtins
1607
- // — they have no runner and legitimately reload in-process:
1608
- // shutdown, unregister, re-initialize, re-wire.
1609
- if (entry.initialized && entry.addon.shutdown) {
1610
- await entry.addon.shutdown()
1611
- }
1612
- // Drain disposers registered via ctx.addDisposer(...)
1613
- await this.drainDisposerChain(addonId)
1614
-
1615
- // Unregister all capabilities provided by this addon
1616
- for (const cap of entry.declaredCapabilities) {
1617
- this.capabilityRegistry.unregisterProvider(cap.name, addonId)
1618
- }
1619
- const manifestCaps = this.getAddonCapabilities(entry.addon)
1620
- for (const cap of manifestCaps) {
1621
- this.capabilityRegistry.unregisterProvider(cap.name, addonId)
1622
- }
1623
- this.capabilityRegistry.unregisterAllWrappersForAddon(addonId)
1624
-
1625
- // Task 7.1: drop any registered custom actions so re-initialization re-registers cleanly.
1626
- this.customActionRegistry.unregisterAddon(addonId)
1627
-
1628
- // Destroy existing Moleculer service for this addon
1629
- try {
1630
- await (
1631
- this.moleculer.broker as unknown as {
1632
- destroyService(name: string): Promise<void>
1633
- }
1634
- ).destroyService(addonId)
1635
- } catch {
1636
- // Service may not exist if it was never registered
1637
- }
1638
-
1639
- entry.initialized = false
1640
-
1641
- // Re-initialize and re-wire capabilities
1642
- await this.initializeAddon(addonId)
1643
- this.wireCapabilities(addonId)
1644
-
1645
- this.logAddonLifecycle('restarted', addonId, 'in-process')
1646
- this.emitAddonLifecycleEvent('addon.restarted', addonId)
1647
- return { success: true }
1648
- } catch (err) {
1649
- const msg = errMsg(err)
1650
- this.logger.error('Failed to restart addon', { tags: { addonId }, meta: { error: msg } })
1651
- this.emitAddonLifecycleEvent('addon.error', addonId, {
1652
- error: msg,
1653
- action: 'restart',
1654
- })
1655
- // Record the failure on the health monitor so the addon shows up
1656
- // `failed` with its `lastError` instead of a stale `healthy`. The
1657
- // in-process branch already unregistered every capability before
1658
- // `initializeAddon` threw — without this the addon is invisible-
1659
- // broken: caps gone, health green, nothing on the Addons page.
1660
- // The "operator sees the error via the mutation result" assumption
1661
- // only holds for a UI-clicked restart; a `camstack deploy` or a
1662
- // `requiresRestart` auto-restart is fire-and-forget — the failure
1663
- // would otherwise vanish. recordFailure also arms the retry loop.
1664
- this.healthMonitor.recordFailure(entry.packageName, err, addonId)
1665
- return { success: false, error: msg }
1666
- } finally {
1667
- // Clear the suppression flag regardless of success/failure — if the
1668
- // restart failed, the operator will see the real error via the
1669
- // mutation result rather than a misleading transient health blip.
1670
- const timer = this.restartingAddons.get(addonId)
1671
- if (timer) clearTimeout(timer)
1672
- this.restartingAddons.delete(addonId)
1673
- }
1674
- }
1675
-
1676
- getAddon(id: string): ICamstackAddon | null {
1677
- const entry = this.addonEntries.get(id)
1678
- return entry?.addon ?? null
1679
- }
1680
-
1681
- getAddonEntry(id: string): { addon: ICamstackAddon } | null {
1682
- const entry = this.addonEntries.get(id)
1683
- return entry ? { addon: entry.addon } : null
1684
- }
1685
-
1686
- /**
1687
- * Returns the on-disk package directory for the given addon ID.
1688
- * Used by the /api/addon-assets route to serve static files (e.g. SVG icons).
1689
- *
1690
- * Falls back to a disk scan when the addon isn't in `addonEntries`
1691
- * (typical for failed-to-load packages: dist/manifest still on disk
1692
- * but the import threw, so the registry never recorded an entry).
1693
- * Without this fallback the icon endpoint 404s and the operator
1694
- * sees a broken-image placeholder for any failed addon.
1695
- */
1696
- getAddonPackageDir(id: string): string | null {
1697
- const entry = this.addonEntries.get(id)
1698
- if (entry?.addonDir) return entry.addonDir
1699
- return this.findAddonDirOnDisk(id)
1700
- }
1701
-
1702
- private findAddonDirOnDisk(addonId: string): string | null {
1703
- const dataDir = path.resolve(process.env.CAMSTACK_DATA ?? 'camstack-data')
1704
- const addonsDir = path.resolve(dataDir, 'addons')
1705
- if (!fs.existsSync(addonsDir)) return null
1706
-
1707
- const visit = (pkgDir: string): string | null => {
1708
- const pkgJsonPath = path.join(pkgDir, 'package.json')
1709
- if (!fs.existsSync(pkgJsonPath)) return null
1710
- try {
1711
- const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')) as Record<string, unknown>
1712
- const camstack = (pkg['camstack'] as { addons?: unknown[] } | undefined) ?? {}
1713
- const addons = Array.isArray(camstack.addons)
1714
- ? (camstack.addons as Record<string, unknown>[])
1715
- : []
1716
- if (addons.some((a) => a['id'] === addonId)) return pkgDir
1717
- } catch {
1718
- return null
1719
- }
1720
- return null
1721
- }
1722
-
1723
- const entries = fs.readdirSync(addonsDir, { withFileTypes: true })
1724
- for (const entry of entries) {
1725
- if (!entry.isDirectory()) continue
1726
- const dirName = entry.name
1727
- if (dirName === 'node_modules' || dirName.startsWith('.')) continue
1728
- const fullPath = path.join(addonsDir, dirName)
1729
- if (dirName.startsWith('@')) {
1730
- for (const sub of fs.readdirSync(fullPath, { withFileTypes: true })) {
1731
- if (!sub.isDirectory()) continue
1732
- const found = visit(path.join(fullPath, sub.name))
1733
- if (found) return found
1734
- }
1735
- } else {
1736
- const found = visit(fullPath)
1737
- if (found) return found
1738
- }
1739
- }
1740
- return null
1741
- }
1742
-
1743
- /**
1744
- * Resolve an addon's on-disk installation directory and the dist
1745
- * sub-directory that contains its built entry. For singleton-package
1746
- * addons the dist sub-directory is `dist/`; for bundled addons (where
1747
- * one npm package ships multiple addon entries) it's `dist/<entryId>/`.
1748
- *
1749
- * Used by the static-file route handler that serves widget bundles
1750
- * (`/api/addon-widgets/:addonId/*`) — post bundle merge, the addonId →
1751
- * package-path mapping is no longer 1:1, so callers MUST go through
1752
- * the registry instead of guessing `addons/@camstack/addon-<id>/dist/`.
1753
- *
1754
- * Returns null if the addonId is unknown or the entry's package.json
1755
- * has no `entry` field. The returned `distDir` is an absolute path.
1756
- */
1757
- getAddonInstallPath(addonId: string): { addonDir: string; distDir: string } | null {
1758
- const entry = this.addonEntries.get(addonId)
1759
- if (!entry?.addonDir || !entry.declaration?.entry) return null
1760
- const distSubdir = path.dirname(entry.declaration.entry)
1761
- return {
1762
- addonDir: entry.addonDir,
1763
- distDir: path.resolve(entry.addonDir, distSubdir),
1764
- }
1765
- }
1766
-
1767
- /**
1768
- * Build an AddonContext for a given addon — same as what initialize() receives.
1769
- * Used by the benchmark system to create fresh addon instances with custom config.
1770
- */
1771
- async buildAddonContext(addonId: string): Promise<AddonContext | null> {
1772
- const entry = this.addonEntries.get(addonId)
1773
- if (!entry) return null
1774
- return this.createAddonContext(entry.addon)
1775
- }
1776
-
1777
- /**
1778
- * True when the entry's manifest opted into protection (uninstall
1779
- * disabled). The flag is per-addon (`camstack.addons[N].protected`)
1780
- * but the kernel uninstall API operates on packages — so a package
1781
- * is treated as protected if ANY of its addons declares the flag.
1782
- * Computed at call time from the running registry; no hardcoded
1783
- * package list to keep in sync.
1784
- */
1785
- isPackageProtected(packageName: string): boolean {
1786
- for (const entry of this.addonEntries.values()) {
1787
- if (entry.packageName !== packageName) continue
1788
- if (entry.declaration?.protected === true) return true
1789
- }
1790
- return false
1791
- }
1792
-
1793
- /**
1794
- * True when the entry boots in its own forked runner. This MUST mirror
1795
- * the fork authority `buildAddonGroupPlan`, which spawns a runner for
1796
- * every addon that ships an on-disk `addonDir` + a manifest
1797
- * `declaration`, is NOT a `@camstack/core` builtin, and is NOT
1798
- * `agent-only`. The `execution` block is OPTIONAL — an addon with no
1799
- * `execution` declaration still forks on its own dedicated runner
1800
- * (`resolveRunnerId` falls back to the addon id). Requiring `execution`
1801
- * here previously diverged from `buildAddonGroupPlan`: an addon like
1802
- * `auth-oidc` (no `execution` block) was forked at boot yet classified
1803
- * in-process by this predicate, so its route-mount took the co-located
1804
- * path and received the async UDS cap proxy whose `getRoutes()` returns
1805
- * a Promise (→ `getRoutes(...).map is not a function`). Only
1806
- * `@camstack/core` builtins boot in-process on the hub. The type
1807
- * predicate narrows both `addonDir` and `declaration` for callers.
1808
- */
1809
- private isForkedAddonEntry(
1810
- entry: AddonEntry,
1811
- ): entry is AddonEntry & { addonDir: string; declaration: AddonDeclaration } {
1812
- return !!(
1813
- entry.declaration &&
1814
- entry.addonDir &&
1815
- entry.packageName !== '@camstack/core' &&
1816
- resolveAddonPlacement(entry.declaration) !== 'agent-only'
1817
- )
1818
- }
1819
-
1820
- /** Per-entry helper used by `listAddons()` to mark a row removable / not. */
1821
- private isRequiredEntry(entry: AddonEntry): boolean {
1822
- if (entry.declaration?.protected === true) return true
1823
- // Fall back to a sibling addon in the same package — the
1824
- // protected-by-association rule above. Catches the case where the
1825
- // SAME package ships an aggregator addon (protected: true) plus
1826
- // a source addon (protected: false): both rows should render
1827
- // as non-removable because uninstalling the package would tear
1828
- // out both.
1829
- return this.isPackageProtected(entry.packageName)
1830
- }
1831
-
1832
- listAddons(): Array<{
1833
- manifest: AddonDeclaration & {
1834
- packageName: string
1835
- packageVersion: string
1836
- packageDisplayName?: string
1837
- bundle?: { displayName: string; description?: string; icon?: string }
1838
- protected?: boolean
1839
- removable?: boolean
1840
- }
1841
- declaration?: AddonDeclaration
1842
- source: AddonSource
1843
- installSource?: 'npm' | 'local' | 'upload'
1844
- process?: { pid?: number; mode: 'in-process'; state: string }
1845
- }> {
1846
- // Build process info map from in-memory data (sync, no stats)
1847
- const processMap = new Map<string, { pid?: number; mode: 'in-process'; state: string }>()
1848
- for (const [id, entry] of this.addonEntries) {
1849
- if (entry.initialized) {
1850
- processMap.set(`addon:${id}`, {
1851
- pid: process.pid,
1852
- mode: 'in-process',
1853
- state: 'running',
1854
- })
1855
- }
1856
- }
1857
-
1858
- const live = Array.from(this.addonEntries.values())
1859
- .filter((entry) => entry.addon?.manifest?.id)
1860
- .map((entry) => {
1861
- let installSource: 'npm' | 'local' | 'upload' | undefined
1862
- if (entry.addonDir) {
1863
- try {
1864
- const markerPath = path.join(entry.addonDir, '.install-source')
1865
- if (fs.existsSync(markerPath)) {
1866
- const raw = fs.readFileSync(markerPath, 'utf-8').trim()
1867
- // Normalize legacy 'workspace' → 'local'
1868
- const normalized = raw === 'workspace' ? 'local' : raw
1869
- if (normalized === 'npm' || normalized === 'local' || normalized === 'upload') {
1870
- installSource = normalized
1871
- }
1872
- }
1873
- } catch (err) {
1874
- this.logger.debug('Failed to read install-source marker for addon', {
1875
- meta: { error: errMsg(err) },
1876
- })
1877
- }
1878
- }
1879
- return {
1880
- manifest: {
1881
- // Overlay the fresh on-disk declaration (refreshed by
1882
- // `loadNewAddons` on every `camstack deploy`) onto the live
1883
- // instance manifest, so a redeployed addon's new capabilities /
1884
- // brokerKind surface here without a full backend restart. See
1885
- // `overlayDeclaration` — this is what keeps Home Assistant
1886
- // (post broker rework: broker + device-adoption) visible in the
1887
- // "+ New Integration" picker.
1888
- ...overlayDeclaration(entry.addon.manifest!, entry.declaration),
1889
- packageName: entry.packageName,
1890
- packageVersion: entry.packageVersion,
1891
- packageDisplayName: entry.packageDisplayName,
1892
- ...(entry.bundle !== undefined ? { bundle: entry.bundle } : {}),
1893
- protected: this.isRequiredEntry(entry) || undefined,
1894
- removable: this.isRequiredEntry(entry) ? false : undefined,
1895
- },
1896
- declaration: entry.declaration,
1897
- source: entry.source,
1898
- installSource,
1899
- process: processMap.get(`addon:${entry.addon.manifest!.id}`),
1900
- }
1901
- })
1902
-
1903
- // Surface packages that exist on disk but failed to load — typical
1904
- // causes are dep-version mismatch, missing native binary, or a
1905
- // corrupted dist. Without this, an operator sees "installed" via
1906
- // the package manifest but can't find the row in the addons UI to
1907
- // diagnose or uninstall, leaving the package stranded.
1908
- const seenPackages = new Set(live.map((row) => row.manifest.packageName))
1909
- const failed = this.scanFailedToLoadPackages(seenPackages)
1910
- return [...live, ...failed]
1911
- }
1912
-
1913
- /**
1914
- * Walk `<dataDir>/addons/` for `package.json` files whose declared
1915
- * `camstack.addons[]` entries don't appear in `addonEntries`. Returns
1916
- * synthetic listing rows so the UI can show + delete them. The rows
1917
- * carry a `process.state: 'failed'` flag so the AddonCard can render
1918
- * the diagnostic state distinctly from a normal addon.
1919
- */
1920
- private scanFailedToLoadPackages(seenPackages: ReadonlySet<string>): Array<{
1921
- manifest: AddonDeclaration & {
1922
- packageName: string
1923
- packageVersion: string
1924
- packageDisplayName?: string
1925
- protected?: boolean
1926
- removable?: boolean
1927
- }
1928
- declaration?: AddonDeclaration
1929
- source: AddonSource
1930
- installSource?: 'npm' | 'local' | 'upload'
1931
- process?: { pid?: number; mode: 'in-process'; state: string }
1932
- }> {
1933
- const dataDir = path.resolve(process.env.CAMSTACK_DATA ?? 'camstack-data')
1934
- const addonsDir = path.resolve(dataDir, 'addons')
1935
- if (!fs.existsSync(addonsDir)) return []
1936
-
1937
- type Row =
1938
- ReturnType<AddonRegistryService['scanFailedToLoadPackages']> extends Array<infer R>
1939
- ? R
1940
- : never
1941
- const out: Row[] = []
1942
- const visit = (pkgDir: string): void => {
1943
- const pkgJsonPath = path.join(pkgDir, 'package.json')
1944
- if (!fs.existsSync(pkgJsonPath)) return
1945
- let pkg: Record<string, unknown>
1946
- try {
1947
- pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')) as Record<string, unknown>
1948
- } catch {
1949
- return
1950
- }
1951
- const packageName = typeof pkg['name'] === 'string' ? pkg['name'] : ''
1952
- const packageVersion = typeof pkg['version'] === 'string' ? pkg['version'] : '0.0.0'
1953
- if (!packageName || seenPackages.has(packageName)) return
1954
- const camstack = (pkg['camstack'] as { addons?: unknown[] } | undefined) ?? {}
1955
- const addons = Array.isArray(camstack.addons)
1956
- ? (camstack.addons as Record<string, unknown>[])
1957
- : []
1958
- if (addons.length === 0) return
1959
-
1960
- let installSource: 'npm' | 'local' | 'upload' | undefined
1961
- try {
1962
- const markerPath = path.join(pkgDir, '.install-source')
1963
- if (fs.existsSync(markerPath)) {
1964
- const raw = fs.readFileSync(markerPath, 'utf-8').trim()
1965
- const normalized = raw === 'workspace' ? 'local' : raw
1966
- if (normalized === 'npm' || normalized === 'local' || normalized === 'upload') {
1967
- installSource = normalized
1968
- }
1969
- }
1970
- } catch {
1971
- /* non-critical */
1972
- }
1973
-
1974
- // Manifest-driven protection — agree with the live-rows path.
1975
- const isProtected = addons.some((a) => a['protected'] === true)
1976
- const packageDisplayName = (camstack as { displayName?: string }).displayName
1977
- // Bundle metadata — present when the package ships multiple addon
1978
- // entries that should render as collapsible children in the UI.
1979
- const bundleRaw = (
1980
- camstack as { bundle?: { displayName?: string; description?: string; icon?: string } }
1981
- ).bundle
1982
- const bundle =
1983
- bundleRaw && typeof bundleRaw.displayName === 'string'
1984
- ? {
1985
- displayName: bundleRaw.displayName,
1986
- ...(bundleRaw.description !== undefined
1987
- ? { description: bundleRaw.description }
1988
- : {}),
1989
- ...(bundleRaw.icon !== undefined ? { icon: bundleRaw.icon } : {}),
1990
- }
1991
- : undefined
1992
-
1993
- for (const decl of addons) {
1994
- const id = typeof decl['id'] === 'string' ? decl['id'] : ''
1995
- const name = typeof decl['name'] === 'string' ? decl['name'] : id
1996
- if (!id) continue
1997
- const manifest: Row['manifest'] = {
1998
- ...(decl as unknown as AddonDeclaration),
1999
- id,
2000
- name,
2001
- packageName,
2002
- packageVersion,
2003
- ...(packageDisplayName ? { packageDisplayName } : {}),
2004
- ...(bundle !== undefined ? { bundle } : {}),
2005
- protected: isProtected || undefined,
2006
- removable: !isProtected,
2007
- }
2008
- out.push({
2009
- manifest,
2010
- declaration: decl as unknown as AddonDeclaration,
2011
- source: 'installed' as AddonSource,
2012
- ...(installSource ? { installSource } : {}),
2013
- process: { mode: 'in-process' as const, state: 'failed' },
2014
- })
2015
- }
2016
- }
2017
-
2018
- const entries = fs.readdirSync(addonsDir, { withFileTypes: true })
2019
- for (const entry of entries) {
2020
- if (!entry.isDirectory()) continue
2021
- const dirName = entry.name
2022
- if (dirName === 'node_modules' || dirName.startsWith('.')) continue
2023
- const fullPath = path.join(addonsDir, dirName)
2024
- // Scoped: walk one level deeper.
2025
- if (dirName.startsWith('@')) {
2026
- const scopedEntries = fs.readdirSync(fullPath, { withFileTypes: true })
2027
- for (const sub of scopedEntries) {
2028
- if (sub.isDirectory()) visit(path.join(fullPath, sub.name))
2029
- }
2030
- } else {
2031
- visit(fullPath)
2032
- }
2033
- }
2034
- return out
2035
- }
2036
-
2037
- /**
2038
- * List all addons including core builtins.
2039
- * Each addon carries packageName/packageVersion from its package.json,
2040
- * so the frontend can group by package automatically.
2041
- */
2042
- listAllAddons() {
2043
- return this.listAddons()
2044
- }
2045
-
2046
- /**
2047
- * List all addon processes (in-process addons)
2048
- * as ManagedProcessStatus-compatible entries for the process tree.
2049
- */
2050
- async listAddonProcesses(): Promise<
2051
- ReadonlyArray<{
2052
- id: string
2053
- label: string
2054
- state: 'running' | 'stopped' | 'crashed' | 'starting'
2055
- pid?: number
2056
- stats?: {
2057
- pid: number
2058
- cpu: number
2059
- memory: number
2060
- uptime: number
2061
- restartCount: number
2062
- }
2063
- restartCount: number
2064
- mode: 'in-process'
2065
- }>
2066
- > {
2067
- const result: Array<{
2068
- id: string
2069
- label: string
2070
- state: 'running' | 'stopped' | 'crashed' | 'starting'
2071
- pid?: number
2072
- stats?: {
2073
- pid: number
2074
- cpu: number
2075
- memory: number
2076
- uptime: number
2077
- restartCount: number
2078
- }
2079
- restartCount: number
2080
- mode: 'in-process'
2081
- }> = []
2082
-
2083
- // Collect hub PID and fetch stats
2084
- const { getPidStats } = await import('@camstack/core')
2085
- const pidStats = await getPidStats([process.pid])
2086
-
2087
- // In-process addons
2088
- const hubStats = pidStats.get(process.pid)
2089
- for (const [id, entry] of this.addonEntries) {
2090
- if (entry.initialized) {
2091
- result.push({
2092
- id: `addon:${id}`,
2093
- label: `Addon: ${id} (in-process)`,
2094
- state: 'running',
2095
- pid: process.pid,
2096
- stats: {
2097
- pid: process.pid,
2098
- cpu: hubStats?.cpu ?? 0,
2099
- memory: hubStats?.memory ?? 0,
2100
- uptime: Math.round(process.uptime()),
2101
- restartCount: 0,
2102
- },
2103
- restartCount: 0,
2104
- mode: 'in-process',
2105
- })
2106
- }
2107
- }
2108
-
2109
- return result
2110
- }
2111
-
2112
- /**
2113
- * Phase 11 (settings redesign): `getAddonConfigSchema`,
2114
- * `getAddonConfig`, and `updateAddonConfig` — the legacy adapter
2115
- * shim that synthesised a plain `ConfigUISchema` view from the new
2116
- * three-level methods — are gone. All settings traffic goes through
2117
- * the six Phase 5 tRPC endpoints (`addons.getAddonSettings`,
2118
- * `updateAddonSettings`, `getGlobalSettings`, `updateGlobalSettings`,
2119
- * `getDeviceSettings`, `updateDeviceSettings`) which call the
2120
- * addon's methods directly via the `addon-settings` singleton capability.
2121
- */
2122
-
2123
- /**
2124
- * Scan data/addons/ for new packages and initialize any addon not already registered.
2125
- * Called after install/uninstall to hot-load new addons without server restart.
2126
- */
2127
- async loadNewAddons(): Promise<{ loaded: string[]; failed: string[] }> {
2128
- const dataDir = path.resolve(process.env.CAMSTACK_DATA ?? 'camstack-data')
2129
- const addonsDir = path.resolve(dataDir, 'addons')
2130
-
2131
- // Create a fresh loader to discover what's on disk (for diffing against existing)
2132
- const freshLoader = new AddonLoader(this.loggingService.createLogger('AddonLoader'))
2133
- await freshLoader.loadFromDirectory(addonsDir)
2134
-
2135
- const loaded: string[] = []
2136
- const failed: string[] = []
2137
-
2138
- for (const registered of freshLoader.listAddons()) {
2139
- // Already-registered addon: refresh the in-memory metadata (the
2140
- // `packageVersion`/`packageDisplayName`/`declaration`/`bundle`
2141
- // fields stored on the entry) from the fresh disk scan. Without
2142
- // this step a hot-update via `installFromTgz` would leave
2143
- // `entry.packageVersion` stale — the forked group-runner picks up
2144
- // the new code on `restartAddon` but the UI + listAddons() keep
2145
- // showing the OLD version. We deliberately keep `entry.addon`
2146
- // (the running instance) and `entry.initialized` untouched so
2147
- // `restartAddon` still shuts the live instance down cleanly.
2148
- const existing = this.addonEntries.get(registered.declaration.id)
2149
- if (existing) {
2150
- existing.packageVersion = registered.packageVersion
2151
- existing.packageName = registered.packageName
2152
- existing.packageDisplayName = registered.packageDisplayName
2153
- if (registered.bundle !== undefined) {
2154
- existing.bundle = registered.bundle
2155
- }
2156
- existing.declaration = registered.declaration
2157
- existing.declaredCapabilities = (registered.declaration.capabilities ?? []).map(
2158
- (c: string | CapabilityDeclaration) =>
2159
- typeof c === 'string' ? { name: c, mode: 'singleton' as const } : c,
2160
- )
2161
- continue
2162
- }
2163
-
2164
- // Skip agent-only addons on the hub. Same rule as the boot loader
2165
- // path above; without it, post-install reload would re-init
2166
- // hub-side every agent-only provider (e.g. `hub-forwarder`),
2167
- // and a hub-resident `hub-forwarder` creates a write→ingest→
2168
- // write feedback loop because the hub IS the log-receiver.
2169
- if (isAgentOnlyPlacement(registered.declaration)) {
2170
- this.logger.debug('loadNewAddons: skipping agent-only addon on hub', {
2171
- tags: { addonId: registered.declaration.id },
2172
- })
2173
- continue
2174
- }
2175
-
2176
- if (registered.declaration.capabilities) {
2177
- for (const cap of registered.declaration.capabilities) {
2178
- this.capabilityRegistry.declareFromManifest(cap, registered.declaration.id)
2179
- }
2180
- }
2181
-
2182
- try {
2183
- const addon = freshLoader.createInstance(registered.declaration.id)
2184
- const declCaps = (registered.declaration.capabilities ?? []).map(
2185
- (c: string | CapabilityDeclaration) =>
2186
- typeof c === 'string' ? { name: c, mode: 'singleton' as const } : c,
2187
- )
2188
-
2189
- this.addonEntries.set(registered.declaration.id, {
2190
- addon,
2191
- initialized: false,
2192
- source: 'installed',
2193
- packageName: registered.packageName,
2194
- packageVersion: registered.packageVersion,
2195
- packageDisplayName: registered.packageDisplayName,
2196
- ...(registered.bundle !== undefined ? { bundle: registered.bundle } : {}),
2197
- declaredCapabilities: declCaps,
2198
- addonDir: path.join(addonsDir, registered.packageName),
2199
- declaration: registered.declaration,
2200
- })
2201
-
2202
- // Initialize the new addon
2203
- await this.initializeAddon(registered.declaration.id)
2204
- this.wireCapabilities(registered.declaration.id)
2205
- loaded.push(registered.declaration.id)
2206
- this.logger.info('Hot-loaded addon', {
2207
- tags: { addonId: registered.declaration.id },
2208
- meta: { packageName: registered.packageName },
2209
- })
2210
- this.emitAddonLifecycleEvent('addon.installed', registered.declaration.id)
2211
- } catch (err) {
2212
- const msg = errMsg(err)
2213
- const stack = err instanceof Error ? err.stack : undefined
2214
- this.logger.error('Failed to hot-load addon', {
2215
- tags: { addonId: registered.declaration.id },
2216
- meta: stack ? { error: msg, stack } : { error: msg },
2217
- })
2218
- this.emitAddonLifecycleEvent('addon.crashed', registered.declaration.id, { error: msg })
2219
- failed.push(registered.declaration.id)
2220
- }
2221
- }
2222
-
2223
- // Remove addons that are no longer on disk — the uninstall path.
2224
- // D5: every non-core addon lives in its own runner subprocess. The
2225
- // uninstall consolidation is "stop the runner"; the runner exits and
2226
- // re-handshakes the hub WITHOUT the removed addon (D3 machinery).
2227
- // Only `@camstack/core` builtins — which have no runner — take the
2228
- // in-process `shutdown()` + disposer-drain path.
2229
- const onDiskIds = new Set(
2230
- freshLoader.listAddons().map((a: { declaration: { id: string } }) => a.declaration.id),
2231
- )
2232
- for (const [id, entry] of this.addonEntries) {
2233
- if (entry.source === 'installed' && !onDiskIds.has(id)) {
2234
- // Unregister capabilities before stopping the addon.
2235
- for (const cap of entry.declaredCapabilities) {
2236
- this.capabilityRegistry.unregisterProvider(cap.name, id)
2237
- }
2238
- const manifestCaps = this.getAddonCapabilities(entry.addon)
2239
- for (const cap of manifestCaps) {
2240
- this.capabilityRegistry.unregisterProvider(cap.name, id)
2241
- }
2242
- this.capabilityRegistry.unregisterAllWrappersForAddon(id)
2243
- // Task 7.1: drop custom actions for removed addons.
2244
- this.customActionRegistry.unregisterAddon(id)
2245
-
2246
- if (this.isForkedAddonEntry(entry) && entry.initialized) {
2247
- // Forked addon — stop the runner subprocess. `$process.stop`
2248
- // kills the child and force-evicts its Moleculer node, so the
2249
- // hub's cap registry drops the gone provider with no per-
2250
- // operation hub bookkeeping.
2251
- try {
2252
- await this.broker.call('$process.stop', { name: id })
2253
- } catch (err) {
2254
- this.logger.warn('Non-fatal: failed to stop runner for removed addon', {
2255
- tags: { addonId: id },
2256
- meta: { error: errMsg(err) },
2257
- })
2258
- }
2259
- } else if (entry.initialized) {
2260
- // `@camstack/core` builtin — reload model is in-process.
2261
- try {
2262
- await entry.addon.shutdown()
2263
- } catch (err) {
2264
- this.logger.debug('Non-fatal: shutdown error for removed addon', {
2265
- tags: { addonId: id },
2266
- meta: { error: errMsg(err) },
2267
- })
2268
- }
2269
- await this.drainDisposerChain(id)
2270
- }
2271
- this.addonEntries.delete(id)
2272
- this.logger.info('Removed addon (no longer on disk)', { tags: { addonId: id } })
2273
- }
2274
- }
2275
-
2276
- return { loaded, failed }
2277
- }
2278
-
2279
- async shutdownAll(): Promise<void> {
2280
- for (const [id, entry] of this.addonEntries) {
2281
- if (entry.initialized) {
2282
- const capabilities = this.getAddonCapabilities(entry.addon)
2283
- for (const cap of capabilities) {
2284
- this.capabilityRegistry.unregisterProvider(cap.name, id)
2285
- }
2286
- this.capabilityRegistry.unregisterAllWrappersForAddon(id)
2287
- // Task 7.1: drop custom actions on full shutdown.
2288
- this.customActionRegistry.unregisterAddon(id)
2289
- await entry.addon.shutdown()
2290
- await this.drainDisposerChain(id)
2291
- entry.initialized = false
2292
- }
2293
- }
2294
- }
2295
-
2296
- // --- Private helpers ---
2297
-
2298
- // registerConsumers() — DELETED. Consumer wiring now in wireCapabilityConsumers().
2299
-
2300
- /**
2301
- * v2: wire capability consumer actions via EventBus.
2302
- * Listens to 'capability:provider-registered' events from CapabilityRegistry
2303
- * and dispatches setup actions (storage wiring, decoder↔broker, etc).
2304
- *
2305
- * Replaces the 11 registerConsumer callbacks from v1.
2306
- */
2307
- private wireCapabilityConsumers(): void {
2308
- if (!this.capabilityRegistry) return
2309
-
2310
- this.eventBusService.subscribe({ category: 'capability:provider-registered' }, (event) => {
2311
- const rawCapability = event.data['capability']
2312
- const rawAddonId = event.data['addonId']
2313
- if (typeof rawCapability !== 'string' || typeof rawAddonId !== 'string') return
2314
- const capability = rawCapability
2315
- const addonId = rawAddonId
2316
-
2317
- // Broadcast readiness on the hub-node scope so subprocess
2318
- // brokers waiting on this cap (e.g. provider-rtsp's
2319
- // `system.ready-state` listener for stream-broker, or
2320
- // `runWorkerDeviceRestoreWithRetry` waiting on
2321
- // device-manager) wake up. We emit ONLY `{ type: 'node',
2322
- // nodeId: 'hub' }` — most consumer filters are scope-agnostic
2323
- // (they match on capName + state), so emitting both `node` and
2324
- // `global` causes duplicate fan-out (e.g. provider-rtsp's
2325
- // `republishAll` running twice → repeated `dispatchCamera`
2326
- // loops). The node-scoped emit is sufficient: the hydrate
2327
- // path on subprocess brokers replays whichever records the
2328
- // hub's `$readiness.getSnapshot` returns, scope and all.
2329
- try {
2330
- this.moleculer.readinessRegistry.emitReady(capability, { type: 'node', nodeId: 'hub' })
2331
- } catch (err) {
2332
- this.logger.warn('emitReady failed', {
2333
- meta: { capability, addonId, error: errMsg(err) },
2334
- })
2335
- }
2336
-
2337
- switch (capability) {
2338
- case 'storage': {
2339
- // Storage-unification refactor (Task 8) — the consumer-
2340
- // facing `storage` cap is now a singleton owned by the
2341
- // `storage-orchestrator` builtin, exposing the codegen'd
2342
- // async `IStorageCapProvider` surface. The legacy
2343
- // synchronous `INewStorageProvider` (filesystem-only) used
2344
- // by `StorageService.setNewStorageProvider` and the
2345
- // `addons-data` dataDir resolution at boot is no longer
2346
- // wired here — filesystem-storage now registers under
2347
- // `storage-provider` (the upstream collection cap), and
2348
- // legacy callers fall back to the deterministic
2349
- // `camstack-data/addons-data/<addonId>` path. Task 17 will
2350
- // migrate those callers off `INewStorageProvider`
2351
- // entirely.
2352
- break
2353
- }
2354
-
2355
- case 'settings-store': {
2356
- const provider = this.capabilityRegistry.getProviderByAddon('settings-store', addonId)
2357
- if (!provider) return
2358
- this.activeSettingsBackend = provider
2359
- if (isSettingsStore(provider)) {
2360
- this.configService.setSettingsStore(provider)
2361
- }
2362
- this.storageService.setSettingsBackend(provider)
2363
- this.integrationRegistry = new IntegrationRegistry(provider)
2364
- void this.integrationRegistry.initialize().then(() => {
2365
- this.logger.info('IntegrationRegistry initialized', { meta: { phase: 'v2' } })
2366
- })
2367
- this.loadCollectionPreferences()
2368
- // DeviceStore/ConfigStore are owned by the `device-persistence`
2369
- // capability addon — no longer created here. The addon boots
2370
- // after `sqlite-settings` and extracts the DB handle via the
2371
- // capability registry.
2372
- this.logger.info('Settings backend wired', { meta: { phase: 'v2' } })
2373
- break
2374
- }
2375
-
2376
- case 'log-destination': {
2377
- const provider = this.capabilityRegistry.getProviderByAddon('log-destination', addonId)
2378
- if (!provider) return
2379
- this.loggingService.addDestination(provider)
2380
- this.logger.info('Log destination added', { meta: { phase: 'v2' } })
2381
- break
2382
- }
2383
-
2384
- case 'restreamer':
2385
- case 'webrtc':
2386
- case 'decoder':
2387
- case 'stream-broker':
2388
- // No wiring needed — consumers read from capabilityRegistry on demand.
2389
- break
2390
-
2391
- case 'addon-routes': {
2392
- // Route mounting is async for forked/group addons — the
2393
- // provider is a Moleculer proxy whose `getRoutes()` returns
2394
- // a Promise (the wire round-trips through the worker). The
2395
- // EventBus subscriber callback is synchronous, so delegate
2396
- // to an async helper and surface any failure via the
2397
- // logger instead of letting a rejected promise (or a
2398
- // `liveRoutes.some is not a function` TypeError) escape the
2399
- // subscriber unobserved.
2400
- void this.mountAddonRoutes(addonId).catch((err: unknown) => {
2401
- this.logger.error('Failed to mount addon routes', {
2402
- meta: { phase: 'v2', addonId, error: errMsg(err) },
2403
- })
2404
- })
2405
- break
2406
- }
2407
-
2408
- default:
2409
- break
2410
- }
2411
-
2412
- // HTTP data-planes aren't capabilities, so no `case` fires for them —
2413
- // pull them off ANY cap-changed signal for this (forked) addon. Cheap +
2414
- // idempotent (replace-all), and re-pulls the fresh baseUrl/secret after a
2415
- // restart (the child re-handshakes → caps re-register → this re-fires).
2416
- void this.mountAddonDataPlanes(addonId).catch((err: unknown) => {
2417
- this.logger.error('Failed to mount addon data-planes', {
2418
- meta: { phase: 'v2', addonId, error: errMsg(err) },
2419
- })
2420
- })
2421
- })
2422
- }
2423
-
2424
- /**
2425
- * Mount the `addon-routes` provider for `addonId` into the
2426
- * `AddonRouteRegistry`. Handles both co-located (in-process) and
2427
- * forked addons:
2428
- *
2429
- * - For a hub-local FORKED addon the route handlers live in the
2430
- * child process and cannot cross the UDS wire (MsgPack can't encode
2431
- * a function). We fetch the handler-stripped route descriptors over
2432
- * UDS via `LocalChildRegistry.callAddonOnChild(addonId,
2433
- * {target:'routes'})` (F3 — replaces the removed per-addon Moleculer
2434
- * `getRoutes` action), synthesize bridge handlers that dispatch
2435
- * through the `addon-routes` cap's `invoke(...)` method (a normal
2436
- * serializable cap call over UDS), and translate the captured
2437
- * envelope back onto the Fastify reply.
2438
- * - For a co-located (hub-resident) addon `getRoutes()` resolves to the
2439
- * live route list with real handlers, so we register the provider
2440
- * directly.
2441
- */
2442
- private async mountAddonRoutes(addonId: string): Promise<void> {
2443
- if (!this.capabilityRegistry || !this.addonRouteRegistry) return
2444
- const addonRouteRegistry = this.addonRouteRegistry
2445
- const routeProvider = this.capabilityRegistry.getProviderByAddon('addon-routes', addonId)
2446
- if (!routeProvider) return
2447
-
2448
- // ── Forked addon: fetch handler-stripped routes over UDS (F3) ──────
2449
- // EVERY non-`@camstack/core` addon forks (the fork authority is
2450
- // `buildAddonGroupPlan`, which does NOT require an `execution` block — an
2451
- // addon like `auth-oidc` with no `execution` still runs in its own
2452
- // runner). `isForkedAddonEntry` now mirrors that rule. For a forked addon
2453
- // the `getProviderByAddon('addon-routes', …)` result is the async UDS cap
2454
- // proxy whose `getRoutes()` returns a Promise — registering it directly
2455
- // would crash `AddonRouteRegistry.registerRoutes` (`getRoutes(...).map is
2456
- // not a function`), and dispatching the proxy's `getRoutes` cap method
2457
- // over UDS would try to MsgPack-serialize the child's live handler
2458
- // functions (→ "Unrecognized object: [object AsyncFunction]"). Instead we
2459
- // fetch handler-STRIPPED descriptors via `callAddonOnChild(target:
2460
- // 'routes')` and bridge each through the `invoke` cap method. The UDS
2461
- // childId for a hub-local single-addon runner equals the addonId
2462
- // (`resolveRunnerId` — no shipped addon declares a group). Gate on
2463
- // `isChildKnown` (the child completed its UDS handshake) rather than
2464
- // `childProvides('addon-routes')`: the `addon-call` route fetch works as
2465
- // long as the child is connected, and the child is added to the UDS
2466
- // registry BEFORE its manifest fires the cap-changed event that triggers
2467
- // this mount, so the gate is satisfied on the happy path.
2468
- const entry = this.addonEntries.get(addonId)
2469
- const childRegistry = this.moleculer.childRegistry
2470
- if (entry && this.isForkedAddonEntry(entry)) {
2471
- if (childRegistry !== null && childRegistry.isChildKnown(addonId)) {
2472
- await this.mountForkedAddonRoutes(addonId, routeProvider, addonRouteRegistry)
2473
- return
2474
- }
2475
- // Child not yet connected over UDS: defer rather than register the async
2476
- // proxy directly (which would crash on `.map`). The `addon-routes`
2477
- // cap-changed event re-fires once the child finishes its handshake.
2478
- this.logger.warn('Deferring forked addon route mount — child not yet UDS-reachable', {
2479
- meta: { phase: 'v2', addonId },
2480
- })
2481
- return
2482
- }
2483
-
2484
- // ── Co-located addon (`@camstack/core` builtin): live handlers, register
2485
- // the provider directly. The route handlers run in-process against the
2486
- // real Fastify reply, so no wire bridge is needed.
2487
- addonRouteRegistry.registerRoutes(routeProvider.id, routeProvider)
2488
- this.logger.info('Addon routes mounted', {
2489
- meta: { phase: 'v2', routeProviderId: routeProvider.id },
2490
- })
2491
- }
2492
-
2493
- /**
2494
- * Mount a hub-local FORKED addon's HTTP routes (F3). The route handlers live
2495
- * in the child process; only handler-stripped descriptors cross the UDS wire.
2496
- * We:
2497
- * 1. fetch the descriptors via `callAddonOnChild(addonId, {target:'routes'})`,
2498
- * 2. synthesize a bridge handler per route that dispatches the captured
2499
- * request through the `addon-routes` cap's `invoke(...)` method (a normal
2500
- * serializable UDS cap call on `routeProvider`), and
2501
- * 3. translate the returned reply envelope back onto the Fastify reply.
2502
- *
2503
- * `addonRouteRegistry` is narrowed non-null by the caller's guard in
2504
- * `mountAddonRoutes` — no assertion needed here.
2505
- */
2506
- private async mountForkedAddonRoutes(
2507
- addonId: string,
2508
- routeProvider: import('@camstack/types').IAddonRouteProvider,
2509
- addonRouteRegistry: AddonRouteRegistry,
2510
- ): Promise<void> {
2511
- // The cap-registry typed surface for `addon-routes` is the operator-facing
2512
- // `IAddonRouteProvider` (id + getRoutes). The bridge dispatch contract
2513
- // `invoke(...)` is present on the UDS proxy but not on that interface —
2514
- // narrow it via a structural type guard (no cast).
2515
- if (!isAddonRoutesInvoker(routeProvider)) {
2516
- this.logger.warn(
2517
- 'Forked addon-routes provider missing `invoke` method — routes will not dispatch. Use `buildAddonRouteProvider()` from @camstack/types.',
2518
- { meta: { phase: 'v2', routeProviderId: routeProvider.id } },
2519
- )
2520
- return
2521
- }
2522
- const invoker = routeProvider
2523
-
2524
- const rawRoutes = await this.addonCallGateway.callForked(addonId, {
2525
- target: 'routes',
2526
- })
2527
- const descriptors = parseSerializableRouteDescriptors(rawRoutes)
2528
- const bridgeRoutes: import('@camstack/types').IAddonHttpRoute[] = descriptors.map((route) => ({
2529
- method: route.method,
2530
- path: route.path,
2531
- access: route.access,
2532
- ...(route.description !== undefined ? { description: route.description } : {}),
2533
- handler: async (req, reply) => {
2534
- const envelope = await invoker.invoke({
2535
- method: route.method,
2536
- path: route.path,
2537
- params: req.params,
2538
- query: req.query,
2539
- body: req.body,
2540
- headers: req.headers,
2541
- ...(req.user ? { user: req.user } : {}),
2542
- ...(req.scopedToken !== undefined ? { scopedToken: req.scopedToken } : {}),
2543
- })
2544
- reply.code(envelope.status)
2545
- if (envelope.contentType) reply.type(envelope.contentType)
2546
- for (const [k, v] of Object.entries(envelope.headers)) reply.header(k, v)
2547
- if (envelope.redirectUrl !== null) {
2548
- reply.header('Location', envelope.redirectUrl)
2549
- reply.send('')
2550
- } else {
2551
- reply.send(envelope.body)
2552
- }
2553
- },
2554
- }))
2555
- addonRouteRegistry.registerRoutes(routeProvider.id, {
2556
- id: routeProvider.id,
2557
- getRoutes: () => bridgeRoutes,
2558
- })
2559
- this.logger.info('Addon routes mounted (forked-bridge over UDS)', {
2560
- meta: { phase: 'v2', routeProviderId: routeProvider.id, routes: bridgeRoutes.length },
2561
- })
2562
- }
2563
-
2564
- // Cleanup: `addonHasConfigFields` deleted. It was the last reader of
2565
- // the legacy `ICamstackAddon.getConfigSchema()` method, exposed via
2566
- // `listAddons().hasConfigSchema` for the old AddonCard settings gate.
2567
- // `NodeAddonsSettingsPanel` now gracefully shows an empty state when
2568
- // an addon doesn't implement `getGlobalSettings`, so the backend no
2569
- // longer needs to pre-compute this flag.
2570
-
2571
- private emitAddonLifecycleEvent(
2572
- eventType:
2573
- | 'addon.started'
2574
- | 'addon.stopped'
2575
- | 'addon.restarted'
2576
- | 'addon.updated'
2577
- | 'addon.installed'
2578
- | 'addon.uninstalled'
2579
- | 'addon.crashed'
2580
- | 'addon.error',
2581
- addonId: string,
2582
- data?: Record<string, unknown>,
2583
- ): void {
2584
- const entry = this.addonEntries.get(addonId)
2585
- this.eventBusService.emit({
2586
- id: randomUUID(),
2587
- timestamp: new Date(),
2588
- source: {
2589
- type: 'addon',
2590
- id: addonId,
2591
- nodeId: this.broker.nodeID,
2592
- },
2593
- category: eventType,
2594
- data: {
2595
- addonId,
2596
- packageName: entry?.packageName,
2597
- packageVersion: entry?.packageVersion,
2598
- agent: process.env.CAMSTACK_AGENT_NAME ?? 'hub',
2599
- ...data,
2600
- },
2601
- })
2602
- }
2603
-
2604
- /**
2605
- * Post-init hook: emit events for newly available capabilities.
2606
- * Provider registration now happens in initialize() via context.registerProvider().
2607
- */
2608
- private wireCapabilities(addonId: string): void {
2609
- const entry = this.addonEntries.get(addonId)
2610
- if (!entry?.initialized) return
2611
-
2612
- // Emit addon-pages event for UI notification. Cap name is
2613
- // `addon-pages-source` after the consolidation split — collection
2614
- // providers register on the source cap; the singleton aggregator
2615
- // owns `addon-pages` cluster-wide.
2616
- const declaredCaps = entry.declaredCapabilities.map((c) => c.name)
2617
- if (declaredCaps.includes('addon-pages-source')) {
2618
- this.eventBusService.emit({
2619
- id: randomUUID(),
2620
- timestamp: new Date(),
2621
- source: { type: 'addon', id: addonId },
2622
- category: EventCategory.AddonPageReady,
2623
- data: { addonId, packageName: entry.packageName },
2624
- })
2625
- }
2626
- // Symmetric for `addon-widgets-source` — addons that contribute
2627
- // widget bundles emit `AddonWidgetReady` so the
2628
- // <WidgetRegistryProvider> in admin-ui invalidates its aggregator
2629
- // query and the new bundle becomes available without a page
2630
- // reload.
2631
- if (declaredCaps.includes('addon-widgets-source')) {
2632
- this.eventBusService.emit({
2633
- id: randomUUID(),
2634
- timestamp: new Date(),
2635
- source: { type: 'addon', id: addonId },
2636
- category: EventCategory.AddonWidgetReady,
2637
- data: { addonId, packageName: entry.packageName },
2638
- })
2639
- }
2640
- }
2641
-
2642
- private getAddonCapabilities(addon: ICamstackAddon): CapabilityDeclaration[] {
2643
- const caps = addon?.manifest?.capabilities
2644
- if (!caps) return []
2645
-
2646
- return caps.map((cap): CapabilityDeclaration => (typeof cap === 'string' ? { name: cap } : cap))
2647
- }
2648
-
2649
- private findAddonForCapability(capName: string, addonIds: string[]): string | null {
2650
- for (const id of addonIds) {
2651
- const entry = this.addonEntries.get(id)
2652
- if (!entry) continue
2653
- // Use declaredCapabilities (from package.json) as source of truth
2654
- if (entry.declaredCapabilities.some((c) => c.name === capName)) return id
2655
- }
2656
- return null
2657
- }
2658
-
2659
- /**
2660
- * Build the addonConfig for a given addon — strictly per-addon now.
2661
- *
2662
- * The legacy cross-addon merge (hardcoded `ADDON_SYSTEM_SETTINGS` map
2663
- * copying `system_settings.<section>.*` into the bootstrap config) has
2664
- * been removed. Addons that need to read shared system settings
2665
- * sections (`ffmpeg`, `logging`, `recording`, …) do so at runtime via
2666
- * `ctx.settings.getGlobal({ section: '<name>' })`, which delegates to
2667
- * `ConfigManager.getSection()` and covers the full SQL → YAML →
2668
- * RUNTIME_DEFAULTS fallback chain.
2669
- *
2670
- * This keeps the kernel agnostic: every addon declares its own needs
2671
- * in its own `initialize(ctx)` code, with zero kernel-side addon id
2672
- * special-casing. The only remaining exception is `sqlite-settings`,
2673
- * which receives `_runtimeDefaults` for first-boot seeding (tracked
2674
- * separately under the kernel-cleanup roadmap — Point 3d).
2675
- */
2676
- private buildAddonConfig(addonId: string): Record<string, unknown> {
2677
- // Start with per-addon SQL settings (may be empty for most addons)
2678
- let addonSpecific: Record<string, unknown> = {}
2679
- try {
2680
- addonSpecific = this.configService.getAddonConfig(addonId)
2681
- } catch (err) {
2682
- this.logger.debug('ConfigManager not ready for addon', {
2683
- tags: { addonId },
2684
- meta: { error: errMsg(err) },
2685
- })
2686
- }
2687
-
2688
- // No addon id special-casing here anymore. Bootstrap-level
2689
- // concerns (e.g. first-boot seeding from RUNTIME_DEFAULTS) are
2690
- // owned by the addon itself — sqlite-settings imports the
2691
- // constant directly from `@camstack/types` instead of relying on
2692
- // a kernel-injected bootstrap field.
2693
- return addonSpecific
2694
- }
2695
-
2696
- private async createAddonContext(addon: ICamstackAddon): Promise<InternalAddonContext> {
2697
- const addonId = addon.manifest!.id
2698
- const brokerNodeId = this.broker.nodeID
2699
- const agentId = brokerNodeId.includes('/') ? brokerNodeId.split('/')[0]! : brokerNodeId
2700
- // No scope on the addon root logger — the brand bracket already shows
2701
- // `[agent/addonId]`, so `(addon:<addonId>)` was pure duplication.
2702
- // Sub-components add their own scope via `.child('<name>')`.
2703
- const logger = this.loggingService
2704
- .createLogger()
2705
- .withTags({ addonId, nodeId: brokerNodeId, agentId })
2706
- const bootstrapConfig = this.buildAddonConfig(addonId)
2707
-
2708
- // Per-addon private data directory — resolved from active storage
2709
- // provider if available, otherwise falls back to a deterministic
2710
- // hardcoded path. Addons that need the full storage provider now
2711
- // resolve it via the capability registry.
2712
- const storageProvider = this.activeStorageProvider
2713
- const dataDir = storageProvider
2714
- ? await storageProvider.resolve({
2715
- location: 'addons-data',
2716
- relativePath: addonId,
2717
- })
2718
- : `camstack-data/addons-data/${addonId}`
2719
-
2720
- const registerProvider = (capabilityName: string, provider: unknown): void => {
2721
- this.capabilityRegistry.registerProvider(capabilityName, addonId, provider)
2722
- logger.info('Registered provider via context.registerProvider()', {
2723
- meta: { capabilityName },
2724
- })
2725
- }
2726
-
2727
- // Raw three-level settings store API. The resolver service is a
2728
- // thin wrapper over `ConfigManager` that exposes raw reads/writes
2729
- // for the addon store, the per-device store, and the cluster-wide
2730
- // yml-backed sections. No schema merging happens here — the addon
2731
- // combines these raw reads with its own schema via
2732
- // `hydrateSchema()` inside `getAddonSettings / getGlobalSettings
2733
- // / getDeviceSettings`.
2734
- const settingsView: AddonSettingsView = this.configService.createSettingsView(addonId)
2735
-
2736
- // Device management — unified path for hub and worker addons.
2737
- // Persistence routes through the `device-manager` capability
2738
- // addon via `ctx.api.deviceManager.*`. On the hub, `ctx.api` is
2739
- // a broker-routed proxy that resolves the local
2740
- // `device-manager` Moleculer service in-process — no network
2741
- // hop. On a forked worker, the same proxy routes through the TCP
2742
- // transport to the hub's service. Zero custom $hub.* actions needed.
2743
- const kernelStreamProbe: import('@camstack/types').IKernelStreamProbe = {
2744
- probe: (url, options) => this.streamProbe.probe(url, options),
2745
- probeField: (key, value) => this.streamProbe.probeField(key, value),
2746
- }
2747
- const deviceManagerApi: import('@camstack/types').DeviceManagerApi =
2748
- createBrokerDeviceManagerApi({
2749
- api: this.getBrokerApi(),
2750
- addonId,
2751
- nodeId: this.broker.nodeID,
2752
- logger,
2753
- eventBus: this.eventBusService,
2754
- registry: this.deviceRegistry,
2755
- capabilityRegistry: this.capabilityRegistry,
2756
- streamProbe: kernelStreamProbe,
2757
- })
2758
-
2759
- const registry = this
2760
- const rr = this.moleculer.readinessRegistry
2761
- const capHandleCache = new Map<string, CapabilityHandle<unknown>>()
2762
- function getOrCreateHandle<T>(
2763
- capName: string,
2764
- scope: ReadinessScope,
2765
- timeoutMs: number,
2766
- ): CapabilityHandle<T> {
2767
- const key = `${capName}::${scopeKey(scope)}`
2768
- const existing = capHandleCache.get(key)
2769
- if (existing) return existing as CapabilityHandle<T>
2770
- const handle = new CapabilityHandle<T>(capName, scope, rr, timeoutMs)
2771
- capHandleCache.set(key, handle as CapabilityHandle<unknown>)
2772
- return handle
2773
- }
2774
- const ctx: InternalAddonContext & {
2775
- integrationRegistry?: unknown
2776
- capabilities: import('@camstack/types').CapabilitiesAccess
2777
- } = {
2778
- id: `addon:${addonId}`,
2779
- logger,
2780
- eventBus: this.eventBusService,
2781
- addonConfig: bootstrapConfig,
2782
- dataDir,
2783
- get api() {
2784
- return registry.getBrokerApi()
2785
- },
2786
- integrationRegistry: this.integrationRegistry ?? undefined,
2787
- // Live capability-collection accessor used by addons like stream-broker
2788
- // (webrtc fan-out), enrichment-engine, and snapshot orchestrator. Reads
2789
- // through the hub's CapabilityRegistry so the collection reflects
2790
- // every addon that has declared itself since the consumer initialized.
2791
- // Without this, those addons silently saw 0 providers and quietly
2792
- // skipped the fan-out — no errors, just missing streams/wiring.
2793
- capabilities: {
2794
- getCollection: <T = unknown>(capName: string): readonly T[] | undefined => {
2795
- const items = registry.capabilityRegistry.getCollection<T>(capName)
2796
- return items ?? undefined
2797
- },
2798
- getCollectionEntries: <T = unknown>(
2799
- capName: string,
2800
- ): readonly (readonly [string, T])[] | undefined => {
2801
- const items = registry.capabilityRegistry.getCollectionEntries<T>(capName)
2802
- return items ?? undefined
2803
- },
2804
- get: <T = unknown>(capName: string): T | undefined => {
2805
- return registry.capabilityRegistry.getSingleton<T>(capName) ?? undefined
2806
- },
2807
- },
2808
- deps: new AddonDepsManager(dataDir, logger),
2809
- kernel: {
2810
- localNodeId: this.broker.nodeID,
2811
- storage: storageProvider ?? undefined,
2812
- deviceRegistry: this.deviceRegistry ?? undefined,
2813
- devices: deviceManagerApi,
2814
- cluster: { broker: adaptBrokerToCluster(this.moleculer.broker) },
2815
- streamProbe: kernelStreamProbe,
2816
- hwaccel: createKernelHwAccel(),
2817
- capabilityRegistry: this.capabilityRegistry,
2818
- // Per-addon storage-location declarations across every installed
2819
- // addon — surfaced from the kernel's AddonLoader so the
2820
- // storage-orchestrator builtin can aggregate them and seed defaults.
2821
- listStorageLocationDeclarations: () => this.addonLoader.listStorageLocationDeclarations(),
2822
- readinessRegistry: this.moleculer.readinessRegistry,
2823
- // D3: handshake-fed native-cap view of the whole cluster. Backed by
2824
- // `HubNodeRegistry.listNativeCapEntries()` populated by every
2825
- // `$hub.registerNode` re-handshake. Used by device-manager as the
2826
- // reliable fallback when push events were lost mid-transport.
2827
- listClusterNativeCaps: () => this.moleculer.listClusterNativeCaps(),
2828
- // Per-device slice of the above — O(caps-for-device) via the registry's
2829
- // deviceId index. The per-device `getBindings` resolver prefers this
2830
- // over filtering the whole-cluster flat view.
2831
- listClusterNativeCapsForDevice: (deviceId: number) =>
2832
- this.moleculer.listClusterNativeCapsForDevice(deviceId),
2833
- },
2834
- registerProvider,
2835
- resolveProvider: <T = unknown>(capName: string): T | null => {
2836
- return (registry.capabilityRegistry.getSingleton<T>(capName) as T | null) ?? null
2837
- },
2838
- getNativeProvider: <TCap extends CapabilityDefinition>(
2839
- cap: TCap,
2840
- deviceId: number,
2841
- ): InferProvider<TCap> => {
2842
- // Hub-side addons live on the same process as the CapabilityRegistry;
2843
- // native providers are always resolvable locally.
2844
- const local = registry.capabilityRegistry.getNativeProvider<InferProvider<TCap>>(
2845
- cap.name,
2846
- deviceId,
2847
- )
2848
- if (!local) {
2849
- throw new Error(`no native provider for capability '${cap.name}' on device '${deviceId}'`)
2850
- }
2851
- return local
2852
- },
2853
- fetchDevice: async (deviceId: number) => {
2854
- const api = registry.getBrokerApi()
2855
- const binding = await api.deviceManager.getBindings.query({ deviceId })
2856
- return createDeviceProxy(api, binding)
2857
- },
2858
- settings: settingsView,
2859
- useCapability<T = unknown>(capName: string, scope: ReadinessScope = { type: 'global' }) {
2860
- return getOrCreateHandle<T>(capName, scope, 15_000)
2861
- },
2862
- async acquireCapability<T = unknown>(
2863
- capName: string,
2864
- scope: ReadinessScope = { type: 'global' },
2865
- opts: { timeoutMs?: number } = {},
2866
- ) {
2867
- const timeoutMs = opts.timeoutMs ?? 15_000
2868
- const handle = getOrCreateHandle<T>(capName, scope, timeoutMs)
2869
- return handle
2870
- },
2871
- onCapabilityStateChange(
2872
- capName: string,
2873
- scope: ReadinessScope,
2874
- handler: (state: 'ready' | 'down') => void,
2875
- ) {
2876
- return rr.onReadyState(capName, scope, (t) => {
2877
- handler(t.state === 'ready' ? 'ready' : 'down')
2878
- })
2879
- },
2880
- addDisposer(fn: () => void | Promise<void>) {
2881
- return registry.getOrCreateDisposerChain(addonId).add(fn)
2882
- },
2883
- }
2884
-
2885
- return ctx
2886
- }
2887
-
2888
- /**
2889
- * Per-addon disposer chain. Created lazily the first time an addon
2890
- * calls `ctx.addDisposer(...)`. Drained by `restartAddon()` /
2891
- * `unregisterAddon()` so cleanup callbacks run before the new addon
2892
- * instance comes up.
2893
- */
2894
- private readonly disposerChains = new Map<string, DisposerChain>()
2895
-
2896
- private getOrCreateDisposerChain(addonId: string): DisposerChain {
2897
- let chain = this.disposerChains.get(addonId)
2898
- if (chain == null) {
2899
- const log = this.loggingService.createLogger().withTags({ addonId })
2900
- chain = new DisposerChain({
2901
- onError: (err, index) => {
2902
- log.error(`Disposer #${index} threw during teardown`, {
2903
- meta: { error: errMsg(err) },
2904
- })
2905
- },
2906
- })
2907
- this.disposerChains.set(addonId, chain)
2908
- }
2909
- return chain
2910
- }
2911
-
2912
- /**
2913
- * Drain (and remove) the disposer chain for an addon. Called by the
2914
- * addon shutdown / restart flow so resources registered via
2915
- * `ctx.addDisposer(...)` clean up before the next instance boots.
2916
- */
2917
- private async drainDisposerChain(addonId: string): Promise<void> {
2918
- const chain = this.disposerChains.get(addonId)
2919
- if (chain == null) return
2920
- this.disposerChains.delete(addonId)
2921
- await chain.dispose()
2922
- }
2923
-
2924
- // ── Group orchestration (Phase G3 — opt-in, not yet wired into boot) ──
2925
-
2926
- /**
2927
- * Compute the runner plan for a set of addon ids (base-layer D2).
2928
- *
2929
- * The runner contract is one-addon-one-process by default (D2/D9):
2930
- * - every addon gets its OWN runner, keyed by the addon id via
2931
- * `resolveRunnerId` — no shipped addon declares `execution.group`
2932
- * today;
2933
- * - the group-collapse path (same `group` → shared runner keyed by
2934
- * group name) is an explicit opt-in mechanism; Phase 5 dissolved
2935
- * the last real co-location group (`pipeline`) once frames travel
2936
- * as shared-memory `FrameHandle`s and audio over tRPC — nothing
2937
- * passes a live JS reference across a process boundary any more.
2938
- *
2939
- * Addons with `placement: 'agent-only'` are dropped (they don't run on
2940
- * the hub). `@camstack/core` builtins are dropped too — they stay
2941
- * in-process on the hub (every forked runner connects to the hub
2942
- * broker via static-URL TCP transit, so cap providers hosted on the
2943
- * hub broker are reachable from any runner with one hop; moving them
2944
- * into a sibling subprocess would isolate them on a separate Moleculer
2945
- * node that child brokers cannot reach).
2946
- *
2947
- * Read-only. Used by the bootstrap to plan spawns up-front and by
2948
- * diagnostics that want to render the current topology.
2949
- */
2950
- buildAddonGroupPlan(addonIds: readonly string[]): RunnerPlan {
2951
- const plan = new Map<string, RunnerAddonPlacement[]>()
2952
- for (const id of addonIds) {
2953
- const entry = this.addonEntries.get(id)
2954
- if (!entry?.declaration || !entry.addonDir) continue
2955
- if (entry.packageName === '@camstack/core') continue
2956
- const placement = resolveAddonPlacement(entry.declaration)
2957
- if (placement === 'agent-only') continue
2958
- const runnerId = resolveRunnerId(entry.declaration, id)
2959
- const bucket = plan.get(runnerId) ?? []
2960
- bucket.push({ addonId: id, addonDir: entry.addonDir })
2961
- plan.set(runnerId, bucket)
2962
- }
2963
- return plan
2964
- }
2965
-
2966
- /**
2967
- * Spawn ONE runner subprocess that hosts every addon in `addons` and
2968
- * register their custom-actions catalogs against the shared
2969
- * CustomActionRegistry. Cap providers register themselves via
2970
- * `CapabilityBridge.onProviderConnected` — the bridge listens for any
2971
- * new Moleculer node (the runner's nodeID is `${parent}/${runnerId}`)
2972
- * and proxies its services into the local `CapabilityRegistry`.
2973
- *
2974
- * `runnerId` is the addon id (a solo runner — one addon per process,
2975
- * D2/D9). It can structurally also be a co-location group name if an
2976
- * addon ever declared `execution.group`, but no shipped manifest does.
2977
- *
2978
- * No-op when the runner is already running (idempotent for tests +
2979
- * crash respawn paths). Errors propagate so the bootstrap can log the
2980
- * failed runner and surface every hosted addon as `addon.error`.
2981
- */
2982
- async initializeAddonGroup(
2983
- runnerId: string,
2984
- addons: readonly RunnerAddonPlacement[],
2985
- ): Promise<void> {
2986
- if (addons.length === 0) {
2987
- throw new Error(`initializeAddonGroup("${runnerId}") requires at least one addon`)
2988
- }
2989
- try {
2990
- await this.broker.call('$process.spawnRunner', {
2991
- runnerId,
2992
- addons,
2993
- })
2994
- } catch (err: unknown) {
2995
- throw new Error(
2996
- `Failed to spawn runner "${runnerId}" (${addons.length} addons): ${errMsg(err)}`,
2997
- { cause: err },
2998
- )
2999
- }
3000
-
3001
- // Register custom actions for each addon on the runner. Provider
3002
- // registration for cap methods is handled by
3003
- // `CapabilityBridge.onProviderConnected` once the runner's
3004
- // INFO heartbeat lands.
3005
- for (const { addonId } of addons) {
3006
- await this.registerForkedAddonCustomActions(addonId, runnerId)
3007
- // Mark the entry initialized so the in-process core-builtin boot
3008
- // passes skip it (those passes only touch `@camstack/core`).
3009
- const entry = this.addonEntries.get(addonId)
3010
- if (entry) {
3011
- entry.initialized = true
3012
- this.emitAddonLifecycleEvent('addon.started', addonId)
3013
- }
3014
- }
3015
-
3016
- this.logger.info('Addon runner spawned', {
3017
- meta: { runnerId, addonCount: addons.length, addonIds: addons.map((a) => a.addonId) },
3018
- })
3019
- }
3020
-
3021
- /**
3022
- * (Re-)register the custom-action catalog for a forked / group-hosted
3023
- * addon against the shared `CustomActionRegistry`.
3024
- *
3025
- * The catalog (zod `input`/`output` specs) is read STATICALLY from the
3026
- * addon module's `customActions` named export — the handler dispatches
3027
- * over UDS via `LocalChildRegistry.callAddonOnChild(addonId,
3028
- * {target:'custom', action, args})` (F3 — replaces the removed per-addon
3029
- * Moleculer `custom.<action>` action), so the only divergence vs an
3030
- * in-process addon is the transport, exactly like cap methods. The hub's
3031
- * `CustomActionRegistry` validates input/output around this dispatch.
3032
- *
3033
- * Why a fresh import: `this.addonLoader`'s `module` namespace is
3034
- * captured once at boot. After a hot-update (`installFromTgz` →
3035
- * `restartAddon`) the on-disk bundle is newer than that cached module,
3036
- * so re-reading the boot-time `module` would register a STALE catalog
3037
- * (or none at all, if the addon was first installed after boot). We
3038
- * re-`import()` the entry with a cache-busting query so Node's ESM
3039
- * loader hands back the current bundle.
3040
- *
3041
- * Idempotent: drops any prior registration first. No-op (with a debug
3042
- * log) when the addon exports no `customActions` — most addons don't.
3043
- */
3044
- private async registerForkedAddonCustomActions(addonId: string, runnerId: string): Promise<void> {
3045
- // Always clear first so a restart that REMOVES custom actions (or an
3046
- // addon whose entry no longer exports them) doesn't leave stale
3047
- // entries resolvable.
3048
- this.customActionRegistry.unregisterAddon(addonId)
3049
-
3050
- const entry = this.addonEntries.get(addonId)
3051
- const addonDir = entry?.addonDir
3052
- const declarationEntry = entry?.declaration?.entry
3053
- if (!addonDir || !declarationEntry) return
3054
-
3055
- // Resolve the built entry the same way `AddonLoader.loadDeclaration`
3056
- // does: `./src/x.ts` → `dist/x.js`, with index.js fallbacks.
3057
- const entryFile = declarationEntry
3058
- .replace(/^\.\//, '')
3059
- .replace(/^src\//, 'dist/')
3060
- .replace(/\.ts$/, '.js')
3061
- let entryPath = path.resolve(addonDir, entryFile)
3062
- if (!fs.existsSync(entryPath)) {
3063
- const base = entryPath.replace(/\.(js|cjs|mjs)$/, '')
3064
- const alternatives = [
3065
- `${base}.cjs`,
3066
- `${base}.mjs`,
3067
- path.resolve(addonDir, 'dist', 'index.js'),
3068
- path.resolve(addonDir, 'dist', 'index.cjs'),
3069
- path.resolve(addonDir, 'dist', 'index.mjs'),
3070
- path.resolve(addonDir, declarationEntry),
3071
- ]
3072
- entryPath = alternatives.find((p) => fs.existsSync(p)) ?? entryPath
3073
- }
3074
- if (!fs.existsSync(entryPath)) return
3075
-
3076
- let catalog: unknown
3077
- try {
3078
- // Cache-bust so a hot-updated bundle is re-read instead of served
3079
- // from Node's ESM module cache.
3080
- const cacheBustedUrl = `${pathToFileURL(entryPath).href}?t=${Date.now()}`
3081
- const modUnknown: unknown = await import(cacheBustedUrl)
3082
- catalog =
3083
- modUnknown && typeof modUnknown === 'object'
3084
- ? (modUnknown as Record<string, unknown>)['customActions']
3085
- : undefined
3086
- } catch (err) {
3087
- this.logger.warn('Failed to load custom-action catalog for forked addon', {
3088
- tags: { addonId },
3089
- meta: { error: errMsg(err) },
3090
- })
3091
- return
3092
- }
3093
-
3094
- if (!catalog || typeof catalog !== 'object') {
3095
- this.logger.debug('Forked addon exports no custom actions', {
3096
- tags: { addonId },
3097
- meta: { runnerId },
3098
- })
3099
- return
3100
- }
3101
-
3102
- this.customActionRegistry.registerAddon(
3103
- addonId,
3104
- catalog as import('@camstack/types').CustomActionsSpec,
3105
- (action, input) => this.dispatchForkedCustomAction(addonId, action, input),
3106
- )
3107
- this.logger.info('Runner addon custom actions registered', {
3108
- tags: { addonId },
3109
- meta: { runnerId },
3110
- })
3111
- }
3112
-
3113
- /**
3114
- * Dispatch a forked addon's custom action through the shared
3115
- * {@link AddonCallGateway} (UDS to the hub-local child). The gateway owns the
3116
- * routing + the child-availability error; there is no broker fallback after
3117
- * the per-addon Moleculer broker was removed.
3118
- */
3119
- private async dispatchForkedCustomAction(
3120
- addonId: string,
3121
- action: string,
3122
- input: unknown,
3123
- ): Promise<unknown> {
3124
- return this.addonCallGateway.callForked(addonId, {
3125
- target: 'custom',
3126
- action,
3127
- args: input,
3128
- })
3129
- }
3130
- }