@camstack/server 0.1.6 → 0.1.8

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 (60) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/addon-upload.spec.ts +58 -0
  3. package/src/__tests__/bulk-update-coordinator.spec.ts +286 -0
  4. package/src/__tests__/cap-ownership-authority.spec.ts +400 -0
  5. package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +186 -0
  6. package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +243 -0
  7. package/src/__tests__/cap-providers-bulk-update.spec.ts +388 -0
  8. package/src/__tests__/cap-route-adapter.spec.ts +289 -0
  9. package/src/__tests__/cap-routers/broker-routing.router.spec.ts +169 -0
  10. package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +123 -0
  11. package/src/__tests__/cap-routers/capabilities-node.spec.ts +55 -0
  12. package/src/__tests__/cap-routers/device-link-overlay.spec.ts +132 -0
  13. package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +30 -0
  14. package/src/__tests__/device-settings-contribution-dispatch.spec.ts +249 -0
  15. package/src/__tests__/framework-installer-defer-restart.spec.ts +165 -0
  16. package/src/__tests__/moleculer/uds-readiness.spec.ts +143 -0
  17. package/src/__tests__/moleculer/uds-topology.spec.ts +390 -0
  18. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +329 -0
  19. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +39 -4
  20. package/src/__tests__/native-cap-route.spec.ts +404 -0
  21. package/src/__tests__/oauth2-account-linking.spec.ts +85 -0
  22. package/src/__tests__/uds-addon-call-wiring.spec.ts +237 -0
  23. package/src/__tests__/uds-log-ingest.spec.ts +183 -0
  24. package/src/api/addon-upload.ts +27 -1
  25. package/src/api/capabilities.router.ts +1 -1
  26. package/src/api/core/__tests__/integration-markers.spec.ts +10 -0
  27. package/src/api/core/bulk-update-coordinator.ts +302 -0
  28. package/src/api/core/cap-providers.ts +211 -9
  29. package/src/api/core/capabilities.router.ts +26 -3
  30. package/src/api/core/logs.router.ts +4 -0
  31. package/src/api/oauth2/oauth2-routes.ts +5 -1
  32. package/src/api/trpc/__tests__/client-ip.spec.ts +146 -0
  33. package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +128 -0
  34. package/src/api/trpc/cap-mount-helpers.ts +12 -1
  35. package/src/api/trpc/cap-route-error-formatter.ts +163 -0
  36. package/src/api/trpc/client-ip.ts +147 -0
  37. package/src/api/trpc/generated-cap-mounts.ts +299 -8
  38. package/src/api/trpc/generated-cap-routers.ts +2384 -302
  39. package/src/api/trpc/trpc.middleware.ts +5 -1
  40. package/src/api/trpc/trpc.router.ts +84 -3
  41. package/src/boot/__tests__/integration-id-backfill.spec.ts +116 -0
  42. package/src/boot/integration-id-backfill.ts +109 -0
  43. package/src/core/addon/__tests__/addon-row-manifest.spec.ts +62 -0
  44. package/src/core/addon/addon-call-gateway.ts +157 -0
  45. package/src/core/addon/addon-package.service.ts +9 -0
  46. package/src/core/addon/addon-registry.service.ts +453 -107
  47. package/src/core/addon/addon-row-manifest.ts +29 -0
  48. package/src/core/addon/addon-settings-provider.ts +40 -116
  49. package/src/core/capability/capability.service.ts +9 -0
  50. package/src/core/logging/logging.service.ts +7 -2
  51. package/src/core/moleculer/cap-call-fn.spec.ts +166 -0
  52. package/src/core/moleculer/cap-call-fn.ts +103 -0
  53. package/src/core/moleculer/cap-route-authority.ts +182 -0
  54. package/src/core/moleculer/moleculer.service.ts +408 -36
  55. package/src/core/network/network-quality.service.spec.ts +2 -1
  56. package/src/main.ts +137 -12
  57. package/src/core/storage/settings-store.spec.ts +0 -213
  58. package/src/core/storage/settings-store.ts +0 -2
  59. package/src/core/storage/sql-schema.spec.ts +0 -140
  60. package/src/core/storage/sql-schema.ts +0 -3
@@ -0,0 +1,329 @@
1
+ /**
2
+ * F0 (slice-5 outbound) — hub-side `onUnownedCall` wiring.
3
+ *
4
+ * When a forked hub child issues `ctx.api.<cap>.<method>` for a cap NO local
5
+ * sibling owns, the hub's `LocalChildRegistry` must route it via the parent's
6
+ * `onUnownedCall` handler and return the result over UDS — instead of falling
7
+ * through `UDS_NO_ROUTE` to the child's own broker (which F1+F2 removes).
8
+ *
9
+ * The hub wires the handler in `onModuleInit` as:
10
+ * createParentUnownedCallHandler({ getResolver: () => this.resolver, broker: this.broker, logger })
11
+ *
12
+ * This test stands up the REAL production handler against:
13
+ * - a REAL `CapRouteResolver` configured with a hub-in-process provider, to
14
+ * prove the resolver-first branch resolves a hub cap, AND
15
+ * - a broker fake exposing a `$`-infra core service (`$core-caps`), to prove
16
+ * the broker-fallback branch reaches a core service the resolver can't see.
17
+ *
18
+ * We do NOT boot the full MoleculerService (it requires a real broker + DI
19
+ * graph); instead we exercise the exact handler the hub constructs, with the
20
+ * same resolver shape the hub injects, so both branches are covered.
21
+ */
22
+ import { describe, it, expect, vi } from 'vitest'
23
+ import {
24
+ createParentUnownedCallHandler,
25
+ CapRouteResolver,
26
+ CapRouteError,
27
+ HubNodeRegistry,
28
+ } from '@camstack/kernel'
29
+ import type {
30
+ NodeCapAuthority,
31
+ InProcessProviderLookup,
32
+ CapRouteResolverDeps,
33
+ NodeNativeCapEntry,
34
+ HubLocalChildDispatcher,
35
+ CapCallInput,
36
+ } from '@camstack/kernel'
37
+ import type { ServiceBroker } from 'moleculer'
38
+
39
+ // A node authority that knows nothing — the only routable thing is the
40
+ // hub-in-process provider injected via inProcessProviders.
41
+ const emptyNodeAuthority: NodeCapAuthority = {
42
+ nodeKnowsCap: () => false,
43
+ nodeIsAgent: () => false,
44
+ nodeOnline: () => false,
45
+ listNodeIds: () => [],
46
+ getAddonId: () => null,
47
+ getAgentChildId: () => null,
48
+ isNativeCap: () => false,
49
+ }
50
+
51
+ /**
52
+ * Broker fake exposing the hub's `$core-caps` core service (the bridge for
53
+ * `system`/`addons`/`capabilities`/`nodes` routers) so the broker-fallback
54
+ * branch resolves it. `$core-caps` is NOT a CapabilityRegistry provider, so
55
+ * the resolver returns `no-provider` for it and the fallback runs.
56
+ */
57
+ function hubBrokerFake(): ServiceBroker {
58
+ const services = [{ name: '$core-caps', nodeID: 'hub', actions: { 'system.info': {} } }]
59
+ const broker = {
60
+ nodeID: 'hub',
61
+ call: vi.fn(async (action: string, params: unknown) => {
62
+ if (action === '$core-caps.system.info') return { uptimeSec: 99, params }
63
+ throw Object.assign(new Error(`Service not found: ${action}`), { type: 'SERVICE_NOT_FOUND' })
64
+ }),
65
+ waitForServices: vi.fn(async () => undefined),
66
+ registry: { getServiceList: () => services },
67
+ }
68
+ return broker as unknown as ServiceBroker
69
+ }
70
+
71
+ /**
72
+ * Minimal `HubNodeRegistry`-shaped fake exposing only the lookup the unowned
73
+ * handler uses: `listNativeCapEntriesForDevice`. The handler depends solely on
74
+ * this method, so we don't need the full registry to exercise its routing.
75
+ */
76
+ function nodeRegistryFake(
77
+ byDevice: Record<number, readonly NodeNativeCapEntry[]> = {},
78
+ ): HubNodeRegistry {
79
+ const fake = {
80
+ listNativeCapEntriesForDevice: (deviceId: number): readonly NodeNativeCapEntry[] =>
81
+ byDevice[deviceId] ?? [],
82
+ }
83
+ return fake as unknown as HubNodeRegistry
84
+ }
85
+
86
+ /**
87
+ * Hub-local UDS dispatcher fake (LocalChildRegistry surface). `resolveChildId`
88
+ * returns the configured childId on a (capName, deviceId) match else null;
89
+ * `callCapOnChild` records the call and echoes a sentinel result.
90
+ */
91
+ function localDispatcherFake(owner?: { capName: string; deviceId: number; childId: string }): {
92
+ dispatcher: HubLocalChildDispatcher
93
+ resolveChildId: ReturnType<typeof vi.fn>
94
+ callCapOnChild: ReturnType<typeof vi.fn>
95
+ } {
96
+ const resolveChildId = vi.fn((capName: string, deviceId?: number): string | null =>
97
+ owner !== undefined && capName === owner.capName && deviceId === owner.deviceId ? owner.childId : null,
98
+ )
99
+ const callCapOnChild = vi.fn(async (childId: string, input: CapCallInput) => ({
100
+ routedOverUds: childId,
101
+ capName: input.capName,
102
+ }))
103
+ const dispatcher: HubLocalChildDispatcher = { resolveChildId, callCapOnChild }
104
+ return { dispatcher, resolveChildId, callCapOnChild }
105
+ }
106
+
107
+ describe('hub onUnownedCall wiring (F0)', () => {
108
+ it('resolver-first: resolves a hub-in-process cap through the real CapRouteResolver', async () => {
109
+ const broker = hubBrokerFake()
110
+
111
+ // Hub-in-process provider for `settings-store`.
112
+ const settingsStore = { get: async (params: unknown) => ({ value: 'hub-resolved', params }) }
113
+ const inProcessProviders: InProcessProviderLookup = (capName) =>
114
+ capName === 'settings-store'
115
+ ? { invoke: (method, args) => Promise.resolve((settingsStore as Record<string, (a: unknown) => unknown>)[method](args)) }
116
+ : null
117
+
118
+ const resolverDeps: CapRouteResolverDeps = {
119
+ hubNodeId: 'hub',
120
+ broker,
121
+ hubLocalRegistry: null,
122
+ nodeAuthority: emptyNodeAuthority,
123
+ inProcessProviders,
124
+ }
125
+ const resolver = new CapRouteResolver(resolverDeps)
126
+
127
+ const handler = createParentUnownedCallHandler({ getResolver: () => resolver, broker, nodeRegistry: nodeRegistryFake() })
128
+
129
+ const result = await handler({ capName: 'settings-store', method: 'get', args: { key: 'k' } })
130
+ expect(result).toEqual({ value: 'hub-resolved', params: { key: 'k' } })
131
+ // Resolver served it — broker untouched.
132
+ expect((broker.call as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled()
133
+ })
134
+
135
+ it('broker-fallback: a `$`-infra core service (`$core-caps.system.info`) routes via the broker', async () => {
136
+ const broker = hubBrokerFake()
137
+
138
+ // No in-process providers and no known nodes → the resolver returns
139
+ // no-provider for `system`, exactly as it does live for the core routers.
140
+ const resolver = new CapRouteResolver({
141
+ hubNodeId: 'hub',
142
+ broker,
143
+ hubLocalRegistry: null,
144
+ nodeAuthority: emptyNodeAuthority,
145
+ inProcessProviders: () => null,
146
+ })
147
+
148
+ const handler = createParentUnownedCallHandler({ getResolver: () => resolver, broker, nodeRegistry: nodeRegistryFake() })
149
+
150
+ const result = await handler({ capName: 'system', method: 'info', args: undefined })
151
+ expect(result).toEqual({ uptimeSec: 99, params: undefined })
152
+ expect(broker.call).toHaveBeenCalledWith('$core-caps.system.info', undefined, undefined)
153
+ })
154
+
155
+ it('pre-init safety: getResolver returns null before onModuleInit → broker fallback still works', async () => {
156
+ const broker = hubBrokerFake()
157
+ // Mirrors the window before `this.resolver` is constructed: getResolver
158
+ // returns null, so the handler goes straight to the broker fallback.
159
+ const handler = createParentUnownedCallHandler({ getResolver: () => null, broker, nodeRegistry: nodeRegistryFake() })
160
+
161
+ const result = await handler({ capName: 'system', method: 'info', args: undefined })
162
+ expect(result).toEqual({ uptimeSec: 99, params: undefined })
163
+ })
164
+
165
+ it('deviceId-aware resolver: derives deviceId from args and routes via the resolver (no broker fallback)', async () => {
166
+ const broker = hubBrokerFake()
167
+
168
+ // A resolver-shaped fake that resolves a route ONLY when invoked with a
169
+ // deviceId — mirroring the real resolver routing a device-scoped native cap
170
+ // to its owning provider. Without a deviceId it returns no-provider.
171
+ let resolvedWithDeviceId: number | undefined
172
+ const resolver = {
173
+ resolveCapRoute: (capName: string, opts: { deviceId?: number }) => {
174
+ if (opts.deviceId === undefined) {
175
+ throw new CapRouteError(capName, undefined, { reason: 'no-provider', rejected: [] })
176
+ }
177
+ resolvedWithDeviceId = opts.deviceId
178
+ return { kind: 'hub-in-process', capName, deviceId: opts.deviceId }
179
+ },
180
+ dispatch: async () => ({ catalog: ['stream-a'] }),
181
+ }
182
+
183
+ const handler = createParentUnownedCallHandler({
184
+ getResolver: () => resolver as unknown as CapRouteResolver,
185
+ broker,
186
+ nodeRegistry: nodeRegistryFake(),
187
+ })
188
+
189
+ // deviceId lives ONLY in args, NOT top-level — the handler must derive it.
190
+ const result = await handler({ capName: 'stream-catalog', method: 'getCatalog', args: { deviceId: 7 } })
191
+ expect(result).toEqual({ catalog: ['stream-a'] })
192
+ expect(resolvedWithDeviceId).toBe(7)
193
+ // Resolver served it via the derived deviceId — broker untouched.
194
+ expect(broker.call).not.toHaveBeenCalled()
195
+ })
196
+
197
+ it('pinned broker fallback: resolver finds nothing → call is pinned to the owning node', async () => {
198
+ const broker = hubBrokerFake()
199
+
200
+ // Resolver always returns no-provider for this device-scoped cap.
201
+ const resolver = {
202
+ resolveCapRoute: (capName: string) => {
203
+ throw new CapRouteError(capName, undefined, { reason: 'no-provider', rejected: [] })
204
+ },
205
+ dispatch: async () => {
206
+ throw new Error('dispatch should not be reached')
207
+ },
208
+ }
209
+
210
+ const owner: NodeNativeCapEntry = { nodeId: 'agent', addonId: 'reolink', capName: 'stream-catalog', deviceId: 7 }
211
+ const handler = createParentUnownedCallHandler({
212
+ getResolver: () => resolver as unknown as CapRouteResolver,
213
+ broker,
214
+ nodeRegistry: nodeRegistryFake({ 7: [owner] }),
215
+ })
216
+
217
+ await handler({ capName: 'stream-catalog', method: 'getCatalog', args: { deviceId: 7 } }).catch(() => undefined)
218
+
219
+ // The broker call is pinned to the owning node via call-opts `{ nodeID }`.
220
+ const callArgs = (broker.call as ReturnType<typeof vi.fn>).mock.calls[0]
221
+ expect(callArgs[2]).toEqual({ nodeID: 'agent' })
222
+ })
223
+
224
+ it('back-compat: no resolvable device-owner → broker call stays UNPINNED (load-balanced)', async () => {
225
+ const broker = hubBrokerFake()
226
+
227
+ const resolver = {
228
+ resolveCapRoute: (capName: string) => {
229
+ throw new CapRouteError(capName, undefined, { reason: 'no-provider', rejected: [] })
230
+ },
231
+ dispatch: async () => {
232
+ throw new Error('dispatch should not be reached')
233
+ },
234
+ }
235
+
236
+ const handler = createParentUnownedCallHandler({
237
+ getResolver: () => resolver as unknown as CapRouteResolver,
238
+ broker,
239
+ nodeRegistry: nodeRegistryFake(), // no owners for any device
240
+ })
241
+
242
+ const result = await handler({ capName: 'system', method: 'info', args: undefined })
243
+ expect(result).toEqual({ uptimeSec: 99, params: undefined })
244
+ // Unpinned: third arg (call-opts) is undefined — today's behavior preserved.
245
+ const callArgs = (broker.call as ReturnType<typeof vi.fn>).mock.calls[0]
246
+ expect(callArgs[2]).toBeUndefined()
247
+ })
248
+
249
+ it('hub-local owner: device-native cap owned by a hub-local UDS child routes over UDS (broker untouched)', async () => {
250
+ const broker = hubBrokerFake()
251
+ // Resolver misses the device-scoped native cap (mirrors live behavior).
252
+ const resolver = {
253
+ resolveCapRoute: (capName: string) => {
254
+ throw new CapRouteError(capName, undefined, { reason: 'no-provider', rejected: [] })
255
+ },
256
+ dispatch: async () => { throw new Error('dispatch should not be reached') },
257
+ }
258
+ const { dispatcher, resolveChildId, callCapOnChild } = localDispatcherFake({
259
+ capName: 'stream-catalog',
260
+ deviceId: 7,
261
+ childId: 'child-reolink',
262
+ })
263
+
264
+ const handler = createParentUnownedCallHandler({
265
+ getResolver: () => resolver as unknown as CapRouteResolver,
266
+ broker,
267
+ nodeRegistry: nodeRegistryFake(), // empty — hub-local child is NOT in HubNodeRegistry
268
+ getLocalDispatcher: () => dispatcher,
269
+ })
270
+
271
+ const result = await handler({ capName: 'stream-catalog', method: 'getCatalog', args: { deviceId: 7 } })
272
+ expect(result).toEqual({ routedOverUds: 'child-reolink', capName: 'stream-catalog' })
273
+ expect(resolveChildId).toHaveBeenCalledWith('stream-catalog', 7)
274
+ expect(callCapOnChild).toHaveBeenCalledWith('child-reolink', {
275
+ capName: 'stream-catalog',
276
+ method: 'getCatalog',
277
+ args: { deviceId: 7 },
278
+ deviceId: 7,
279
+ })
280
+ expect(broker.call).not.toHaveBeenCalled()
281
+ })
282
+
283
+ it('remote owner: hub-local dispatcher misses → pinned broker call', async () => {
284
+ const broker = hubBrokerFake()
285
+ const resolver = {
286
+ resolveCapRoute: (capName: string) => {
287
+ throw new CapRouteError(capName, undefined, { reason: 'no-provider', rejected: [] })
288
+ },
289
+ dispatch: async () => { throw new Error('dispatch should not be reached') },
290
+ }
291
+ const { dispatcher, callCapOnChild } = localDispatcherFake() // no local owner
292
+ const owner: NodeNativeCapEntry = { nodeId: 'agent', addonId: 'reolink', capName: 'stream-catalog', deviceId: 7 }
293
+
294
+ const handler = createParentUnownedCallHandler({
295
+ getResolver: () => resolver as unknown as CapRouteResolver,
296
+ broker,
297
+ nodeRegistry: nodeRegistryFake({ 7: [owner] }),
298
+ getLocalDispatcher: () => dispatcher,
299
+ })
300
+
301
+ await handler({ capName: 'stream-catalog', method: 'getCatalog', args: { deviceId: 7 } }).catch(() => undefined)
302
+
303
+ expect(callCapOnChild).not.toHaveBeenCalled()
304
+ const callArgs = (broker.call as ReturnType<typeof vi.fn>).mock.calls[0]
305
+ expect(callArgs[2]).toEqual({ nodeID: 'agent' })
306
+ })
307
+
308
+ it('no local dispatcher (getter returns null): behaves as before — HubNodeRegistry → broker', async () => {
309
+ const broker = hubBrokerFake()
310
+ const resolver = {
311
+ resolveCapRoute: (capName: string) => {
312
+ throw new CapRouteError(capName, undefined, { reason: 'no-provider', rejected: [] })
313
+ },
314
+ dispatch: async () => { throw new Error('dispatch should not be reached') },
315
+ }
316
+ const owner: NodeNativeCapEntry = { nodeId: 'agent', addonId: 'reolink', capName: 'stream-catalog', deviceId: 7 }
317
+
318
+ const handler = createParentUnownedCallHandler({
319
+ getResolver: () => resolver as unknown as CapRouteResolver,
320
+ broker,
321
+ nodeRegistry: nodeRegistryFake({ 7: [owner] }),
322
+ getLocalDispatcher: () => null,
323
+ })
324
+
325
+ await handler({ capName: 'stream-catalog', method: 'getCatalog', args: { deviceId: 7 } }).catch(() => undefined)
326
+ const callArgs = (broker.call as ReturnType<typeof vi.fn>).mock.calls[0]
327
+ expect(callArgs[2]).toEqual({ nodeID: 'agent' })
328
+ })
329
+ })
@@ -49,12 +49,17 @@ interface RegisterNodeDriver {
49
49
  createCapabilityProxy: (capabilityName: string, nodeId: string) => Record<string, (params: unknown) => Promise<unknown>> | null
50
50
  }
51
51
 
52
+ /** Build a harness whose declared caps are SINGLETON, for active-provider preference tests. */
53
+ function createSingletonHarness(capNames: readonly string[]): Harness {
54
+ return createHarness(capNames, 'singleton')
55
+ }
56
+
52
57
  /** A minimal real `CapabilityDefinition` so `getDefinition`/`expandCapMethods` resolve. */
53
- function makeCapDef(name: string): CapabilityDefinition {
58
+ function makeCapDef(name: string, mode: 'collection' | 'singleton' = 'collection'): CapabilityDefinition {
54
59
  return {
55
60
  name,
56
61
  scope: 'system',
57
- mode: 'collection',
62
+ mode,
58
63
  methods: {
59
64
  ping: {
60
65
  input: z.object({}),
@@ -93,10 +98,10 @@ interface Harness {
93
98
  * The broker is never started, so there is nothing to stop in teardown —
94
99
  * no `afterEach` teardown is needed.
95
100
  */
96
- function createHarness(capNames: readonly string[]): Harness {
101
+ function createHarness(capNames: readonly string[], mode: 'collection' | 'singleton' = 'collection'): Harness {
97
102
  const registry = new CapabilityRegistry(makeLogger())
98
103
  for (const name of capNames) {
99
- registry.declareCapability(makeCapDef(name))
104
+ registry.declareCapability(makeCapDef(name, mode))
100
105
  }
101
106
  // Boot-complete state — `getAllProviders` returns [] until `ready()`.
102
107
  registry.ready()
@@ -227,3 +232,33 @@ describe('MoleculerService.applyNodeManifest — re-handshake idempotency', () =
227
232
  expect(harness.driver.createCapabilityProxy('cap-alpha', 'hub/reolink')).not.toBeNull()
228
233
  })
229
234
  })
235
+
236
+ describe('MoleculerService.applyNodeManifest — singleton local-first preference (UDS regression)', () => {
237
+ // A `placement: 'any-node'` singleton (e.g. `pipeline-executor`) can register
238
+ // on BOTH the hub-local forked child AND a remote agent. The hub must resolve
239
+ // its OWN local provider (reachable over UDS) — never the agent's proxy, whose
240
+ // callFn routes over Moleculer to a UDS-only agent runner that no longer hosts
241
+ // the service ("not found on <agent>"). First-registered-wins picked the agent.
242
+ const LOCAL = 'hub/detection-pipeline'
243
+ const REMOTE = 'dev-agent-0/detection-pipeline'
244
+ const singleton: (nodeId: string) => RegisterNodeParams = (nodeId) => ({
245
+ nodeId,
246
+ addons: [{ addonId: 'detection-pipeline', capabilities: ['pipeline-executor'] }],
247
+ })
248
+
249
+ it('prefers the hub-local provider when the REMOTE one registered first', () => {
250
+ const { driver, registry } = createSingletonHarness(['pipeline-executor'])
251
+ driver.onRegisterNode(singleton(REMOTE)) // agent first → would win under first-wins
252
+ driver.onRegisterNode(singleton(LOCAL)) // hub-local second
253
+ // Active provider must be the hub-local key (bare addonId, no '@').
254
+ expect(registry.getSingletonAddonId('pipeline-executor')).toBe('detection-pipeline')
255
+ expect(registry.getSingletonAddonId('pipeline-executor')).not.toContain('@')
256
+ })
257
+
258
+ it('keeps the hub-local provider active when a REMOTE one registers afterwards', () => {
259
+ const { driver, registry } = createSingletonHarness(['pipeline-executor'])
260
+ driver.onRegisterNode(singleton(LOCAL)) // hub-local first
261
+ driver.onRegisterNode(singleton(REMOTE)) // agent second must NOT steal active
262
+ expect(registry.getSingletonAddonId('pipeline-executor')).toBe('detection-pipeline')
263
+ })
264
+ })