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