@camstack/server 0.1.7 → 0.2.0

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