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