@camstack/server 0.1.3
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/.env.example +17 -0
- package/package.json +55 -0
- package/src/__tests__/addon-install-e2e.test.ts +75 -0
- package/src/__tests__/addon-pages-e2e.test.ts +178 -0
- package/src/__tests__/addon-route-session.test.ts +17 -0
- package/src/__tests__/addon-settings-router.spec.ts +62 -0
- package/src/__tests__/addon-upload.spec.ts +355 -0
- package/src/__tests__/agent-registry.spec.ts +162 -0
- package/src/__tests__/agent-status-page.spec.ts +84 -0
- package/src/__tests__/auth-session-cookie.test.ts +21 -0
- package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
- package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
- package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
- package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
- package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
- package/src/__tests__/cap-routers/harness.ts +159 -0
- package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
- package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
- package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
- package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
- package/src/__tests__/capability-e2e.test.ts +386 -0
- package/src/__tests__/cli-e2e.test.ts +129 -0
- package/src/__tests__/core-cap-bridge.spec.ts +89 -0
- package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
- package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
- package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
- package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
- package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
- package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
- package/src/__tests__/framework-allowlist.spec.ts +95 -0
- package/src/__tests__/https-e2e.test.ts +118 -0
- package/src/__tests__/lifecycle-e2e.test.ts +140 -0
- package/src/__tests__/live-events-subscription.spec.ts +150 -0
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
- package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
- package/src/__tests__/post-boot-restart.spec.ts +161 -0
- package/src/__tests__/singleton-contention.test.ts +487 -0
- package/src/__tests__/streaming-diagnostic.test.ts +512 -0
- package/src/__tests__/streaming-scale.test.ts +280 -0
- package/src/agent-status-page.ts +121 -0
- package/src/api/__tests__/addons-custom.spec.ts +134 -0
- package/src/api/__tests__/capabilities.router.test.ts +47 -0
- package/src/api/addon-upload.ts +472 -0
- package/src/api/addons-custom.router.ts +100 -0
- package/src/api/auth-whoami.ts +99 -0
- package/src/api/bridge-addons.router.ts +120 -0
- package/src/api/capabilities.router.ts +226 -0
- package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
- package/src/api/core/addon-settings.router.ts +124 -0
- package/src/api/core/agents.router.ts +87 -0
- package/src/api/core/auth.router.ts +303 -0
- package/src/api/core/cap-providers.ts +993 -0
- package/src/api/core/capabilities.router.ts +119 -0
- package/src/api/core/collection-preference.ts +40 -0
- package/src/api/core/event-bus-proxy.router.ts +45 -0
- package/src/api/core/hwaccel.router.ts +81 -0
- package/src/api/core/live-events.router.ts +60 -0
- package/src/api/core/logs.router.ts +162 -0
- package/src/api/core/notifications.router.ts +65 -0
- package/src/api/core/repl.router.ts +41 -0
- package/src/api/core/settings-backend.router.ts +142 -0
- package/src/api/core/stream-probe.router.ts +57 -0
- package/src/api/core/system-events.router.ts +116 -0
- package/src/api/health/health.routes.ts +123 -0
- package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
- package/src/api/oauth2/consent-page.ts +42 -0
- package/src/api/oauth2/oauth2-routes.ts +248 -0
- package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
- package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
- package/src/api/trpc/cap-mount-helpers.ts +225 -0
- package/src/api/trpc/core-cap-bridge.ts +152 -0
- package/src/api/trpc/generated-cap-mounts.ts +707 -0
- package/src/api/trpc/generated-cap-routers.ts +6340 -0
- package/src/api/trpc/scope-access.ts +110 -0
- package/src/api/trpc/trpc.context.ts +255 -0
- package/src/api/trpc/trpc.middleware.ts +140 -0
- package/src/api/trpc/trpc.router.ts +275 -0
- package/src/auth/session-cookie.ts +44 -0
- package/src/boot/boot-config.ts +278 -0
- package/src/boot/post-boot.service.ts +103 -0
- package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
- package/src/core/addon/addon-package.service.ts +1684 -0
- package/src/core/addon/addon-registry.service.ts +2926 -0
- package/src/core/addon/addon-search.service.ts +90 -0
- package/src/core/addon/addon-settings-provider.ts +276 -0
- package/src/core/addon/addon.tokens.ts +2 -0
- package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
- package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
- package/src/core/addon-pages/addon-pages.service.ts +80 -0
- package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
- package/src/core/agent/agent-registry.service.ts +507 -0
- package/src/core/auth/auth.service.spec.ts +88 -0
- package/src/core/auth/auth.service.ts +8 -0
- package/src/core/capability/capability.service.ts +57 -0
- package/src/core/config/config.schema.ts +3 -0
- package/src/core/config/config.service.spec.ts +175 -0
- package/src/core/config/config.service.ts +7 -0
- package/src/core/events/event-bus.service.spec.ts +212 -0
- package/src/core/events/event-bus.service.ts +85 -0
- package/src/core/feature/feature.service.spec.ts +96 -0
- package/src/core/feature/feature.service.ts +8 -0
- package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
- package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
- package/src/core/logging/log-ring-buffer.ts +3 -0
- package/src/core/logging/logging.service.spec.ts +247 -0
- package/src/core/logging/logging.service.ts +129 -0
- package/src/core/logging/scoped-logger.ts +3 -0
- package/src/core/moleculer/moleculer.service.ts +612 -0
- package/src/core/network/network-quality.service.spec.ts +47 -0
- package/src/core/network/network-quality.service.ts +5 -0
- package/src/core/notification/notification-wrapper.service.ts +36 -0
- package/src/core/notification/toast-wrapper.service.ts +31 -0
- package/src/core/provider/provider.tokens.ts +1 -0
- package/src/core/repl/repl-engine.service.spec.ts +417 -0
- package/src/core/repl/repl-engine.service.ts +156 -0
- package/src/core/storage/fs-storage-backend.spec.ts +70 -0
- package/src/core/storage/fs-storage-backend.ts +3 -0
- package/src/core/storage/settings-store.spec.ts +213 -0
- package/src/core/storage/settings-store.ts +2 -0
- package/src/core/storage/sql-schema.spec.ts +140 -0
- package/src/core/storage/sql-schema.ts +3 -0
- package/src/core/storage/storage-location-manager.spec.ts +121 -0
- package/src/core/storage/storage-location-manager.ts +3 -0
- package/src/core/storage/storage.service.spec.ts +73 -0
- package/src/core/storage/storage.service.ts +3 -0
- package/src/core/streaming/stream-probe.service.ts +212 -0
- package/src/core/topology/topology-emitter.service.ts +101 -0
- package/src/launcher.ts +309 -0
- package/src/main.ts +1049 -0
- package/src/manual-boot.ts +322 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +26 -0
|
@@ -0,0 +1,993 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access -- pre-existing lint debt across this 800-line provider-factory module. The flagged sites delegate into Moleculer/EventBus/IntegrationRegistry surfaces typed as `unknown` to break circular dependencies; runtime contracts are validated by the cap-mount-helper layer above. Tracked separately. */
|
|
2
|
+
/**
|
|
3
|
+
* Capability provider factories for the Phase E core caps —
|
|
4
|
+
* `system`, `network-quality`, `toast`, `nodes`, `integrations`,
|
|
5
|
+
* `addons`. Each factory builds a fresh provider object that
|
|
6
|
+
* fulfils the cap's `InferProvider<...>` contract by delegating
|
|
7
|
+
* to the existing backend services.
|
|
8
|
+
*
|
|
9
|
+
* Why factories instead of static singletons?
|
|
10
|
+
* - `addons.custom` needs per-request `ctx.user` for per-action
|
|
11
|
+
* auth checks. The cap-router codegen already passes `ctx`
|
|
12
|
+
* into `getProvider(ctx)`, so closing over it is cheap and
|
|
13
|
+
* keeps the auth surface tight.
|
|
14
|
+
* - The other caps don't need ctx, but we keep the signature
|
|
15
|
+
* uniform for symmetry.
|
|
16
|
+
*
|
|
17
|
+
* No addon "owns" these surfaces — they manage cluster state
|
|
18
|
+
* (cluster topology, integrations, addon packages) or expose
|
|
19
|
+
* server-level singletons (toast bus, network-quality tracker,
|
|
20
|
+
* feature flags + retention controls).
|
|
21
|
+
*
|
|
22
|
+
* Phase E (2026-05-06): replaces the hand-written core routers in
|
|
23
|
+
* `server/backend/src/api/core/{system,network-quality,toast,
|
|
24
|
+
* nodes,integrations,addons}.router.ts`.
|
|
25
|
+
*/
|
|
26
|
+
import * as os from 'node:os'
|
|
27
|
+
import { execFile } from 'node:child_process'
|
|
28
|
+
import { promisify } from 'node:util'
|
|
29
|
+
import { randomUUID } from 'node:crypto'
|
|
30
|
+
import { TRPCError } from '@trpc/server'
|
|
31
|
+
import type {
|
|
32
|
+
IAddonsProvider,
|
|
33
|
+
IIntegrationsProvider,
|
|
34
|
+
INetworkQualityProvider,
|
|
35
|
+
INodesProvider,
|
|
36
|
+
ISystemProvider,
|
|
37
|
+
IToastProvider,
|
|
38
|
+
IAnalysisDataPersistence,
|
|
39
|
+
Toast,
|
|
40
|
+
TopologyNode,
|
|
41
|
+
Integration,
|
|
42
|
+
IIntegrationRegistry,
|
|
43
|
+
IDeviceProvider,
|
|
44
|
+
CapabilityMethodAuth,
|
|
45
|
+
} from '@camstack/types'
|
|
46
|
+
import { asJsonObject, asJsonArray, errMsg } from '@camstack/types'
|
|
47
|
+
import type { CapabilityRegistry } from '@camstack/kernel'
|
|
48
|
+
import { getCapUsageRegistry } from '@camstack/kernel'
|
|
49
|
+
import type { ToastService, NotificationService } from '@camstack/core'
|
|
50
|
+
import type { TrpcContext } from '../trpc/trpc.context.js'
|
|
51
|
+
import type { FeatureService } from '../../core/feature/feature.service'
|
|
52
|
+
import type { LoggingService } from '../../core/logging/logging.service'
|
|
53
|
+
import type { EventBusService } from '../../core/events/event-bus.service'
|
|
54
|
+
import type { AgentRegistryService } from '../../core/agent/agent-registry.service'
|
|
55
|
+
import type { MoleculerService } from '../../core/moleculer/moleculer.service'
|
|
56
|
+
import type { AddonRegistryService } from '../../core/addon/addon-registry.service'
|
|
57
|
+
import type { AddonPackageService } from '../../core/addon/addon-package.service'
|
|
58
|
+
import type { NetworkQualityService } from '../../core/network/network-quality.service'
|
|
59
|
+
import type { ConfigService } from '../../core/config/config.service'
|
|
60
|
+
import { persistCollectionDisabled } from './collection-preference.js'
|
|
61
|
+
|
|
62
|
+
const execFileAsync = promisify(execFile)
|
|
63
|
+
|
|
64
|
+
// ── system ──────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
function getRetention(registry: CapabilityRegistry | null) {
|
|
67
|
+
return registry?.getSingleton<IAnalysisDataPersistence>('analysis-data-persistence')?.retention ?? null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function buildSystemProvider(
|
|
71
|
+
feature: FeatureService,
|
|
72
|
+
registry: CapabilityRegistry | null,
|
|
73
|
+
): ISystemProvider {
|
|
74
|
+
return {
|
|
75
|
+
info: async () => feature.getManifest(),
|
|
76
|
+
health: async () => ({ status: 'ok' as const, uptime: process.uptime() }),
|
|
77
|
+
featureFlags: async () => feature.getManifest(),
|
|
78
|
+
networkAddresses: async () => {
|
|
79
|
+
const ifaces = os.networkInterfaces()
|
|
80
|
+
const result: Array<{ name: string; address: string; family: string; internal: boolean }> = []
|
|
81
|
+
for (const [name, addrs] of Object.entries(ifaces)) {
|
|
82
|
+
for (const addr of addrs ?? []) {
|
|
83
|
+
result.push({
|
|
84
|
+
name,
|
|
85
|
+
address: addr.address,
|
|
86
|
+
family: addr.family,
|
|
87
|
+
internal: addr.internal,
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return result
|
|
92
|
+
},
|
|
93
|
+
getRetentionConfig: async () => getRetention(registry)?.getConfig() ?? null,
|
|
94
|
+
setRetentionConfig: async (input) => {
|
|
95
|
+
getRetention(registry)?.setConfig(input)
|
|
96
|
+
return null
|
|
97
|
+
},
|
|
98
|
+
forceRetentionCleanup: async () => {
|
|
99
|
+
await getRetention(registry)?.forceCleanup()
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── network-quality ─────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
export function buildNetworkQualityProvider(
|
|
107
|
+
nq: NetworkQualityService,
|
|
108
|
+
): INetworkQualityProvider {
|
|
109
|
+
return {
|
|
110
|
+
getDeviceStats: async (input) => nq.getDeviceStats(input.deviceId),
|
|
111
|
+
getAllStats: async () => nq.getAllStats(),
|
|
112
|
+
reportClientStats: async (input) => {
|
|
113
|
+
nq.reportClientStats(input.deviceId, {
|
|
114
|
+
rttMs: input.rttMs,
|
|
115
|
+
jitterMs: input.jitterMs,
|
|
116
|
+
estimatedBandwidthKbps: input.estimatedBandwidthKbps,
|
|
117
|
+
})
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── toast ───────────────────────────────────────────────────────────
|
|
123
|
+
//
|
|
124
|
+
// Per-request factory: each subscription opens its own connectionId.
|
|
125
|
+
// The factory captures `ctx.user.id` so the underlying ToastService
|
|
126
|
+
// can scope deliveries (broadcast vs. per-user). The cap defines
|
|
127
|
+
// `onToast` as a subscription — the codegen wires it through the
|
|
128
|
+
// `iterableSubscription` adapter and pushes raw `Toast` payloads.
|
|
129
|
+
|
|
130
|
+
export function buildToastProvider(
|
|
131
|
+
toastService: ToastService | null,
|
|
132
|
+
ctx: TrpcContext,
|
|
133
|
+
): IToastProvider {
|
|
134
|
+
return {
|
|
135
|
+
onToast: (_input, push) => {
|
|
136
|
+
if (!toastService) return () => {}
|
|
137
|
+
const userId = ctx.user?.id ?? 'anonymous'
|
|
138
|
+
const connectionId = randomUUID()
|
|
139
|
+
const unsubscribe = toastService.subscribe(connectionId, userId, (toast: Toast) => push(toast))
|
|
140
|
+
return unsubscribe ?? (() => {})
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── nodes ───────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
interface SubProcessLite {
|
|
148
|
+
readonly pid: number
|
|
149
|
+
readonly name: string
|
|
150
|
+
readonly state: string
|
|
151
|
+
readonly cpuPercent: number
|
|
152
|
+
readonly memoryRss: number
|
|
153
|
+
readonly uptimeSeconds: number
|
|
154
|
+
readonly addonIds?: readonly string[]
|
|
155
|
+
readonly groupId?: string | null
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function getLocalIps(): string[] {
|
|
159
|
+
const interfaces = os.networkInterfaces()
|
|
160
|
+
const ips: string[] = []
|
|
161
|
+
for (const ifaces of Object.values(interfaces)) {
|
|
162
|
+
if (!ifaces) continue
|
|
163
|
+
for (const iface of ifaces) {
|
|
164
|
+
if (iface.internal) continue
|
|
165
|
+
ips.push(iface.address)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return ips
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Pure (well, async) topology computation — same shape returned by the
|
|
173
|
+
* `nodes.topology` cap procedure. Extracted so the topology emitter
|
|
174
|
+
* service can produce identical snapshots without going through tRPC.
|
|
175
|
+
*/
|
|
176
|
+
export async function computeTopology(
|
|
177
|
+
agentRegistry: AgentRegistryService,
|
|
178
|
+
addonRegistry?: AddonRegistryService,
|
|
179
|
+
): Promise<readonly TopologyNode[]> {
|
|
180
|
+
const nodes = await agentRegistry.listNodes()
|
|
181
|
+
const allAddons = addonRegistry?.listAddons() ?? []
|
|
182
|
+
const getInGroupAddonIds = (node: typeof nodes[number]): readonly string[] => {
|
|
183
|
+
const subs = (node.subProcesses ?? []) as readonly SubProcessLite[]
|
|
184
|
+
return subs.flatMap((p) => p.addonIds ?? [])
|
|
185
|
+
}
|
|
186
|
+
const addonCaps = new Map<string, readonly string[]>()
|
|
187
|
+
for (const a of allAddons) {
|
|
188
|
+
const id = a.manifest?.id ?? ''
|
|
189
|
+
const caps = a.declaration?.capabilities?.map(c => typeof c === 'string' ? c : c.name) ?? []
|
|
190
|
+
addonCaps.set(id, caps)
|
|
191
|
+
}
|
|
192
|
+
const addonCategory = new Map<string, string>()
|
|
193
|
+
for (const a of allAddons) {
|
|
194
|
+
const id = a.manifest?.id ?? ''
|
|
195
|
+
const category = a.declaration?.category ?? 'system'
|
|
196
|
+
addonCategory.set(id, category)
|
|
197
|
+
}
|
|
198
|
+
return nodes.map((node) => {
|
|
199
|
+
const inGroupAddonIds = new Set(getInGroupAddonIds(node))
|
|
200
|
+
const agentAddonIds: readonly string[] = (node as { agentAddons?: readonly string[] }).agentAddons ?? []
|
|
201
|
+
type NodeAddonEntry = { id: string; capabilities: readonly string[]; status: 'running' }
|
|
202
|
+
const allNodeAddons: NodeAddonEntry[] = node.isHub
|
|
203
|
+
? allAddons.map((a) => {
|
|
204
|
+
const id = a.manifest?.id ?? 'unknown'
|
|
205
|
+
return { id, capabilities: [...(addonCaps.get(id) ?? [])], status: 'running' as const }
|
|
206
|
+
})
|
|
207
|
+
: agentAddonIds.map((addonId) => ({
|
|
208
|
+
id: addonId,
|
|
209
|
+
capabilities: [...(addonCaps.get(addonId) ?? [])],
|
|
210
|
+
status: 'running' as const,
|
|
211
|
+
}))
|
|
212
|
+
const inProcessAddons = allNodeAddons.filter((a) => !inGroupAddonIds.has(a.id))
|
|
213
|
+
const isolatedProcesses = ((node.subProcesses ?? []) as readonly SubProcessLite[])
|
|
214
|
+
const mainProcessServices = inProcessAddons.map(a => ({
|
|
215
|
+
addonId: a.id,
|
|
216
|
+
capabilities: a.capabilities,
|
|
217
|
+
status: a.status,
|
|
218
|
+
}))
|
|
219
|
+
const mainProcess = node.isHub ? {
|
|
220
|
+
pid: process.pid,
|
|
221
|
+
name: 'hub (core)',
|
|
222
|
+
state: 'running' as const,
|
|
223
|
+
cpuPercent: node.status?.cpuPercent ?? 0,
|
|
224
|
+
memoryRss: process.memoryUsage().rss,
|
|
225
|
+
uptimeSeconds: Math.floor(process.uptime()),
|
|
226
|
+
services: mainProcessServices,
|
|
227
|
+
} : {
|
|
228
|
+
pid: 0,
|
|
229
|
+
name: `${node.info.id} (core)`,
|
|
230
|
+
state: 'running' as const,
|
|
231
|
+
cpuPercent: node.status?.cpuPercent ?? 0,
|
|
232
|
+
memoryRss: 0,
|
|
233
|
+
uptimeSeconds: Math.floor((Date.now() - node.connectedSince) / 1000),
|
|
234
|
+
services: mainProcessServices,
|
|
235
|
+
}
|
|
236
|
+
const childProcesses = isolatedProcesses.map((p) => {
|
|
237
|
+
const memberIds = (p.addonIds && p.addonIds.length > 0) ? p.addonIds : [p.name]
|
|
238
|
+
return {
|
|
239
|
+
pid: p.pid,
|
|
240
|
+
name: p.name,
|
|
241
|
+
state: p.state,
|
|
242
|
+
cpuPercent: p.cpuPercent,
|
|
243
|
+
memoryRss: p.memoryRss,
|
|
244
|
+
uptimeSeconds: p.uptimeSeconds,
|
|
245
|
+
groupId: p.groupId ?? p.name,
|
|
246
|
+
services: memberIds.map((addonId) => ({
|
|
247
|
+
addonId,
|
|
248
|
+
capabilities: [...(addonCaps.get(addonId) ?? [])],
|
|
249
|
+
status: p.state,
|
|
250
|
+
})),
|
|
251
|
+
}
|
|
252
|
+
})
|
|
253
|
+
// Aggregate node-local addons by category. `allNodeAddons` already
|
|
254
|
+
// contains the per-node addon roster (hub uses every installed
|
|
255
|
+
// addon; agents use their assigned agentAddons subset).
|
|
256
|
+
const byCategory = new Map<string, {
|
|
257
|
+
category: string
|
|
258
|
+
total: number
|
|
259
|
+
healthy: number
|
|
260
|
+
addons: { id: string; status: string; cpuPercent: number; memoryRss: number }[]
|
|
261
|
+
}>()
|
|
262
|
+
const procByAddon = new Map<string, { cpuPercent: number; memoryRss: number; state: string }>()
|
|
263
|
+
for (const p of (node.subProcesses ?? []) as readonly SubProcessLite[]) {
|
|
264
|
+
for (const addonId of (p.addonIds ?? [])) {
|
|
265
|
+
procByAddon.set(addonId, { cpuPercent: p.cpuPercent, memoryRss: p.memoryRss, state: p.state })
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
for (const a of allNodeAddons) {
|
|
269
|
+
const category = addonCategory.get(a.id) ?? 'system'
|
|
270
|
+
const procInfo = procByAddon.get(a.id)
|
|
271
|
+
const entry = byCategory.get(category) ?? { category, total: 0, healthy: 0, addons: [] }
|
|
272
|
+
const status = procInfo?.state ?? a.status
|
|
273
|
+
entry.total += 1
|
|
274
|
+
if (status === 'running') entry.healthy += 1
|
|
275
|
+
entry.addons.push({
|
|
276
|
+
id: a.id,
|
|
277
|
+
status,
|
|
278
|
+
cpuPercent: procInfo?.cpuPercent ?? 0,
|
|
279
|
+
memoryRss: procInfo?.memoryRss ?? 0,
|
|
280
|
+
})
|
|
281
|
+
byCategory.set(category, entry)
|
|
282
|
+
}
|
|
283
|
+
const categoriesProjection = [...byCategory.values()]
|
|
284
|
+
return {
|
|
285
|
+
id: node.info.id,
|
|
286
|
+
name: node.info.name,
|
|
287
|
+
hostname: node.isHub ? os.hostname() : (node.info.hostname ?? node.info.id),
|
|
288
|
+
platform: node.info.platform ?? 'unknown',
|
|
289
|
+
arch: node.info.arch ?? 'unknown',
|
|
290
|
+
cpuModel: node.info.cpuModel ?? null,
|
|
291
|
+
cpuCores: node.info.cpuCores ?? 0,
|
|
292
|
+
memoryMB: node.info.memoryMB ?? 0,
|
|
293
|
+
engines: [...(node.info.pythonRuntimes ?? [])],
|
|
294
|
+
isHub: node.isHub,
|
|
295
|
+
isOnline: node.isOnline !== false,
|
|
296
|
+
cpuPercent: node.status?.cpuPercent ?? 0,
|
|
297
|
+
memoryPercent: node.status?.memoryPercent ?? 0,
|
|
298
|
+
uptime: Date.now() - node.connectedSince,
|
|
299
|
+
lastSeen: new Date().toISOString(),
|
|
300
|
+
localIps: node.isHub ? getLocalIps() : (node.localIps ?? []),
|
|
301
|
+
addons: allNodeAddons,
|
|
302
|
+
processes: [
|
|
303
|
+
mainProcess,
|
|
304
|
+
...childProcesses,
|
|
305
|
+
],
|
|
306
|
+
categories: categoriesProjection,
|
|
307
|
+
}
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Local narrowing of Moleculer's broker. Going through `moleculer.broker`
|
|
313
|
+
* directly leaves typescript-eslint with `error`-typed access — Moleculer's
|
|
314
|
+
* `index.d.ts` chains through `eventemitter2` whose package.json has no
|
|
315
|
+
* `types` field; under `moduleResolution: node` the parser loses the chain
|
|
316
|
+
* even though `tsc --skipLibCheck` accepts it. Same shim is used in
|
|
317
|
+
* `addon-registry.service.ts`; consider hoisting if a third call site appears.
|
|
318
|
+
*/
|
|
319
|
+
interface BrokerLike {
|
|
320
|
+
call<T = unknown>(action: string, params?: unknown, opts?: { nodeID?: string; timeout?: number }): Promise<T>
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function buildNodesProvider(
|
|
324
|
+
agentRegistry: AgentRegistryService,
|
|
325
|
+
moleculer: MoleculerService,
|
|
326
|
+
addonRegistry?: AddonRegistryService,
|
|
327
|
+
): INodesProvider {
|
|
328
|
+
const broker = moleculer.broker as unknown as BrokerLike
|
|
329
|
+
return {
|
|
330
|
+
topology: async () => computeTopology(agentRegistry, addonRegistry),
|
|
331
|
+
deployAddon: async () => {
|
|
332
|
+
// Placeholder — actual deployment orchestration TBD
|
|
333
|
+
return { success: true }
|
|
334
|
+
},
|
|
335
|
+
undeployAddon: async (input) => {
|
|
336
|
+
await broker.call('$agent.undeploy', {
|
|
337
|
+
addonId: input.addonId,
|
|
338
|
+
}, { nodeID: input.nodeId, timeout: 30_000 })
|
|
339
|
+
return { success: true }
|
|
340
|
+
},
|
|
341
|
+
restartAddon: async (input) => {
|
|
342
|
+
const isHubLocal = input.nodeId === 'hub' || input.nodeId.startsWith('hub/')
|
|
343
|
+
if (isHubLocal && addonRegistry) {
|
|
344
|
+
const result: unknown = await addonRegistry.restartAddon(input.addonId)
|
|
345
|
+
if (typeof result === 'object' && result !== null && 'success' in result) {
|
|
346
|
+
return result as { success: boolean }
|
|
347
|
+
}
|
|
348
|
+
return { success: true }
|
|
349
|
+
}
|
|
350
|
+
const agentNodeId = input.nodeId.includes('/') ? input.nodeId.split('/')[0]! : input.nodeId
|
|
351
|
+
await broker.call('$agent.restart', {
|
|
352
|
+
addonId: input.addonId,
|
|
353
|
+
}, { nodeID: agentNodeId, timeout: 30_000 })
|
|
354
|
+
return { success: true }
|
|
355
|
+
},
|
|
356
|
+
restartProcess: async (input) => {
|
|
357
|
+
return await broker.call('$process.restart', {
|
|
358
|
+
name: input.processName,
|
|
359
|
+
}, { nodeID: input.nodeId, timeout: 30_000 }) as { success: boolean; reason?: string }
|
|
360
|
+
},
|
|
361
|
+
restartNode: async (input) => {
|
|
362
|
+
return await broker.call('$process.restartAll', {}, {
|
|
363
|
+
nodeID: input.nodeId,
|
|
364
|
+
timeout: 60_000,
|
|
365
|
+
}) as { restarted: readonly string[]; failed: readonly string[] }
|
|
366
|
+
},
|
|
367
|
+
shutdownNode: async (input) => {
|
|
368
|
+
if (input.nodeId === 'hub') {
|
|
369
|
+
setTimeout(() => process.exit(0), 500)
|
|
370
|
+
return { success: true }
|
|
371
|
+
}
|
|
372
|
+
await broker.call('$agent.shutdown', {}, {
|
|
373
|
+
nodeID: input.nodeId,
|
|
374
|
+
timeout: 10_000,
|
|
375
|
+
})
|
|
376
|
+
return { success: true }
|
|
377
|
+
},
|
|
378
|
+
renameNode: async (input) => {
|
|
379
|
+
const trimmed = input.name.trim()
|
|
380
|
+
if (input.nodeId === 'hub') {
|
|
381
|
+
const key = `node-display-name:${input.nodeId}`
|
|
382
|
+
await broker.call('settings-store.set', {
|
|
383
|
+
collection: 'system-settings',
|
|
384
|
+
key,
|
|
385
|
+
value: trimmed,
|
|
386
|
+
})
|
|
387
|
+
} else {
|
|
388
|
+
await broker.call('$agent.rename', {
|
|
389
|
+
name: trimmed,
|
|
390
|
+
}, { nodeID: input.nodeId, timeout: 10_000 })
|
|
391
|
+
agentRegistry.updateAgentName(input.nodeId, trimmed)
|
|
392
|
+
}
|
|
393
|
+
return { nodeId: input.nodeId, name: trimmed }
|
|
394
|
+
},
|
|
395
|
+
getNodeAddons: async (input) => {
|
|
396
|
+
// Hub branch: read straight from the local registry. Surfaces every
|
|
397
|
+
// loaded addon with its package name + version so the per-node Addons
|
|
398
|
+
// tab can render the same shape regardless of whether the target is
|
|
399
|
+
// hub or agent.
|
|
400
|
+
if (input.nodeId === 'hub') {
|
|
401
|
+
const rows = addonRegistry?.listAddons() ?? []
|
|
402
|
+
return rows.map((r) => ({
|
|
403
|
+
id: r.manifest.id,
|
|
404
|
+
status: r.process?.state ?? 'running',
|
|
405
|
+
version: r.manifest.packageVersion,
|
|
406
|
+
packageName: r.manifest.packageName,
|
|
407
|
+
}))
|
|
408
|
+
}
|
|
409
|
+
// Agent branch: forward to `$agent.status` and pick out its `addons`
|
|
410
|
+
// field. Done as a direct call (not via `agentRegistry`'s cached
|
|
411
|
+
// listing) so the UI always sees fresh data when it opens the tab
|
|
412
|
+
// — the cache otherwise lags the cluster heartbeat interval.
|
|
413
|
+
try {
|
|
414
|
+
const status = await broker.call<{
|
|
415
|
+
addons?: readonly { id: string; status: string; version?: string; packageName?: string }[]
|
|
416
|
+
}>('$agent.status', {}, { nodeID: input.nodeId, timeout: 5_000 })
|
|
417
|
+
return (status.addons ?? []).map((a) => ({
|
|
418
|
+
id: a.id,
|
|
419
|
+
status: a.status,
|
|
420
|
+
...(a.version !== undefined ? { version: a.version } : {}),
|
|
421
|
+
...(a.packageName !== undefined ? { packageName: a.packageName } : {}),
|
|
422
|
+
}))
|
|
423
|
+
} catch {
|
|
424
|
+
return []
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
clusterAddonStatus: async () => {
|
|
428
|
+
const hubAddons = addonRegistry?.listAllAddons() ?? []
|
|
429
|
+
const hubMap = new Map(hubAddons.map((a) => [a.manifest.id, a]))
|
|
430
|
+
|
|
431
|
+
const nodes = await agentRegistry.listNodes()
|
|
432
|
+
const remoteNodes = nodes.filter((n) => !n.isHub && n.connectedSince > 0)
|
|
433
|
+
|
|
434
|
+
const remoteStatuses = await Promise.all(
|
|
435
|
+
remoteNodes.map(async (node) => {
|
|
436
|
+
try {
|
|
437
|
+
const status = await broker.call('$agent.status', {}, {
|
|
438
|
+
nodeID: node.info.id, timeout: 5_000,
|
|
439
|
+
}) as { addons?: readonly { id: string; status: string; version?: string; packageName?: string }[] }
|
|
440
|
+
return { nodeId: node.info.id, name: node.info.name, online: true, addons: status.addons ?? [] }
|
|
441
|
+
} catch {
|
|
442
|
+
return { nodeId: node.info.id, name: node.info.name, online: false, addons: [] as readonly { id: string; status: string; version?: string; packageName?: string }[] }
|
|
443
|
+
}
|
|
444
|
+
}),
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
type NodeDeployment = { nodeId: string; name: string; version: string; status: string; synced: boolean }
|
|
448
|
+
const result: Record<string, { hubVersion: string; nodes: NodeDeployment[] }> = {}
|
|
449
|
+
|
|
450
|
+
for (const [addonId, hubAddon] of hubMap) {
|
|
451
|
+
const hubVersion = hubAddon.manifest.packageVersion
|
|
452
|
+
const addonNodes: NodeDeployment[] = [
|
|
453
|
+
{ nodeId: 'hub', name: 'hub', version: hubVersion, status: 'running', synced: true },
|
|
454
|
+
]
|
|
455
|
+
|
|
456
|
+
for (const remote of remoteStatuses) {
|
|
457
|
+
const remoteAddon = remote.addons.find((a) => a.id === addonId)
|
|
458
|
+
if (remoteAddon) {
|
|
459
|
+
const remoteVersion = remoteAddon.version ?? 'unknown'
|
|
460
|
+
addonNodes.push({
|
|
461
|
+
nodeId: remote.nodeId,
|
|
462
|
+
name: remote.name,
|
|
463
|
+
version: remoteVersion,
|
|
464
|
+
status: remote.online ? remoteAddon.status : 'offline',
|
|
465
|
+
synced: remoteVersion === hubVersion,
|
|
466
|
+
})
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
result[addonId] = { hubVersion, nodes: addonNodes }
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return result
|
|
474
|
+
},
|
|
475
|
+
getCapUsageGraph: async (input) => {
|
|
476
|
+
const reg = getCapUsageRegistry()
|
|
477
|
+
return reg.getGraph({ windowSeconds: input.windowSeconds, nowMs: Date.now() })
|
|
478
|
+
},
|
|
479
|
+
setProcessLogLevel: async (input) => {
|
|
480
|
+
await broker.call('$node-mgmt.setLogLevel', {
|
|
481
|
+
level: input.level,
|
|
482
|
+
}, { nodeID: input.nodeId, timeout: 5_000 })
|
|
483
|
+
return { success: true }
|
|
484
|
+
},
|
|
485
|
+
executeQuery: async (input) => {
|
|
486
|
+
return await broker.call(
|
|
487
|
+
`${input.addonId}.queryable.query`,
|
|
488
|
+
{ queryName: input.queryName, params: input.params ?? {} },
|
|
489
|
+
{ nodeID: input.nodeId, timeout: 30_000 },
|
|
490
|
+
)
|
|
491
|
+
},
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ── integrations ────────────────────────────────────────────────────
|
|
496
|
+
|
|
497
|
+
interface IntegrationWithProcessState extends Integration {
|
|
498
|
+
readonly processState: string
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function requireIntegrationRegistry(ar: AddonRegistryService): IIntegrationRegistry {
|
|
502
|
+
const reg = ar.getIntegrationRegistry()
|
|
503
|
+
if (!reg) throw new Error('IntegrationRegistry not available')
|
|
504
|
+
return reg
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function isDeviceProvider(value: unknown): value is IDeviceProvider {
|
|
508
|
+
return value !== null
|
|
509
|
+
&& typeof value === 'object'
|
|
510
|
+
&& typeof Reflect.get(value, 'discoverDevices') === 'function'
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function getDeviceProvider(ar: AddonRegistryService, addonId: string): IDeviceProvider | null {
|
|
514
|
+
const provider = ar.getCapabilityRegistry().getProviderByAddon('device-provider', addonId)
|
|
515
|
+
return isDeviceProvider(provider) ? provider : null
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export function buildIntegrationsProvider(
|
|
519
|
+
ar: AddonRegistryService,
|
|
520
|
+
eb: EventBusService,
|
|
521
|
+
loggingService: LoggingService,
|
|
522
|
+
): IIntegrationsProvider {
|
|
523
|
+
const logger = loggingService.createLogger('integrations')
|
|
524
|
+
const withProcessState = (i: Integration): IntegrationWithProcessState => ({
|
|
525
|
+
...i,
|
|
526
|
+
processState:
|
|
527
|
+
ar.listAddons().find(a => a.manifest.id === i.addonId)?.process?.state
|
|
528
|
+
?? 'unknown',
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
return {
|
|
532
|
+
list: async () => {
|
|
533
|
+
const integrations = await requireIntegrationRegistry(ar).listIntegrations()
|
|
534
|
+
return integrations.map(withProcessState)
|
|
535
|
+
},
|
|
536
|
+
get: async (input) => {
|
|
537
|
+
const integration = await requireIntegrationRegistry(ar).getIntegration(input.id)
|
|
538
|
+
if (!integration) throw new Error(`Integration "${input.id}" not found`)
|
|
539
|
+
return withProcessState(integration)
|
|
540
|
+
},
|
|
541
|
+
getByAddonId: async (input) => requireIntegrationRegistry(ar).getIntegrationByAddonId(input.addonId),
|
|
542
|
+
create: async (input) => {
|
|
543
|
+
const { skipRestart, ...payload } = input
|
|
544
|
+
const reg = requireIntegrationRegistry(ar)
|
|
545
|
+
|
|
546
|
+
logger.info('request', { tags: { addonId: input.addonId }, meta: { phase: 'create', name: input.name } })
|
|
547
|
+
|
|
548
|
+
const addon = ar.listAddons().find((a) => a.manifest.id === input.addonId)
|
|
549
|
+
const instanceMode =
|
|
550
|
+
addon?.declaration?.instanceMode ?? addon?.manifest?.instanceMode ?? 'multiple'
|
|
551
|
+
if (instanceMode === 'unique') {
|
|
552
|
+
const existing = (await reg.listIntegrations()).filter(
|
|
553
|
+
(i) => i.addonId === input.addonId,
|
|
554
|
+
)
|
|
555
|
+
if (existing.length > 0) {
|
|
556
|
+
logger.warn(
|
|
557
|
+
'rejected duplicate unique',
|
|
558
|
+
{ tags: { addonId: input.addonId, integrationId: existing[0]!.id }, meta: { phase: 'create' } },
|
|
559
|
+
)
|
|
560
|
+
throw new Error(
|
|
561
|
+
`Addon "${input.addonId}" is unique-instance and already has an integration (${existing[0]!.id})`,
|
|
562
|
+
)
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const integration = await reg.createIntegration(payload)
|
|
567
|
+
logger.info('persisted', { tags: { integrationId: integration.id, addonId: integration.addonId }, meta: { phase: 'create' } })
|
|
568
|
+
|
|
569
|
+
const hasSettings = input.settings != null && Object.keys(input.settings).length > 0
|
|
570
|
+
if (!skipRestart && hasSettings) {
|
|
571
|
+
logger.info('settings present — restarting addon', { tags: { addonId: input.addonId }, meta: { phase: 'create' } })
|
|
572
|
+
await ar.restartAddon(input.addonId)
|
|
573
|
+
logger.info('addon restart complete', { tags: { addonId: input.addonId }, meta: { phase: 'create' } })
|
|
574
|
+
} else {
|
|
575
|
+
logger.info('skipping restart (no settings or skipRestart=true)', { meta: { phase: 'create' } })
|
|
576
|
+
}
|
|
577
|
+
return integration
|
|
578
|
+
},
|
|
579
|
+
update: async (input) => {
|
|
580
|
+
const reg = requireIntegrationRegistry(ar)
|
|
581
|
+
const previous = await reg.getIntegration(input.id)
|
|
582
|
+
if (!previous) {
|
|
583
|
+
logger.warn('not found', { tags: { integrationId: input.id }, meta: { phase: 'update' } })
|
|
584
|
+
throw new Error(`Integration "${input.id}" not found`)
|
|
585
|
+
}
|
|
586
|
+
const { id, ...updates } = input
|
|
587
|
+
const changedFields = Object.keys(updates).filter(
|
|
588
|
+
(k) => (updates as Record<string, unknown>)[k] !== undefined,
|
|
589
|
+
)
|
|
590
|
+
logger.info(
|
|
591
|
+
'request',
|
|
592
|
+
{ tags: { integrationId: input.id, addonId: previous.addonId }, meta: { phase: 'update', fields: changedFields } },
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
const result = await reg.updateIntegration(id, updates)
|
|
596
|
+
if (!result) throw new Error(`Integration "${id}" not found`)
|
|
597
|
+
|
|
598
|
+
const enabledChanged =
|
|
599
|
+
input.enabled !== undefined && input.enabled !== previous.enabled
|
|
600
|
+
if (enabledChanged) {
|
|
601
|
+
const category = input.enabled ? 'integration.enabled' : 'integration.disabled'
|
|
602
|
+
logger.info(
|
|
603
|
+
'enabled state changed',
|
|
604
|
+
{ tags: { integrationId: result.id, addonId: result.addonId }, meta: { phase: 'update', enabled: input.enabled } },
|
|
605
|
+
)
|
|
606
|
+
eb.emit({
|
|
607
|
+
id: `integration-${category}-${Date.now()}`,
|
|
608
|
+
timestamp: new Date(),
|
|
609
|
+
source: { type: 'integration', id: result.id },
|
|
610
|
+
category,
|
|
611
|
+
data: {
|
|
612
|
+
integrationId: result.id,
|
|
613
|
+
addonId: result.addonId,
|
|
614
|
+
},
|
|
615
|
+
})
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (input.name !== undefined && input.name !== previous.name) {
|
|
619
|
+
logger.info(
|
|
620
|
+
'renamed',
|
|
621
|
+
{ tags: { integrationId: result.id }, meta: { phase: 'update', previousName: previous.name, newName: input.name } },
|
|
622
|
+
)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const infoChanged = input.info !== undefined
|
|
626
|
+
if (infoChanged) {
|
|
627
|
+
logger.info('info changed — restarting addon', { tags: { addonId: result.addonId }, meta: { phase: 'update' } })
|
|
628
|
+
await ar.restartAddon(result.addonId)
|
|
629
|
+
logger.info('addon restart complete', { tags: { addonId: result.addonId }, meta: { phase: 'update' } })
|
|
630
|
+
} else {
|
|
631
|
+
logger.info('no restart needed (only enabled/name changed)', { meta: { phase: 'update' } })
|
|
632
|
+
}
|
|
633
|
+
return result
|
|
634
|
+
},
|
|
635
|
+
delete: async (input) => {
|
|
636
|
+
const reg = requireIntegrationRegistry(ar)
|
|
637
|
+
logger.info('request', { tags: { integrationId: input.id }, meta: { phase: 'delete' } })
|
|
638
|
+
|
|
639
|
+
const integration = await reg.getIntegration(input.id)
|
|
640
|
+
if (!integration) {
|
|
641
|
+
logger.warn('not found', { tags: { integrationId: input.id }, meta: { phase: 'delete' } })
|
|
642
|
+
throw new Error(`Integration "${input.id}" not found`)
|
|
643
|
+
}
|
|
644
|
+
logger.info(
|
|
645
|
+
'removing',
|
|
646
|
+
{ tags: { integrationId: input.id, addonId: integration.addonId }, meta: { phase: 'delete', name: integration.name } },
|
|
647
|
+
)
|
|
648
|
+
await reg.deleteIntegration(input.id)
|
|
649
|
+
|
|
650
|
+
eb.emit({
|
|
651
|
+
id: `integration-deleted-${Date.now()}`,
|
|
652
|
+
timestamp: new Date(),
|
|
653
|
+
source: { type: 'integration', id: input.id },
|
|
654
|
+
category: 'integration.deleted',
|
|
655
|
+
data: {
|
|
656
|
+
integrationId: input.id,
|
|
657
|
+
addonId: integration.addonId,
|
|
658
|
+
},
|
|
659
|
+
})
|
|
660
|
+
logger.info('completed (no restart)', { tags: { integrationId: input.id }, meta: { phase: 'delete' } })
|
|
661
|
+
|
|
662
|
+
return { success: true, deletedId: input.id }
|
|
663
|
+
},
|
|
664
|
+
getSettings: async (input) => {
|
|
665
|
+
const reg = requireIntegrationRegistry(ar)
|
|
666
|
+
const integration = await reg.getIntegration(input.id)
|
|
667
|
+
if (!integration) throw new Error(`Integration "${input.id}" not found`)
|
|
668
|
+
return reg.getIntegrationSettings(input.id)
|
|
669
|
+
},
|
|
670
|
+
setSettings: async (input) => {
|
|
671
|
+
const reg = requireIntegrationRegistry(ar)
|
|
672
|
+
const integration = await reg.getIntegration(input.id)
|
|
673
|
+
if (!integration) {
|
|
674
|
+
logger.warn('not found', { tags: { integrationId: input.id }, meta: { phase: 'setSettings' } })
|
|
675
|
+
throw new Error(`Integration "${input.id}" not found`)
|
|
676
|
+
}
|
|
677
|
+
const settingsKeys = Object.keys(input.settings)
|
|
678
|
+
logger.info(
|
|
679
|
+
'request',
|
|
680
|
+
{ tags: { integrationId: input.id, addonId: integration.addonId }, meta: { phase: 'setSettings', keys: settingsKeys } },
|
|
681
|
+
)
|
|
682
|
+
await reg.setIntegrationSettings(input.id, input.settings)
|
|
683
|
+
|
|
684
|
+
logger.info('persisted — restarting addon', { tags: { addonId: integration.addonId }, meta: { phase: 'setSettings' } })
|
|
685
|
+
await ar.restartAddon(integration.addonId)
|
|
686
|
+
logger.info('addon restart complete', { tags: { addonId: integration.addonId }, meta: { phase: 'setSettings' } })
|
|
687
|
+
return { success: true }
|
|
688
|
+
},
|
|
689
|
+
getAvailableTypes: async () => {
|
|
690
|
+
const reg = requireIntegrationRegistry(ar)
|
|
691
|
+
const addons = ar.listAddons()
|
|
692
|
+
// Hide failed-to-load packages from the picker. Fix #7 surfaces them in
|
|
693
|
+
// the Addons page so the operator can uninstall, but creating an
|
|
694
|
+
// integration against an addon that didn't load produces an orphaned
|
|
695
|
+
// row that `createFilteredRegistry` then filters out — silent data
|
|
696
|
+
// loss from the operator's POV. Filter at the source instead.
|
|
697
|
+
const providerAddons = addons.filter(a =>
|
|
698
|
+
a.process?.state !== 'failed' &&
|
|
699
|
+
a.manifest.capabilities?.some(c =>
|
|
700
|
+
typeof c === 'string' ? c === 'device-provider' : c.name === 'device-provider',
|
|
701
|
+
),
|
|
702
|
+
)
|
|
703
|
+
const integrations = await reg.listIntegrations()
|
|
704
|
+
return providerAddons.map(addon => {
|
|
705
|
+
const m = addon.manifest
|
|
706
|
+
const d = addon.declaration
|
|
707
|
+
const icon = d?.icon ?? m.icon
|
|
708
|
+
const color = d?.color ?? m.color ?? '#78716c'
|
|
709
|
+
const instanceMode = d?.instanceMode ?? m.instanceMode ?? 'multiple'
|
|
710
|
+
const existing = integrations.filter(i => i.addonId === m.id)
|
|
711
|
+
const provider = getDeviceProvider(ar, m.id)
|
|
712
|
+
const discoveryMode = provider?.discoveryMode ?? 'manual'
|
|
713
|
+
return {
|
|
714
|
+
addonId: m.id,
|
|
715
|
+
name: m.name ?? m.id,
|
|
716
|
+
description: m.description ?? '',
|
|
717
|
+
iconUrl: icon ? `/api/addon-assets/${m.id}/${icon}` : null,
|
|
718
|
+
color,
|
|
719
|
+
instanceMode,
|
|
720
|
+
discoveryMode,
|
|
721
|
+
existingInstances: existing.map(i => ({
|
|
722
|
+
id: i.id,
|
|
723
|
+
name: i.name,
|
|
724
|
+
})),
|
|
725
|
+
canAdd: instanceMode === 'multiple' || existing.length === 0,
|
|
726
|
+
}
|
|
727
|
+
})
|
|
728
|
+
},
|
|
729
|
+
testConnection: async (input) => {
|
|
730
|
+
const url = String(
|
|
731
|
+
input.settings['main_stream_url'] ?? input.settings['url'] ?? '',
|
|
732
|
+
).trim()
|
|
733
|
+
if (!url) return { success: false, error: 'No stream URL provided' }
|
|
734
|
+
try {
|
|
735
|
+
const { stdout } = await execFileAsync(
|
|
736
|
+
'ffprobe',
|
|
737
|
+
['-v', 'error', '-rtsp_transport', 'tcp', '-timeout', '3000000', '-show_entries', 'stream=codec_name,width,height', '-of', 'json', url],
|
|
738
|
+
{ timeout: 5000 },
|
|
739
|
+
)
|
|
740
|
+
const parsed = asJsonObject(JSON.parse(stdout))
|
|
741
|
+
const streams = asJsonArray(parsed?.streams)
|
|
742
|
+
return streams.length > 0
|
|
743
|
+
? { success: true }
|
|
744
|
+
: { success: false, error: 'No streams found at URL' }
|
|
745
|
+
} catch (err) {
|
|
746
|
+
return {
|
|
747
|
+
success: false,
|
|
748
|
+
error: `Connection failed: ${errMsg(err)}`,
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
},
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// ── addons ──────────────────────────────────────────────────────────
|
|
756
|
+
//
|
|
757
|
+
// `addons.custom` enforces per-action auth dynamically. The cap-router
|
|
758
|
+
// codegen passes ctx into `getProvider(ctx)` so we can close over
|
|
759
|
+
// `ctx.user` and reject before dispatching.
|
|
760
|
+
|
|
761
|
+
function ensureCustomActionAuth(ctx: TrpcContext, level: CapabilityMethodAuth): void {
|
|
762
|
+
if (level === 'public') return
|
|
763
|
+
if (!ctx.user) {
|
|
764
|
+
throw new TRPCError({ code: 'UNAUTHORIZED' })
|
|
765
|
+
}
|
|
766
|
+
if (level === 'protected') return
|
|
767
|
+
if (level === 'admin') {
|
|
768
|
+
if (!ctx.user.isAdmin) {
|
|
769
|
+
throw new TRPCError({ code: 'FORBIDDEN', message: 'custom action requires admin' })
|
|
770
|
+
}
|
|
771
|
+
return
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** A node id that refers to the hub itself (top-level or a hub group runner). */
|
|
776
|
+
function isHubNode(nodeId: string): boolean {
|
|
777
|
+
return nodeId === 'hub' || nodeId.startsWith('hub/')
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Read an agent's installed npm packages via `$agent.status`. The
|
|
782
|
+
* agent reports its addon roster (id + status + version + packageName);
|
|
783
|
+
* we keep only entries that carry both a package name and a version so
|
|
784
|
+
* the hub can diff them against npm.
|
|
785
|
+
*/
|
|
786
|
+
async function fetchAgentInstalledPackages(
|
|
787
|
+
broker: BrokerLike,
|
|
788
|
+
nodeId: string,
|
|
789
|
+
): Promise<readonly { name: string; version: string }[]> {
|
|
790
|
+
const status = await broker.call<{
|
|
791
|
+
addons?: readonly { id: string; status: string; version?: string; packageName?: string }[]
|
|
792
|
+
}>('$agent.status', {}, { nodeID: nodeId, timeout: 5_000 })
|
|
793
|
+
const out: { name: string; version: string }[] = []
|
|
794
|
+
for (const a of status.addons ?? []) {
|
|
795
|
+
if (typeof a.packageName === 'string' && typeof a.version === 'string') {
|
|
796
|
+
out.push({ name: a.packageName, version: a.version })
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
return out
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
export function buildAddonsProvider(
|
|
803
|
+
ar: AddonRegistryService,
|
|
804
|
+
ps: AddonPackageService,
|
|
805
|
+
ls: LoggingService,
|
|
806
|
+
moleculer: MoleculerService,
|
|
807
|
+
configService: ConfigService,
|
|
808
|
+
ctx: TrpcContext,
|
|
809
|
+
): IAddonsProvider {
|
|
810
|
+
const broker = moleculer.broker as unknown as BrokerLike
|
|
811
|
+
return {
|
|
812
|
+
list: async () => {
|
|
813
|
+
const rollbackable = ps.getRollbackablePackages()
|
|
814
|
+
const healthByPackage = new Map<string, ReturnType<typeof ar.getAddonHealthSnapshot>[number]>()
|
|
815
|
+
for (const h of ar.getAddonHealthSnapshot()) {
|
|
816
|
+
healthByPackage.set(h.packageName, h)
|
|
817
|
+
}
|
|
818
|
+
return ar.listAllAddons().map((item) => ({
|
|
819
|
+
...item,
|
|
820
|
+
hasBackup: rollbackable.has(item.manifest.packageName),
|
|
821
|
+
health: healthByPackage.get(item.manifest.packageName) ?? null,
|
|
822
|
+
}))
|
|
823
|
+
},
|
|
824
|
+
getLogs: async (input) => ls.query({
|
|
825
|
+
tags: { addonId: input.addonId },
|
|
826
|
+
limit: input.limit,
|
|
827
|
+
level: input.level,
|
|
828
|
+
}),
|
|
829
|
+
listPackages: async () => ps.listInstalled(),
|
|
830
|
+
installPackage: async (input) => ps.installAndLoad(input.packageName, input.version),
|
|
831
|
+
installFromWorkspace: async (input) => ps.installFromWorkspaceAndLoad(input.packageName),
|
|
832
|
+
isWorkspaceAvailable: async () => ps.isWorkspaceAvailable(),
|
|
833
|
+
listWorkspacePackages: async () => ps.listWorkspacePackages(),
|
|
834
|
+
uninstallPackage: async (input) => ps.uninstallAndReload(input.packageName),
|
|
835
|
+
reloadPackages: async () => ps.reloadPackages(),
|
|
836
|
+
searchAvailable: async (input) => {
|
|
837
|
+
const results = await ps.searchNpm(input?.query)
|
|
838
|
+
const installedIds = new Set(ps.listInstalled().map((p) => p.name))
|
|
839
|
+
return results.map((r) => ({
|
|
840
|
+
...r,
|
|
841
|
+
installed: installedIds.has(r.name),
|
|
842
|
+
}))
|
|
843
|
+
},
|
|
844
|
+
listUpdates: async (input) => {
|
|
845
|
+
const nodeId = input.nodeId
|
|
846
|
+
if (nodeId === undefined || isHubNode(nodeId)) return ps.checkUpdates()
|
|
847
|
+
const installed = await fetchAgentInstalledPackages(broker, nodeId)
|
|
848
|
+
return ps.checkUpdatesForInstalled(installed)
|
|
849
|
+
},
|
|
850
|
+
updatePackage: async (input) => {
|
|
851
|
+
const nodeId = input.nodeId
|
|
852
|
+
if (nodeId === undefined || isHubNode(nodeId)) {
|
|
853
|
+
return ps.updatePackage(input.name, input.version)
|
|
854
|
+
}
|
|
855
|
+
// Agent target: the hub packs the resolved version and ships the
|
|
856
|
+
// tarball over `$agent.deploy` — the agent has no npm runtime.
|
|
857
|
+
const packed = await ps.packPackage(input.name, input.version)
|
|
858
|
+
await broker.call('$agent.deploy', { addonId: input.name, bundle: packed.buffer }, { nodeID: nodeId, timeout: 120_000 })
|
|
859
|
+
await broker.call('$agent.reload', {}, { nodeID: nodeId, timeout: 120_000 })
|
|
860
|
+
return { success: true, name: input.name, version: packed.version, nodeId }
|
|
861
|
+
},
|
|
862
|
+
rollbackPackage: async (input) => ps.rollbackPackage(input.name),
|
|
863
|
+
forceRefresh: async (input) => {
|
|
864
|
+
const nodeId = input.nodeId
|
|
865
|
+
if (nodeId === undefined || isHubNode(nodeId)) return ps.checkUpdates(true)
|
|
866
|
+
// Agent rosters carry no hub-side cache — the diff is always live.
|
|
867
|
+
const installed = await fetchAgentInstalledPackages(broker, nodeId)
|
|
868
|
+
return ps.checkUpdatesForInstalled(installed)
|
|
869
|
+
},
|
|
870
|
+
restartServer: async () => ps.restartServer(ctx.user?.username ?? ctx.user?.id),
|
|
871
|
+
getLastRestart: async () => {
|
|
872
|
+
// Avoid pulling PostBootService through the cap-providers tree —
|
|
873
|
+
// dynamic import keeps the wiring loose and lets us read the
|
|
874
|
+
// static cache without a constructor dependency.
|
|
875
|
+
const mod = await import('../../boot/post-boot.service.js')
|
|
876
|
+
return mod.PostBootService.getLastRestart()
|
|
877
|
+
},
|
|
878
|
+
listFrameworkPackages: async () => ps.listFrameworkPackages(),
|
|
879
|
+
listCapabilityProviders: async (input) => {
|
|
880
|
+
const registry = ar.getCapabilityRegistry()
|
|
881
|
+
const caps = registry.listCapabilities()
|
|
882
|
+
const found = caps.find((c) => c.name === input.capName)
|
|
883
|
+
if (!found) return []
|
|
884
|
+
const mode = found.mode === 'collection' ? 'collection' as const : 'singleton' as const
|
|
885
|
+
const disabled = new Set(found.disabledProviders)
|
|
886
|
+
return found.providers.map((addonId) => ({
|
|
887
|
+
addonId,
|
|
888
|
+
mode,
|
|
889
|
+
isActive: mode === 'collection'
|
|
890
|
+
? !disabled.has(addonId)
|
|
891
|
+
: found.activeProvider === addonId,
|
|
892
|
+
}))
|
|
893
|
+
},
|
|
894
|
+
setCapabilityProviderEnabled: async (input) => {
|
|
895
|
+
const registry = ar.getCapabilityRegistry()
|
|
896
|
+
const caps = registry.listCapabilities()
|
|
897
|
+
const found = caps.find((c) => c.name === input.capName)
|
|
898
|
+
if (!found) {
|
|
899
|
+
throw new TRPCError({
|
|
900
|
+
code: 'NOT_FOUND',
|
|
901
|
+
message: `Unknown capability: ${input.capName}`,
|
|
902
|
+
})
|
|
903
|
+
}
|
|
904
|
+
if (found.mode !== 'collection') {
|
|
905
|
+
throw new TRPCError({
|
|
906
|
+
code: 'BAD_REQUEST',
|
|
907
|
+
message: `Capability "${input.capName}" is not a collection`,
|
|
908
|
+
})
|
|
909
|
+
}
|
|
910
|
+
if (!found.providers.includes(input.addonId)) {
|
|
911
|
+
throw new TRPCError({
|
|
912
|
+
code: 'BAD_REQUEST',
|
|
913
|
+
message: `Provider "${input.addonId}" is not registered for "${input.capName}"`,
|
|
914
|
+
})
|
|
915
|
+
}
|
|
916
|
+
if (input.enabled) {
|
|
917
|
+
registry.enableCollectionProvider(input.capName, input.addonId)
|
|
918
|
+
} else {
|
|
919
|
+
registry.disableCollectionProvider(input.capName, input.addonId)
|
|
920
|
+
}
|
|
921
|
+
// Persist the new disabled-set so the choice survives a hub reboot.
|
|
922
|
+
// Reuses the same `capabilities.collection.<cap>` key/format the
|
|
923
|
+
// `capabilities` core router writes — via the shared canonical writer.
|
|
924
|
+
const updated = registry.listCapabilities().find((c) => c.name === input.capName)
|
|
925
|
+
persistCollectionDisabled(
|
|
926
|
+
configService,
|
|
927
|
+
input.capName,
|
|
928
|
+
updated?.disabledProviders ?? [],
|
|
929
|
+
)
|
|
930
|
+
return { success: true as const }
|
|
931
|
+
},
|
|
932
|
+
updateFrameworkPackage: async (input) => ps.updateFrameworkPackage({
|
|
933
|
+
packageName: input.packageName,
|
|
934
|
+
...(input.version !== undefined ? { version: input.version } : {}),
|
|
935
|
+
...(ctx.user?.username !== undefined
|
|
936
|
+
? { requestedBy: ctx.user.username }
|
|
937
|
+
: ctx.user?.id !== undefined
|
|
938
|
+
? { requestedBy: ctx.user.id }
|
|
939
|
+
: {}),
|
|
940
|
+
}),
|
|
941
|
+
getVersions: async (input) => ps.getPackageVersions(input.name),
|
|
942
|
+
restartAddon: async (input) => ar.restartAddon(input.addonId),
|
|
943
|
+
retryLoad: async (input) => {
|
|
944
|
+
await ar.retryAddonLoad(input.packageName)
|
|
945
|
+
return { success: true as const }
|
|
946
|
+
},
|
|
947
|
+
getAutoUpdateSettings: async () => ps.getAutoUpdateSettings(),
|
|
948
|
+
setAutoUpdateSettings: async (input) => ps.setAutoUpdateSettings(input.channel, input.intervalSeconds),
|
|
949
|
+
getAddonAutoUpdate: async (input) => ps.getAddonAutoUpdate(input.addonId),
|
|
950
|
+
setAddonAutoUpdate: async (input) => ps.setAddonAutoUpdate(input.addonId, input.channel),
|
|
951
|
+
applyAutoUpdateToAll: async (input) => {
|
|
952
|
+
await ps.setAutoUpdateSettings(input.channel)
|
|
953
|
+
for (const pkg of ps.listInstalled()) {
|
|
954
|
+
await ps.setAddonAutoUpdate(pkg.name, input.channel)
|
|
955
|
+
}
|
|
956
|
+
return { success: true as const }
|
|
957
|
+
},
|
|
958
|
+
custom: async (input) => {
|
|
959
|
+
const registry = ar.getCustomActionRegistry()
|
|
960
|
+
const entry = registry.resolve(input.addonId, input.action)
|
|
961
|
+
if (!entry) {
|
|
962
|
+
throw new TRPCError({
|
|
963
|
+
code: 'NOT_FOUND',
|
|
964
|
+
message: `addon '${input.addonId}' has no custom action '${input.action}'`,
|
|
965
|
+
})
|
|
966
|
+
}
|
|
967
|
+
ensureCustomActionAuth(ctx, entry.spec.auth)
|
|
968
|
+
const parsedInput = entry.spec.input.parse(input.input)
|
|
969
|
+
const result = await entry.handler(parsedInput)
|
|
970
|
+
return entry.spec.output.parse(result)
|
|
971
|
+
},
|
|
972
|
+
onAddonLogs: (input, push) => {
|
|
973
|
+
const unsubscribe = ls.subscribe(
|
|
974
|
+
{ tags: { addonId: input.addonId }, level: input.level },
|
|
975
|
+
(entry: { timestamp: Date | string | number; level: string; message: string; scope?: string }) => {
|
|
976
|
+
push({
|
|
977
|
+
timestamp: entry.timestamp instanceof Date
|
|
978
|
+
? entry.timestamp.toISOString()
|
|
979
|
+
: String(entry.timestamp),
|
|
980
|
+
level: entry.level,
|
|
981
|
+
message: entry.message,
|
|
982
|
+
scope: entry.scope,
|
|
983
|
+
})
|
|
984
|
+
},
|
|
985
|
+
)
|
|
986
|
+
return unsubscribe ?? (() => {})
|
|
987
|
+
},
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Avoid an unused-import warning in environments where NotificationService
|
|
992
|
+
// is referenced solely via type imports above.
|
|
993
|
+
export type _NotificationServiceHint = NotificationService
|