@camstack/server 1.2.477 → 1.2.478

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.
@@ -42,6 +42,7 @@ const path = __importStar(require("node:path"));
42
42
  const system_1 = require("@camstack/system");
43
43
  const moleculer_1 = require("moleculer");
44
44
  const agent_deploy_swap_js_1 = require("./agent-deploy-swap.js");
45
+ const agent_undeploy_js_1 = require("./agent-undeploy.js");
45
46
  const apply_model_distribution_js_1 = require("./apply-model-distribution.js");
46
47
  const fetch_bundle_from_hub_js_1 = require("./fetch-bundle-from-hub.js");
47
48
  /**
@@ -57,6 +58,14 @@ const deploySwapLogger = {
57
58
  child: () => deploySwapLogger,
58
59
  withTags: () => deploySwapLogger,
59
60
  };
61
+ /**
62
+ * Packages an agent bootstraps itself when the bootstrap does not say
63
+ * otherwise. Mirrors the hub reconcile's `AGENT_BOOTSTRAP_PACKAGES`.
64
+ */
65
+ const DEFAULT_AGENT_BOOTSTRAP_PACKAGES = new Set([
66
+ '@camstack/system',
67
+ '@camstack/addon-agent-ui',
68
+ ]);
60
69
  /** Timeout for the agent-local `$process.restart` delegation. */
61
70
  const AGENT_PROCESS_RESTART_TIMEOUT_MS = 30_000;
62
71
  function getLocalIps() {
@@ -455,81 +464,28 @@ function createAgentService(deps) {
455
464
  },
456
465
  },
457
466
  /**
458
- * Genuinely drop a deployed addon from this agent.
459
- *
460
- * A plain `loadedAddons.delete()` only forgets the bookkeeping entry —
461
- * the addon instance keeps running and, crucially, its Moleculer
462
- * service keeps advertising the addon's capabilities into the cluster
463
- * (the hub still sees the provider as a live `<cap>@<agent>` entry).
464
- * To truly undeploy we must, in order:
465
- * 1. `shutdown()` the running instance (releases timers, sockets,
466
- * native handles) — same hook the agent's SIGTERM path uses.
467
- * 2. `broker.destroyService(addonId)` — the deployed-addon Moleculer
468
- * service is named after the addon DECLARATION id (see
469
- * `createAddonService` → `name: declaration.id`). Destroying it
470
- * unregisters every capability action so the cluster stops
471
- * routing to / advertising this agent's provider.
472
- * 3. Drop the `loadedAddons` entry and delete the on-disk folder.
473
- *
474
- * `addonId` here is the addon DECLARATION id — the same key
475
- * `loadedAddons` uses and the same value `$agent.status` reports, so
476
- * the hub reconciler can match it directly.
467
+ * Genuinely drop a deployed addon — or a whole package — from this agent.
468
+ * `{ packageName }` is the operator's uninstall (`addons.uninstallPackage
469
+ * ({ nodeId })`); `{ addonId }` is the stale-addon reconcile. Both stop the
470
+ * runner(s) hosting the addon, which the old body never did: on an agent
471
+ * nearly every addon lives in a group runner, so deleting its folder left
472
+ * the process running and advertising its caps. See `agent-undeploy.ts`.
477
473
  */
478
474
  undeploy: {
479
475
  handler: async (ctx) => {
480
476
  const { params, broker } = ctx;
481
- const { addonId } = params;
482
- const entry = deps.loadedAddons.get(addonId);
483
- // 1. Shut the running instance down (best-effort — a throwing
484
- // disposer must not block the rest of the teardown).
485
- if (entry?.addon?.shutdown) {
486
- try {
487
- await entry.addon.shutdown();
488
- }
489
- catch (err) {
490
- broker.logger.warn(`$agent.undeploy: ${addonId} shutdown() threw`, {
491
- error: String(err),
492
- });
493
- }
494
- }
495
- // 2. Destroy the Moleculer service so the cluster stops seeing
496
- // this agent's capability providers for the addon.
497
- try {
498
- await broker.destroyService(addonId);
499
- }
500
- catch {
501
- // Service may not exist (group-spawned addons have no per-addon
502
- // service on the agent broker, or it was never created).
503
- }
504
- // 3. Forget the entry and remove the deployed folder. The deploy
505
- // handler keys the on-disk dir by the PACKAGE name, but $agent
506
- // redeploys for a single addon use addonId === packageName for
507
- // unscoped packages; remove whichever folder matches.
508
- deps.loadedAddons.delete(addonId);
509
- const addonDir = path.join(deps.addonsDir, addonId);
510
- if (fs.existsSync(addonDir)) {
511
- fs.rmSync(addonDir, { recursive: true, force: true });
512
- }
513
- // Defense-in-depth: one package dir can host MANY addons (e.g.
514
- // `@camstack/addon-pipeline` ships decoder-ffmpeg, recorder,
515
- // motion-wasm, …). Removing the whole bundle because ONE member was
516
- // undeployed is destructive — every sibling runner would crash-loop
517
- // on the missing package.json. Only remove the package dir when no
518
- // OTHER loaded addon still lives in it (this addon's own entry was
519
- // already deleted from `loadedAddons` above, so the remaining
520
- // values are exactly the siblings).
521
- const pkgName = entry?.packageName;
522
- if (pkgName && pkgName !== addonId) {
523
- const pkgShared = [...deps.loadedAddons.values()].some((e) => e.packageName === pkgName);
524
- if (!pkgShared) {
525
- const pkgDir = path.join(deps.addonsDir, pkgName);
526
- if (fs.existsSync(pkgDir)) {
527
- fs.rmSync(pkgDir, { recursive: true, force: true });
528
- }
529
- }
530
- }
531
- broker.logger.info(`$agent.undeploy: ${addonId} disposed (instance + service + folder)`);
532
- return { success: true, addonId };
477
+ return (0, agent_undeploy_js_1.undeployFromAgent)({
478
+ addonsDir: deps.addonsDir,
479
+ loadedAddons: deps.loadedAddons,
480
+ bootstrapPackages: deps.bootstrapPackages ?? DEFAULT_AGENT_BOOTSTRAP_PACKAGES,
481
+ listRunnersByAddon: (addonId) => broker.call('$process.listByAddon', { addonId }, { nodeID: broker.nodeID, timeout: AGENT_PROCESS_RESTART_TIMEOUT_MS }),
482
+ stopRunner: (name) => broker.call('$process.stop', { name }, { nodeID: broker.nodeID, timeout: AGENT_PROCESS_RESTART_TIMEOUT_MS }),
483
+ destroyService: (addonId) => broker.destroyService(addonId),
484
+ unregisterProvider: (capName, addonId) => deps.unregisterProvider?.(capName, addonId),
485
+ respawnSurvivors: deps.reloadDeployedAddons,
486
+ reconcileNodeRegistration: deps.reconcileNodeRegistration,
487
+ logger: deploySwapLogger,
488
+ }, { addonId: params.addonId, packageName: params.packageName });
533
489
  },
534
490
  },
535
491
  /**
@@ -0,0 +1,255 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.undeployFromAgent = undeployFromAgent;
37
+ const fs = __importStar(require("node:fs"));
38
+ const path = __importStar(require("node:path"));
39
+ const agent_group_runner_js_1 = require("./agent-group-runner.js");
40
+ function capName(cap) {
41
+ return typeof cap === 'string' ? cap : cap.name;
42
+ }
43
+ function packageDirFor(addonsDir, packageName) {
44
+ const resolved = path.resolve(addonsDir, packageName);
45
+ const root = path.resolve(addonsDir);
46
+ // A package name is data from the network. It must name a dir INSIDE the
47
+ // addon root and never the root itself.
48
+ if (resolved === root || !resolved.startsWith(root + path.sep))
49
+ return null;
50
+ return resolved;
51
+ }
52
+ function resolveTarget(seam, request) {
53
+ const packageName = request.packageName ??
54
+ (request.addonId !== undefined
55
+ ? seam.loadedAddons.get(request.addonId)?.packageName
56
+ : undefined);
57
+ const packageDir = packageName !== undefined ? packageDirFor(seam.addonsDir, packageName) : null;
58
+ const manifest = packageDir !== null ? (0, agent_group_runner_js_1.readPackageManifestAddons)(packageDir) : null;
59
+ const capsByAddon = new Map();
60
+ let protectedBy = null;
61
+ for (const decl of manifest?.declarations ?? []) {
62
+ capsByAddon.set(decl.id, (decl.capabilities ?? []).map(capName));
63
+ if (decl.protected === true)
64
+ protectedBy = decl.id;
65
+ }
66
+ if (request.packageName === undefined) {
67
+ return {
68
+ packageName,
69
+ addonIds: request.addonId !== undefined ? [request.addonId] : [],
70
+ capsByAddon,
71
+ packageDir,
72
+ protectedBy,
73
+ };
74
+ }
75
+ const ids = new Set(capsByAddon.keys());
76
+ for (const entry of seam.loadedAddons.values()) {
77
+ if (entry.packageName === request.packageName)
78
+ ids.add(entry.id);
79
+ }
80
+ return { packageName, addonIds: [...ids], capsByAddon, packageDir, protectedBy };
81
+ }
82
+ function refusalReason(seam, target) {
83
+ if (target.packageName !== undefined && seam.bootstrapPackages.has(target.packageName)) {
84
+ return `${target.packageName} is this node's bootstrap infrastructure and cannot be uninstalled`;
85
+ }
86
+ if (target.protectedBy !== null) {
87
+ return `${target.packageName ?? 'package'} is protected (addon "${target.protectedBy}" declares protected: true)`;
88
+ }
89
+ if (target.packageName === undefined && target.addonIds.length === 0) {
90
+ return 'neither addonId nor packageName was given';
91
+ }
92
+ return null;
93
+ }
94
+ async function shutdownInProcess(seam, addonId) {
95
+ const entry = seam.loadedAddons.get(addonId);
96
+ if (entry?.addon?.shutdown) {
97
+ try {
98
+ await entry.addon.shutdown();
99
+ }
100
+ catch (err) {
101
+ seam.logger.warn(`undeploy: ${addonId} shutdown() threw`, {
102
+ tags: { addonId },
103
+ meta: { error: err instanceof Error ? err.message : String(err) },
104
+ });
105
+ }
106
+ }
107
+ try {
108
+ await seam.destroyService(addonId);
109
+ }
110
+ catch {
111
+ // Group-runner addons have no per-addon service on the agent broker.
112
+ }
113
+ }
114
+ async function stopHostingRunners(seam, addonIds) {
115
+ const removing = new Set(addonIds);
116
+ const runners = new Map();
117
+ const warnings = [];
118
+ for (const id of addonIds) {
119
+ try {
120
+ for (const row of await seam.listRunnersByAddon(id))
121
+ runners.set(row.name, row);
122
+ }
123
+ catch (err) {
124
+ warnings.push(`could not list runners for ${id}: ${err instanceof Error ? err.message : String(err)}`);
125
+ }
126
+ }
127
+ const stopped = [];
128
+ const survivors = [];
129
+ for (const runner of runners.values()) {
130
+ const result = await seam.stopRunner(runner.name).catch((err) => ({
131
+ success: false,
132
+ reason: err instanceof Error ? err.message : String(err),
133
+ }));
134
+ if (!result.success) {
135
+ warnings.push(`runner "${runner.name}" did not stop: ${result.reason ?? 'unknown'}`);
136
+ continue;
137
+ }
138
+ stopped.push(runner.name);
139
+ for (const id of runner.addonIds) {
140
+ if (!removing.has(id))
141
+ survivors.push(id);
142
+ }
143
+ }
144
+ return { stopped, survivors, warnings };
145
+ }
146
+ function removeDirs(seam, request, target) {
147
+ const removed = [];
148
+ const rm = (dir) => {
149
+ if (dir === null || !fs.existsSync(dir))
150
+ return;
151
+ fs.rmSync(dir, { recursive: true, force: true });
152
+ removed.push(dir);
153
+ };
154
+ if (request.packageName !== undefined) {
155
+ rm(target.packageDir);
156
+ return removed;
157
+ }
158
+ // Addon mode — the reconcile's per-id removal. One package dir can host MANY
159
+ // addons; only remove it when no OTHER loaded addon still lives in it (this
160
+ // addon's own row is already gone from `loadedAddons`).
161
+ const addonId = request.addonId;
162
+ if (addonId !== undefined)
163
+ rm(packageDirFor(seam.addonsDir, addonId));
164
+ const pkg = target.packageName;
165
+ if (pkg !== undefined && pkg !== addonId) {
166
+ const shared = [...seam.loadedAddons.values()].some((e) => e.packageName === pkg);
167
+ if (!shared)
168
+ rm(target.packageDir);
169
+ }
170
+ return removed;
171
+ }
172
+ /** Run one `$agent.undeploy`. Never throws for a refusal — it answers `refused`. */
173
+ async function undeployFromAgent(seam, request) {
174
+ const target = resolveTarget(seam, request);
175
+ const scope = request.packageName !== undefined ? 'package' : 'addon';
176
+ const base = {
177
+ scope,
178
+ addonId: request.addonId,
179
+ packageName: target.packageName,
180
+ addonIds: target.addonIds,
181
+ };
182
+ const refused = refusalReason(seam, target);
183
+ if (refused !== null) {
184
+ seam.logger.warn(`undeploy refused: ${refused}`, { meta: { request } });
185
+ return {
186
+ ...base,
187
+ success: false,
188
+ outcome: 'refused',
189
+ reason: refused,
190
+ runnersStopped: [],
191
+ runnersRespawned: [],
192
+ removedDirs: [],
193
+ warnings: [],
194
+ };
195
+ }
196
+ const present = target.addonIds.some((id) => seam.loadedAddons.has(id)) ||
197
+ (target.packageDir !== null && fs.existsSync(target.packageDir));
198
+ if (scope === 'package' && !present) {
199
+ return {
200
+ ...base,
201
+ success: true,
202
+ outcome: 'not-installed',
203
+ runnersStopped: [],
204
+ runnersRespawned: [],
205
+ removedDirs: [],
206
+ warnings: [],
207
+ };
208
+ }
209
+ for (const id of target.addonIds)
210
+ await shutdownInProcess(seam, id);
211
+ const runners = await stopHostingRunners(seam, target.addonIds);
212
+ for (const id of target.addonIds) {
213
+ for (const cap of target.capsByAddon.get(id) ?? [])
214
+ seam.unregisterProvider(cap, id);
215
+ seam.loadedAddons.delete(id);
216
+ }
217
+ // A survivor's row is dropped too, so the discovery pass below sees it as
218
+ // not-loaded and respawns its runner — without the removed addons, whose
219
+ // code is gone by then.
220
+ for (const id of runners.survivors) {
221
+ for (const cap of target.capsByAddon.get(id) ?? [])
222
+ seam.unregisterProvider(cap, id);
223
+ seam.loadedAddons.delete(id);
224
+ }
225
+ const removedDirs = removeDirs(seam, request, target);
226
+ const warnings = [...runners.warnings];
227
+ let runnersRespawned = [];
228
+ if (runners.survivors.length > 0) {
229
+ if (seam.respawnSurvivors) {
230
+ try {
231
+ await seam.respawnSurvivors();
232
+ runnersRespawned = runners.stopped;
233
+ }
234
+ catch (err) {
235
+ warnings.push(`survivors ${runners.survivors.join(', ')} were not respawned: ${err instanceof Error ? err.message : String(err)}`);
236
+ }
237
+ }
238
+ else {
239
+ warnings.push(`survivors ${runners.survivors.join(', ')} were stopped and not respawned`);
240
+ }
241
+ }
242
+ seam.reconcileNodeRegistration?.();
243
+ seam.logger.info(`undeploy: ${target.packageName ?? request.addonId ?? '?'} removed ` +
244
+ `(addons=${target.addonIds.join(',') || '-'} runnersStopped=${runners.stopped.join(',') || '-'} ` +
245
+ `dirs=${removedDirs.length})`, { meta: { warnings } });
246
+ return {
247
+ ...base,
248
+ success: true,
249
+ outcome: 'uninstalled',
250
+ runnersStopped: runners.stopped,
251
+ runnersRespawned,
252
+ removedDirs,
253
+ warnings,
254
+ };
255
+ }
@@ -470,6 +470,19 @@ async function startAgent(configPath) {
470
470
  // (manifest strip + runtime deps + native deps + atomic swap) instead of
471
471
  // the bare tar swap that shipped addons without their node_modules.
472
472
  installBundleTgz: (tgzPath) => installer.installFromTgz(tgzPath),
473
+ // `$agent.undeploy` refuses what this node bootstraps itself, and drops the
474
+ // group-runner cap proxies it removes (registered by
475
+ // `registerGroupRunnerProviders`, ownerless — so a plain unregister owns them).
476
+ bootstrapPackages: new Set([
477
+ '@camstack/system',
478
+ '@camstack/addon-agent-ui',
479
+ ...system_1.AddonInstaller.AGENT_PACKAGES,
480
+ ]),
481
+ unregisterProvider: (capName, addonId) => {
482
+ if (capabilityRegistry.hasProvider(capName, addonId)) {
483
+ capabilityRegistry.unregisterProvider(capName, addonId);
484
+ }
485
+ },
473
486
  });
474
487
  broker.createService(agentServiceSchema);
475
488
  // $process service — manages forked child processes (same as hub).
@@ -77,6 +77,7 @@ const types_1 = require("@camstack/types");
77
77
  const server_1 = require("@trpc/server");
78
78
  const integration_id_backfill_1 = require("../../boot/integration-id-backfill");
79
79
  const addon_package_service_js_1 = require("../../core/addon/addon-package.service.js");
80
+ const addon_uninstall_js_1 = require("../../core/addon/addon-uninstall.js");
80
81
  const lifecycle_runner_singleton_js_1 = require("../../core/lifecycle/lifecycle-runner.singleton.js");
81
82
  const agent_installed_packages_js_1 = require("../../core/updates/agent-installed-packages.js");
82
83
  const http_request_census_singleton_js_1 = require("../trpc/http-request-census.singleton.js");
@@ -1318,8 +1319,82 @@ function isHubNode(nodeId) {
1318
1319
  * filter it out to make the list look clean — the number reaching zero is the
1319
1320
  * point of the number.
1320
1321
  */
1321
- function buildAddonsProvider(ar, ps, ls, moleculer, configService, ctx) {
1322
+ /**
1323
+ * How long the hub waits for an agent's `$agent.undeploy`. It stops runners
1324
+ * (each up to a 5 s SIGTERM grace plus the group reap) and may respawn a shared
1325
+ * runner's survivors, so it is far longer than a status read.
1326
+ */
1327
+ const AGENT_UNDEPLOY_TIMEOUT_MS = 120_000;
1328
+ function parseAgentStatusAddons(reply) {
1329
+ if (typeof reply !== 'object' || reply === null || !('addons' in reply))
1330
+ return [];
1331
+ const addons = reply.addons;
1332
+ if (!Array.isArray(addons))
1333
+ return [];
1334
+ const rows = [];
1335
+ for (const raw of addons) {
1336
+ if (typeof raw !== 'object' || raw === null || !('id' in raw) || typeof raw.id !== 'string') {
1337
+ continue;
1338
+ }
1339
+ const packageName = 'packageName' in raw && typeof raw.packageName === 'string' ? raw.packageName : undefined;
1340
+ const version = 'version' in raw && typeof raw.version === 'string' ? raw.version : undefined;
1341
+ rows.push({ id: raw.id, packageName, version });
1342
+ }
1343
+ return rows;
1344
+ }
1345
+ /**
1346
+ * The real seam behind `addons.uninstallPackage` / `listInstallations`. The
1347
+ * agent leg is the same hub-side `broker.call({ nodeID })` to the agent's
1348
+ * `$agent` service that `updatePackage({ nodeId })` uses.
1349
+ */
1350
+ function buildUninstallSeam(ar, ps, broker, agentRegistry) {
1351
+ return {
1352
+ isProtected: (name) => ps.isProtected(name),
1353
+ listHubPackages: () => {
1354
+ let installed;
1355
+ try {
1356
+ installed = ps.listInstalled();
1357
+ }
1358
+ catch {
1359
+ return [];
1360
+ }
1361
+ const idsByPackage = new Map();
1362
+ for (const row of ar.listAddons()) {
1363
+ const ids = idsByPackage.get(row.manifest.packageName) ?? [];
1364
+ ids.push(row.manifest.id);
1365
+ idsByPackage.set(row.manifest.packageName, ids);
1366
+ }
1367
+ return installed.map((p) => ({
1368
+ name: p.name,
1369
+ version: p.version,
1370
+ addonIds: idsByPackage.get(p.name) ?? [],
1371
+ }));
1372
+ },
1373
+ uninstallOnHub: async (name) => {
1374
+ await ps.uninstallAndReload(name);
1375
+ },
1376
+ listAgents: async () => {
1377
+ if (!agentRegistry)
1378
+ return [];
1379
+ const directory = await agentRegistry.listNodeDirectory();
1380
+ return directory.nodes
1381
+ .filter((n) => !n.isHub && !n.id.includes('/'))
1382
+ .map((n) => ({ id: n.id, name: n.name, isOnline: n.isOnline }));
1383
+ },
1384
+ agentAddons: async (nodeId) => parseAgentStatusAddons(await broker.call('$agent.status', {}, { nodeID: nodeId, timeout: agent_installed_packages_js_1.AGENT_STATUS_TIMEOUT_MS })),
1385
+ agentUndeploy: (nodeId, request) => broker.call('$agent.undeploy', request, {
1386
+ nodeID: nodeId,
1387
+ timeout: AGENT_UNDEPLOY_TIMEOUT_MS,
1388
+ }),
1389
+ recordedPackages: async (nodeId) => agentRegistry ? agentRegistry.getRecordedPackages(nodeId) : [],
1390
+ notePackageUndeployed: async (nodeId, packageName, addonIds) => {
1391
+ await agentRegistry?.notePackageUndeployed(nodeId, packageName, addonIds);
1392
+ },
1393
+ };
1394
+ }
1395
+ function buildAddonsProvider(ar, ps, ls, moleculer, configService, ctx, agentRegistry) {
1322
1396
  const broker = moleculer.broker;
1397
+ const uninstallSeam = buildUninstallSeam(ar, ps, broker, agentRegistry);
1323
1398
  const frameworkAllowSet = new Set([addon_package_service_js_1.SYSTEM_PACKAGE]);
1324
1399
  // Process-wide runner built once at boot (manual-boot wires its deps from the
1325
1400
  // addon-package service + event bus). `createdBy` travels per `startJob` call.
@@ -1349,7 +1424,18 @@ function buildAddonsProvider(ar, ps, ls, moleculer, configService, ctx) {
1349
1424
  installFromWorkspace: async (input) => ps.installFromWorkspaceAndLoad(input.packageName),
1350
1425
  isWorkspaceAvailable: async () => ps.isWorkspaceAvailable(),
1351
1426
  listWorkspacePackages: async () => ps.listWorkspacePackages(),
1352
- uninstallPackage: async (input) => ps.uninstallAndReload(input.packageName),
1427
+ uninstallPackage: async (input) => {
1428
+ try {
1429
+ return await (0, addon_uninstall_js_1.uninstallPackage)(uninstallSeam, input);
1430
+ }
1431
+ catch (err) {
1432
+ if (err instanceof addon_uninstall_js_1.PackageProtectedError) {
1433
+ throw new server_1.TRPCError({ code: 'FORBIDDEN', message: err.message });
1434
+ }
1435
+ throw err;
1436
+ }
1437
+ },
1438
+ listInstallations: async (input) => (0, addon_uninstall_js_1.listInstallations)(uninstallSeam, input.packageName),
1353
1439
  reloadPackages: async () => ps.reloadPackages(),
1354
1440
  searchAvailable: async (input) => {
1355
1441
  const results = await ps.searchNpm(input?.query);
@@ -1486,7 +1572,9 @@ function buildAddonsProvider(ar, ps, ls, moleculer, configService, ctx) {
1486
1572
  if (offNode.length > 0) {
1487
1573
  throw new server_1.TRPCError({
1488
1574
  code: 'BAD_REQUEST',
1489
- message: `The lifecycle job engine is hub-local; it cannot target ${offNode.join(', ')}. Update an agent's packages via updatePackage({ nodeId }).`,
1575
+ message: input.kind === 'uninstall'
1576
+ ? `The lifecycle job engine is hub-local; it cannot target ${offNode.join(', ')}. Uninstall from an agent via uninstallPackage({ packageName, nodeId }), or from every node via uninstallPackage({ packageName, everywhere: true }).`
1577
+ : `The lifecycle job engine is hub-local; it cannot target ${offNode.join(', ')}. Update an agent's packages via updatePackage({ nodeId }).`,
1490
1578
  });
1491
1579
  }
1492
1580
  return lifecycleRunner.startJob({
@@ -569,6 +569,14 @@ function createCapRouter_addons(getProvider, _createRemoteProxy) {
569
569
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
570
570
  return p.uninstallPackage(input);
571
571
  }),
572
+ listInstallations: trpc_middleware_js_1.adminProcedure
573
+ .input(types_10.addonsCapability.methods.listInstallations.input.loose())
574
+ .output(types_10.addonsCapability.methods.listInstallations.output)
575
+ .query(async ({ input, ctx }) => {
576
+ const p = requireCapProvider('addons', () => getProvider(ctx));
577
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
578
+ return p.listInstallations(input);
579
+ }),
572
580
  reloadPackages: trpc_middleware_js_1.adminProcedure
573
581
  .input(zod_1.z.object({ nodeId: zod_1.z.string().optional() }).optional())
574
582
  .output(types_10.addonsCapability.methods.reloadPackages.output)
@@ -2434,15 +2442,6 @@ function createCapRouter_dayNight(getProvider, createRemoteProxy) {
2434
2442
  }
2435
2443
  function createCapRouter_decoder(getProvider, createRemoteProxy) {
2436
2444
  return (0, trpc_middleware_js_1.trpcRouter)({
2437
- supportsCodec: trpc_middleware_js_1.protectedProcedure
2438
- .input(types_42.decoderCapability.methods.supportsCodec.input.loose())
2439
- .output(types_42.decoderCapability.methods.supportsCodec.output)
2440
- .query(async ({ input, ctx }) => {
2441
- const { nodeId, ...methodInput } = input;
2442
- const p = resolveProvider('decoder', nodeId, () => getProvider(ctx), createRemoteProxy);
2443
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2444
- return p.supportsCodec(methodInput);
2445
- }),
2446
2445
  getInfo: trpc_middleware_js_1.protectedProcedure
2447
2446
  .input(zod_1.z.object({ nodeId: zod_1.z.string().optional() }).optional())
2448
2447
  .output(types_42.decoderCapability.methods.getInfo.output)
@@ -2450,103 +2449,6 @@ function createCapRouter_decoder(getProvider, createRemoteProxy) {
2450
2449
  const p = resolveProvider('decoder', input?.nodeId, () => getProvider(ctx), createRemoteProxy);
2451
2450
  return p.getInfo();
2452
2451
  }),
2453
- createSession: trpc_middleware_js_1.protectedProcedure
2454
- .input(types_42.decoderCapability.methods.createSession.input.loose())
2455
- .output(types_42.decoderCapability.methods.createSession.output)
2456
- .query(async ({ input, ctx }) => {
2457
- const { nodeId, ...methodInput } = input;
2458
- const p = resolveProvider('decoder', nodeId, () => getProvider(ctx), createRemoteProxy);
2459
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2460
- return p.createSession(methodInput);
2461
- }),
2462
- destroySession: trpc_middleware_js_1.protectedProcedure
2463
- .input(types_42.decoderCapability.methods.destroySession.input.loose())
2464
- .output(types_42.decoderCapability.methods.destroySession.output)
2465
- .query(async ({ input, ctx }) => {
2466
- const { nodeId, ...methodInput } = input;
2467
- const p = resolveProvider('decoder', nodeId, () => getProvider(ctx), createRemoteProxy);
2468
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2469
- return p.destroySession(methodInput);
2470
- }),
2471
- pushPacket: trpc_middleware_js_1.protectedProcedure
2472
- .input(types_42.decoderCapability.methods.pushPacket.input.loose())
2473
- .output(types_42.decoderCapability.methods.pushPacket.output)
2474
- .query(async ({ input, ctx }) => {
2475
- const { nodeId, ...methodInput } = input;
2476
- const p = resolveProvider('decoder', nodeId, () => getProvider(ctx), createRemoteProxy);
2477
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2478
- return p.pushPacket(methodInput);
2479
- }),
2480
- openStream: trpc_middleware_js_1.protectedProcedure
2481
- .input(types_42.decoderCapability.methods.openStream.input.loose())
2482
- .output(types_42.decoderCapability.methods.openStream.output)
2483
- .query(async ({ input, ctx }) => {
2484
- const { nodeId, ...methodInput } = input;
2485
- const p = resolveProvider('decoder', nodeId, () => getProvider(ctx), createRemoteProxy);
2486
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2487
- return p.openStream(methodInput);
2488
- }),
2489
- pullFrames: trpc_middleware_js_1.protectedProcedure
2490
- .input(types_42.decoderCapability.methods.pullFrames.input.loose())
2491
- .output(types_42.decoderCapability.methods.pullFrames.output)
2492
- .query(async ({ input, ctx }) => {
2493
- const { nodeId, ...methodInput } = input;
2494
- const p = resolveProvider('decoder', nodeId, () => getProvider(ctx), createRemoteProxy);
2495
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2496
- return p.pullFrames(methodInput);
2497
- }),
2498
- pullHandles: trpc_middleware_js_1.protectedProcedure
2499
- .input(types_42.decoderCapability.methods.pullHandles.input.loose())
2500
- .output(types_42.decoderCapability.methods.pullHandles.output)
2501
- .query(async ({ input, ctx }) => {
2502
- const { nodeId, ...methodInput } = input;
2503
- const p = resolveProvider('decoder', nodeId, () => getProvider(ctx), createRemoteProxy);
2504
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2505
- return p.pullHandles(methodInput);
2506
- }),
2507
- getFrame: trpc_middleware_js_1.protectedProcedure
2508
- .input(types_42.decoderCapability.methods.getFrame.input.loose())
2509
- .output(types_42.decoderCapability.methods.getFrame.output)
2510
- .query(async ({ input, ctx }) => {
2511
- const { nodeId, ...methodInput } = input;
2512
- const p = resolveProvider('decoder', nodeId, () => getProvider(ctx), createRemoteProxy);
2513
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2514
- return p.getFrame(methodInput);
2515
- }),
2516
- getShmStats: trpc_middleware_js_1.protectedProcedure
2517
- .input(types_42.decoderCapability.methods.getShmStats.input.loose())
2518
- .output(types_42.decoderCapability.methods.getShmStats.output)
2519
- .query(async ({ input, ctx }) => {
2520
- const { nodeId, ...methodInput } = input;
2521
- const p = resolveProvider('decoder', nodeId, () => getProvider(ctx), createRemoteProxy);
2522
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2523
- return p.getShmStats(methodInput);
2524
- }),
2525
- updateConfig: trpc_middleware_js_1.protectedProcedure
2526
- .input(types_42.decoderCapability.methods.updateConfig.input.loose())
2527
- .output(types_42.decoderCapability.methods.updateConfig.output)
2528
- .query(async ({ input, ctx }) => {
2529
- const { nodeId, ...methodInput } = input;
2530
- const p = resolveProvider('decoder', nodeId, () => getProvider(ctx), createRemoteProxy);
2531
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2532
- return p.updateConfig(methodInput);
2533
- }),
2534
- getStats: trpc_middleware_js_1.protectedProcedure
2535
- .input(types_42.decoderCapability.methods.getStats.input.loose())
2536
- .output(types_42.decoderCapability.methods.getStats.output)
2537
- .query(async ({ input, ctx }) => {
2538
- const { nodeId, ...methodInput } = input;
2539
- const p = resolveProvider('decoder', nodeId, () => getProvider(ctx), createRemoteProxy);
2540
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2541
- return p.getStats(methodInput);
2542
- }),
2543
- listActiveSessions: trpc_middleware_js_1.protectedProcedure
2544
- .input(zod_1.z.object({ nodeId: zod_1.z.string().optional() }).optional())
2545
- .output(types_42.decoderCapability.methods.listActiveSessions.output)
2546
- .query(async ({ input, ctx }) => {
2547
- const p = resolveProvider('decoder', input?.nodeId, () => getProvider(ctx), createRemoteProxy);
2548
- return p.listActiveSessions();
2549
- }),
2550
2452
  reprobeHwaccel: trpc_middleware_js_1.adminProcedure
2551
2453
  .input(zod_1.z.object({ nodeId: zod_1.z.string().optional() }).optional())
2552
2454
  .output(types_42.decoderCapability.methods.reprobeHwaccel.output)
@@ -10255,33 +10157,6 @@ function createCapRouter_streamBroker(getProvider, createRemoteProxy) {
10255
10157
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
10256
10158
  return p.unsubscribeAudioChunks(methodInput);
10257
10159
  }),
10258
- subscribeFrames: trpc_middleware_js_1.protectedProcedure
10259
- .input(types_118.streamBrokerCapability.methods.subscribeFrames.input.loose())
10260
- .output(types_118.streamBrokerCapability.methods.subscribeFrames.output)
10261
- .mutation(async ({ input, ctx }) => {
10262
- const { nodeId, ...methodInput } = input;
10263
- const p = resolveProvider('stream-broker', nodeId, () => getProvider(ctx), createRemoteProxy);
10264
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
10265
- return p.subscribeFrames(methodInput);
10266
- }),
10267
- pullFrameHandles: trpc_middleware_js_1.protectedProcedure
10268
- .input(types_118.streamBrokerCapability.methods.pullFrameHandles.input.loose())
10269
- .output(types_118.streamBrokerCapability.methods.pullFrameHandles.output)
10270
- .query(async ({ input, ctx }) => {
10271
- const { nodeId, ...methodInput } = input;
10272
- const p = resolveProvider('stream-broker', nodeId, () => getProvider(ctx), createRemoteProxy);
10273
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
10274
- return p.pullFrameHandles(methodInput);
10275
- }),
10276
- unsubscribeFrames: trpc_middleware_js_1.protectedProcedure
10277
- .input(types_118.streamBrokerCapability.methods.unsubscribeFrames.input.loose())
10278
- .output(types_118.streamBrokerCapability.methods.unsubscribeFrames.output)
10279
- .mutation(async ({ input, ctx }) => {
10280
- const { nodeId, ...methodInput } = input;
10281
- const p = resolveProvider('stream-broker', nodeId, () => getProvider(ctx), createRemoteProxy);
10282
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
10283
- return p.unsubscribeFrames(methodInput);
10284
- }),
10285
10160
  setPreBufferDuration: trpc_middleware_js_1.adminProcedure
10286
10161
  .input(types_118.streamBrokerCapability.methods.setPreBufferDuration.input.loose())
10287
10162
  .output(types_118.streamBrokerCapability.methods.setPreBufferDuration.output)
@@ -170,7 +170,7 @@ function buildServerProviders(services) {
170
170
  nodes: wrap(() => (0, cap_providers_js_1.buildNodesProvider)(services.agentRegistry, services.moleculer, services.addonRegistry, (0, cap_providers_js_1.createNodeRootPackageLookup)(services.moleculer, services.serverUpdateService), services.loggingService.createLogger('nodes'))),
171
171
  // server-management — runtime-updatable root package (phase 1: hub only).
172
172
  'server-management': wrap(() => (0, server_management_provider_js_1.buildServerManagementProvider)(services.serverUpdateService)),
173
- addons: wrap((ctx) => (0, cap_providers_js_1.buildAddonsProvider)(services.addonRegistry, services.addonPackageService, services.loggingService, services.moleculer, services.configService, ctx)),
173
+ addons: wrap((ctx) => (0, cap_providers_js_1.buildAddonsProvider)(services.addonRegistry, services.addonPackageService, services.loggingService, services.moleculer, services.configService, ctx, services.agentRegistry)),
174
174
  // webrtc-session — the resolved broker singleton, wrapped per request to
175
175
  // inject the server-read User-Agent into signaling attribution (the broker
176
176
  // is a forked addon that can't see the HTTP request). Cross-node (agent-
@@ -0,0 +1,259 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PackageProtectedError = void 0;
4
+ exports.isHubNodeId = isHubNodeId;
5
+ exports.uninstallPackage = uninstallPackage;
6
+ exports.listInstallations = listInstallations;
7
+ /** The hub refuses before touching any node. Mapped to FORBIDDEN by the router. */
8
+ class PackageProtectedError extends Error {
9
+ constructor(packageName) {
10
+ super(`Package ${packageName} is required and cannot be uninstalled`);
11
+ this.name = 'PackageProtectedError';
12
+ }
13
+ }
14
+ exports.PackageProtectedError = PackageProtectedError;
15
+ /** A node id that refers to the hub itself (top-level or a hub group runner). */
16
+ function isHubNodeId(nodeId) {
17
+ return nodeId === 'hub' || nodeId.startsWith('hub/');
18
+ }
19
+ const OK_OUTCOMES = new Set([
20
+ 'uninstalled',
21
+ 'not-installed',
22
+ 'pending-reconnect',
23
+ ]);
24
+ function errMessage(err) {
25
+ return err instanceof Error ? err.message : String(err);
26
+ }
27
+ function nodeResult(nodeId, outcome, extra = {}) {
28
+ return {
29
+ nodeId,
30
+ outcome,
31
+ addonIds: extra.addonIds ?? [],
32
+ runnersStopped: extra.runnersStopped ?? [],
33
+ warnings: extra.warnings ?? [],
34
+ ...(extra.error !== undefined ? { error: extra.error } : {}),
35
+ };
36
+ }
37
+ function stringArray(value) {
38
+ return Array.isArray(value) ? value.filter((v) => typeof v === 'string') : [];
39
+ }
40
+ function isRecord(value) {
41
+ return typeof value === 'object' && value !== null;
42
+ }
43
+ function parseUndeployReply(reply) {
44
+ if (!isRecord(reply)) {
45
+ return {
46
+ packageScoped: false,
47
+ outcome: null,
48
+ reason: undefined,
49
+ addonIds: [],
50
+ runnersStopped: null,
51
+ warnings: [],
52
+ };
53
+ }
54
+ const outcome = reply['outcome'];
55
+ return {
56
+ packageScoped: reply['scope'] === 'package',
57
+ outcome: outcome === 'uninstalled' || outcome === 'not-installed' || outcome === 'refused'
58
+ ? outcome
59
+ : null,
60
+ reason: typeof reply['reason'] === 'string' ? reply['reason'] : undefined,
61
+ addonIds: stringArray(reply['addonIds']),
62
+ runnersStopped: Array.isArray(reply['runnersStopped'])
63
+ ? stringArray(reply['runnersStopped'])
64
+ : null,
65
+ warnings: stringArray(reply['warnings']),
66
+ };
67
+ }
68
+ /**
69
+ * An agent built before package mode answers `{ success, addonId }` and removes
70
+ * ONE addon — its folder, not its runner. Its packages are still removed (one
71
+ * call per addon id), and the result says the runner outlives it.
72
+ */
73
+ const LEGACY_AGENT_WARNING = 'this agent predates runner teardown: the addon is removed from disk but its runner keeps running until the agent restarts — update the agent';
74
+ async function uninstallFromOnlineAgent(seam, nodeId, packageName) {
75
+ let listed;
76
+ try {
77
+ listed = (await seam.agentAddons(nodeId))
78
+ .filter((a) => a.packageName === packageName)
79
+ .map((a) => a.id);
80
+ }
81
+ catch (err) {
82
+ return nodeResult(nodeId, 'failed', { error: `status read failed: ${errMessage(err)}` });
83
+ }
84
+ try {
85
+ // `addonId` is for an agent that predates package mode: it reads only that
86
+ // field. With no listed addon, the package name doubles as the dir to drop.
87
+ const first = parseUndeployReply(await seam.agentUndeploy(nodeId, { packageName, addonId: listed[0] ?? packageName }));
88
+ if (first.packageScoped && first.outcome !== null) {
89
+ if (first.outcome === 'refused') {
90
+ return nodeResult(nodeId, 'refused', { error: first.reason, addonIds: first.addonIds });
91
+ }
92
+ await seam.notePackageUndeployed(nodeId, packageName, first.addonIds);
93
+ return nodeResult(nodeId, first.outcome, {
94
+ addonIds: first.addonIds,
95
+ runnersStopped: first.runnersStopped ?? [],
96
+ warnings: first.warnings,
97
+ });
98
+ }
99
+ for (const addonId of listed.slice(1)) {
100
+ await seam.agentUndeploy(nodeId, { packageName, addonId });
101
+ }
102
+ await seam.notePackageUndeployed(nodeId, packageName, listed);
103
+ return nodeResult(nodeId, listed.length > 0 ? 'uninstalled' : 'not-installed', {
104
+ addonIds: listed,
105
+ warnings: listed.length > 0 ? [LEGACY_AGENT_WARNING] : [],
106
+ });
107
+ }
108
+ catch (err) {
109
+ return nodeResult(nodeId, 'failed', { addonIds: listed, error: errMessage(err) });
110
+ }
111
+ }
112
+ async function uninstallFromAgent(seam, node, packageName) {
113
+ if (node.isOnline)
114
+ return uninstallFromOnlineAgent(seam, node.id, packageName);
115
+ // Offline: nothing can be called. Pruning the record is what keeps the
116
+ // back-fill from returning it; the copy itself is the stale sweep's on rejoin.
117
+ try {
118
+ await seam.notePackageUndeployed(node.id, packageName, []);
119
+ return nodeResult(node.id, 'pending-reconnect', {
120
+ warnings: [
121
+ 'node is offline — its copy is removed when it reconnects, provided the hub no longer installs the package',
122
+ ],
123
+ });
124
+ }
125
+ catch (err) {
126
+ return nodeResult(node.id, 'failed', { error: errMessage(err) });
127
+ }
128
+ }
129
+ async function uninstallFromHub(seam, packageName) {
130
+ const row = seam.listHubPackages().find((p) => p.name === packageName);
131
+ if (!row)
132
+ return nodeResult('hub', 'not-installed');
133
+ try {
134
+ await seam.uninstallOnHub(packageName);
135
+ return nodeResult('hub', 'uninstalled', { addonIds: row.addonIds });
136
+ }
137
+ catch (err) {
138
+ return nodeResult('hub', 'failed', { addonIds: row.addonIds, error: errMessage(err) });
139
+ }
140
+ }
141
+ function summarize(packageName, results) {
142
+ return { success: results.every((r) => OK_OUTCOMES.has(r.outcome)), packageName, results };
143
+ }
144
+ /**
145
+ * Agents that have (or are recorded to have) the package. An online agent
146
+ * whose status read fails is still targeted — the undeploy leg reports the
147
+ * failure for that node rather than silently skipping it.
148
+ */
149
+ async function agentsHolding(seam, packageName) {
150
+ const agents = await seam.listAgents();
151
+ const holding = await Promise.all(agents.map(async (node) => {
152
+ if (node.isOnline) {
153
+ try {
154
+ const addons = await seam.agentAddons(node.id);
155
+ if (addons.some((a) => a.packageName === packageName))
156
+ return node;
157
+ }
158
+ catch {
159
+ return node;
160
+ }
161
+ }
162
+ const recorded = await seam.recordedPackages(node.id).catch(() => []);
163
+ return recorded.some((p) => p.name === packageName) ? node : null;
164
+ }));
165
+ return holding.filter((n) => n !== null);
166
+ }
167
+ async function uninstallPackage(seam, request) {
168
+ const { packageName } = request;
169
+ if (seam.isProtected(packageName))
170
+ throw new PackageProtectedError(packageName);
171
+ if (request.everywhere === true) {
172
+ const agents = await agentsHolding(seam, packageName);
173
+ // Agents first, hub last — the hub routes every call to the agents, and a
174
+ // hub uninstall that fails must not leave the agents' records already
175
+ // pruned for nothing: each leg records only its own success.
176
+ const results = [];
177
+ for (const node of agents)
178
+ results.push(await uninstallFromAgent(seam, node, packageName));
179
+ results.push(await uninstallFromHub(seam, packageName));
180
+ return summarize(packageName, results);
181
+ }
182
+ const nodeId = request.nodeId;
183
+ if (nodeId === undefined || isHubNodeId(nodeId)) {
184
+ // The original contract: hub-only, and a failure THROWS.
185
+ await seam.uninstallOnHub(packageName);
186
+ const row = seam.listHubPackages().find((p) => p.name === packageName);
187
+ return summarize(packageName, [
188
+ nodeResult('hub', 'uninstalled', { addonIds: row?.addonIds ?? [] }),
189
+ ]);
190
+ }
191
+ const node = (await seam.listAgents()).find((n) => n.id === nodeId);
192
+ if (!node) {
193
+ return summarize(packageName, [
194
+ nodeResult(nodeId, 'failed', { error: `unknown node ${nodeId}` }),
195
+ ]);
196
+ }
197
+ return summarize(packageName, [await uninstallFromAgent(seam, node, packageName)]);
198
+ }
199
+ async function listInstallations(seam, packageName) {
200
+ const wanted = (name) => packageName === undefined || name === packageName;
201
+ const hubPackages = seam.listHubPackages();
202
+ const onHub = new Set(hubPackages.map((p) => p.name));
203
+ const installations = hubPackages
204
+ .filter((p) => wanted(p.name))
205
+ .map((p) => ({
206
+ packageName: p.name,
207
+ nodeId: 'hub',
208
+ nodeName: 'Hub',
209
+ isHub: true,
210
+ isOnline: true,
211
+ version: p.version,
212
+ addonIds: p.addonIds,
213
+ source: 'live',
214
+ onHub: true,
215
+ }));
216
+ const unreachable = [];
217
+ const perAgent = await Promise.all((await seam.listAgents()).map(async (node) => {
218
+ const base = { nodeId: node.id, nodeName: node.name, isHub: false, isOnline: node.isOnline };
219
+ if (node.isOnline) {
220
+ try {
221
+ const byPackage = new Map();
222
+ for (const addon of await seam.agentAddons(node.id)) {
223
+ if (addon.packageName === undefined || !wanted(addon.packageName))
224
+ continue;
225
+ const row = byPackage.get(addon.packageName) ?? {
226
+ version: addon.version ?? null,
227
+ addonIds: [],
228
+ };
229
+ row.addonIds.push(addon.id);
230
+ byPackage.set(addon.packageName, row);
231
+ }
232
+ return [...byPackage].map(([name, row]) => ({
233
+ ...base,
234
+ packageName: name,
235
+ version: row.version,
236
+ addonIds: row.addonIds,
237
+ source: 'live',
238
+ onHub: onHub.has(name),
239
+ }));
240
+ }
241
+ catch (err) {
242
+ unreachable.push({ nodeId: node.id, error: errMessage(err) });
243
+ return [];
244
+ }
245
+ }
246
+ const recorded = await seam.recordedPackages(node.id).catch(() => []);
247
+ return recorded
248
+ .filter((p) => wanted(p.name))
249
+ .map((p) => ({
250
+ ...base,
251
+ packageName: p.name,
252
+ version: p.version,
253
+ addonIds: [],
254
+ source: 'recorded',
255
+ onHub: onHub.has(p.name),
256
+ }));
257
+ }));
258
+ return { installations: [...installations, ...perAgent.flat()], unreachable };
259
+ }
@@ -50,11 +50,11 @@ const types_1 = require("@camstack/types");
50
50
  *
51
51
  * What's left is ONLY the addon package management surface: the installer
52
52
  * + the loader + `reloadPackages` + `listAvailableAddons`. These are still
53
- * used by:
54
- * - `server/backend/src/api/addon-upload.ts` (multipart upload → install
55
- * → loader reload)
56
- * - `server/backend/src/api/bridge-addons.router.ts` (install/uninstall
57
- * package lifecycle, addon list via the loader)
53
+ * used by `server/backend/src/api/addon-upload.ts` (multipart upload →
54
+ * install → loader reload). The `bridge-addons.router.ts` that also used it
55
+ * was never mounted and was deleted on 2026-10-08 — its hard-coded REQUIRED
56
+ * set read like the uninstall guard and was not; uninstall is the `addons`
57
+ * cap's (`core/addon/addon-uninstall.ts`).
58
58
  */
59
59
  class AddonBridgeService {
60
60
  loggingService;
@@ -24,13 +24,20 @@
24
24
  * both mutations are attributable actions rather than heuristics.
25
25
  *
26
26
  * THE INTENDED VERSION
27
- * The hub's own installed version wins, falling back to the version the node
28
- * last reported. Never `latest` — resolving `latest` per node is exactly the
29
- * silent per-node drift the bootstrap installer already suffers from
30
- * (`addon-installer.ts` installs unpinned and `ensureRequiredPackages` never
31
- * re-installs, so whatever `latest` meant at a node's first boot is what that
32
- * node runs forever). A package with no resolvable version is reported, not
33
- * guessed at.
27
+ * The hub's own installed version, and only that. Never `latest` — resolving
28
+ * `latest` per node is exactly the silent per-node drift the bootstrap
29
+ * installer already suffers from (`addon-installer.ts` installs unpinned and
30
+ * `ensureRequiredPackages` never re-installs, so whatever `latest` meant at a
31
+ * node's first boot is what that node runs forever).
32
+ *
33
+ * A package the hub does NOT install is never delivered, whatever the node last
34
+ * reported. It used to fall back to the node's last-reported version, which
35
+ * made the back-fill the one path that could put an UNINSTALLED addon back: an
36
+ * operator removes `@camstack/addon-decoder-ffmpeg` from the hub and moves the
37
+ * agent's copy aside, and the next reconnect reinstalled it from npm at the
38
+ * version the node last ran — while the same pass's stale sweep exists to
39
+ * remove exactly such an addon. The two directions disagreed; now the hub's
40
+ * install set bounds both. Such packages are reported as `notOnHub`.
34
41
  *
35
42
  * BOUNDING
36
43
  * Delivery failures are counted per `(node, package, version)` and the pair is
@@ -66,7 +73,7 @@ function computeBackfillPlan(input) {
66
73
  const liveNames = new Set(input.live.map((p) => p.name));
67
74
  const targets = [];
68
75
  const retired = [];
69
- const unresolvable = [];
76
+ const notOnHub = [];
70
77
  for (const entry of input.recorded) {
71
78
  if (liveNames.has(entry.name))
72
79
  continue;
@@ -79,18 +86,13 @@ function computeBackfillPlan(input) {
79
86
  continue;
80
87
  }
81
88
  const hubVersion = input.hubVersions.get(entry.name);
82
- const version = hubVersion ?? entry.version;
83
- if (version === null || version === undefined || version.length === 0) {
84
- unresolvable.push(entry.name);
89
+ if (hubVersion === undefined) {
90
+ notOnHub.push(entry.name);
85
91
  continue;
86
92
  }
87
- targets.push({
88
- name: entry.name,
89
- version,
90
- versionSource: hubVersion === undefined ? 'node-last-reported' : 'hub-installed',
91
- });
93
+ targets.push({ name: entry.name, version: hubVersion, versionSource: 'hub-installed' });
92
94
  }
93
- return { targets, retired, unresolvable, liveCount: liveNames.size };
95
+ return { targets, retired, notOnHub, liveCount: liveNames.size };
94
96
  }
95
97
  function confirmBackfillPlan(plan, secondRead) {
96
98
  const present = new Set(secondRead.map((p) => p.name));
@@ -187,5 +189,5 @@ async function executeBackfill(seam, nodeId, plan, tracker) {
187
189
  if (delivered.length > 0) {
188
190
  await seam.reload(nodeId);
189
191
  }
190
- return { delivered, failed, skipped: plan.retired, unresolvable: plan.unresolvable };
192
+ return { delivered, failed, skipped: plan.retired, notOnHub: plan.notOnHub };
191
193
  }
@@ -281,6 +281,32 @@ class AgentRegistryService {
281
281
  return;
282
282
  await this.historyStore?.prunePackages(nodeId, [packageName]);
283
283
  }
284
+ /**
285
+ * The operator removed a whole PACKAGE from a node (`addons.uninstallPackage`
286
+ * with a `nodeId`, or `everywhere`). Same intent as
287
+ * {@link noteAddonUndeployed}, keyed by package — which is what an uninstall
288
+ * names, and the only key that still resolves once the hub itself no longer
289
+ * has the package. Emits one `AddonUninstalled` per addon removed, like the
290
+ * stale-addon reconcile does.
291
+ */
292
+ async notePackageUndeployed(nodeId, packageName, addonIds) {
293
+ if (AGENT_BOOTSTRAP_PACKAGES.has(packageName))
294
+ return;
295
+ await this.historyStore?.prunePackages(nodeId, [packageName]);
296
+ for (const addonId of addonIds) {
297
+ this.eventBus.emit({
298
+ id: (0, node_crypto_1.randomUUID)(),
299
+ timestamp: new Date(),
300
+ source: { type: 'core', id: 'agent-registry' },
301
+ category: types_1.EventCategory.AddonUninstalled,
302
+ data: { addonId, agentId: nodeId, packageName, reason: 'operator uninstall' },
303
+ });
304
+ }
305
+ }
306
+ /** The packages a node last declared (its intended set). `[]` with no store. */
307
+ async getRecordedPackages(nodeId) {
308
+ return this.historyStore ? this.historyStore.getPackages(nodeId) : [];
309
+ }
284
310
  /** Map an addon declaration id to the package that ships it, via the hub registry. */
285
311
  resolvePackageForAddon(addonId) {
286
312
  if (!this.addonRegistry)
@@ -744,9 +770,14 @@ class AgentRegistryService {
744
770
  retired: this.backfillTracker.retiredFor(agentId),
745
771
  hubVersions,
746
772
  });
747
- if (planned.targets.length === 0 &&
748
- planned.retired.length === 0 &&
749
- planned.unresolvable.length === 0) {
773
+ // Never a delivery, and said so: an addon the hub no longer installs stays
774
+ // off the node even though the node once declared it (the stale sweep above
775
+ // removes a copy that is still there).
776
+ if (planned.notOnHub.length > 0) {
777
+ console.log(`[agent-registry] Back-fill ${agentId}: NOT delivering ${planned.notOnHub.join(', ')} — ` +
778
+ `in the node's declared roster but not installed on the hub`);
779
+ }
780
+ if (planned.targets.length === 0 && planned.retired.length === 0) {
750
781
  return;
751
782
  }
752
783
  // GATE 1 — the trigger allowlist. A pass that must not install says so and
@@ -773,7 +804,7 @@ class AgentRegistryService {
773
804
  const plan = await this.confirmBackfillTargets(agentId, planned);
774
805
  if (plan === null)
775
806
  return;
776
- if (plan.targets.length === 0 && plan.retired.length === 0 && plan.unresolvable.length === 0) {
807
+ if (plan.targets.length === 0 && plan.retired.length === 0) {
777
808
  return;
778
809
  }
779
810
  // Hold the delivery window open for the whole pass, so the reload this
@@ -813,9 +844,6 @@ class AgentRegistryService {
813
844
  for (const name of outcome.skipped) {
814
845
  console.error(`[agent-registry] Back-fill ${agentId}: ${name} is still missing and RETIRED — the node is degraded until an operator intervenes`);
815
846
  }
816
- for (const name of outcome.unresolvable) {
817
- console.error(`[agent-registry] Back-fill ${agentId}: ${name} is missing but no version could be resolved (not installed on the hub, no version recorded) — not guessing "latest"`);
818
- }
819
847
  }
820
848
  /**
821
849
  * Re-read the node's roster and drop every target the second read reports
@@ -961,7 +961,6 @@ var require_dist = __commonJS({
961
961
  var import_node_url = require("url");
962
962
  var HOST_EXTERNAL_SPECIFIERS = [
963
963
  "@camstack/system",
964
- "@camstack/shm-ring",
965
964
  "@camstack/types",
966
965
  "@camstack/sdk",
967
966
  "zod",
@@ -189,7 +189,6 @@ exports.CLOSURE_PROVIDED_PACKAGES = [
189
189
  '@camstack/system',
190
190
  '@camstack/types',
191
191
  '@camstack/sdk',
192
- '@camstack/shm-ring',
193
192
  '@camstack/ui-library',
194
193
  ];
195
194
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.477",
3
+ "version": "1.2.478",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "files": [
@@ -18,8 +18,8 @@
18
18
  "scripts": {
19
19
  "build": "tsc -p tsconfig.build.json && tsup && node scripts/copy-closure-patches.mjs",
20
20
  "postinstall": "node scripts/closure-postinstall.mjs",
21
- "dev": "npx concurrently -n server,ui -c blue,magenta \"CAMSTACK_RESTART_TOUCH_FILE=src/launcher.ts tsx watch --env-file=.env --ignore=./camstack-data --ignore=../../packages/core/dist --ignore=../../packages/sdk/dist --ignore=../../packages/ui-library/dist --ignore=../../packages/shm-ring/dist src/launcher.ts\" \"cd ../../packages/addon-admin-ui && npx vite --port 3001\"",
22
- "serve": "CAMSTACK_RESTART_TOUCH_FILE=src/launcher.ts tsx watch --env-file=.env --ignore=./camstack-data --ignore=../../packages/core/dist --ignore=../../packages/sdk/dist --ignore=../../packages/ui-library/dist --ignore=../../packages/shm-ring/dist src/launcher.ts",
21
+ "dev": "npx concurrently -n server,ui -c blue,magenta \"CAMSTACK_RESTART_TOUCH_FILE=src/launcher.ts tsx watch --env-file=.env --ignore=./camstack-data --ignore=../../packages/core/dist --ignore=../../packages/sdk/dist --ignore=../../packages/ui-library/dist src/launcher.ts\" \"cd ../../packages/addon-admin-ui && npx vite --port 3001\"",
22
+ "serve": "CAMSTACK_RESTART_TOUCH_FILE=src/launcher.ts tsx watch --env-file=.env --ignore=./camstack-data --ignore=../../packages/core/dist --ignore=../../packages/sdk/dist --ignore=../../packages/ui-library/dist src/launcher.ts",
23
23
  "serve:once": "tsx --env-file=.env src/launcher.ts",
24
24
  "start": "node --env-file=.env dist/launcher.js",
25
25
  "typecheck": "tsc --noEmit -p tsconfig.spec.json",
@@ -37,18 +37,18 @@
37
37
  ]
38
38
  },
39
39
  "dependencies": {
40
- "@camstack/addon-admin-ui": "1.2.349",
41
- "@camstack/addon-agent-ui": "1.2.188",
40
+ "@camstack/addon-admin-ui": "1.2.351",
41
+ "@camstack/addon-agent-ui": "1.2.190",
42
42
  "@camstack/addon-auth": "1.2.130",
43
- "@camstack/addon-decoder-nodeav": "1.2.119",
43
+ "@camstack/addon-decoder-nodeav": "1.2.121",
44
44
  "@camstack/addon-notifiers": "1.2.128",
45
- "@camstack/addon-pipeline": "1.2.342",
46
- "@camstack/addon-pipeline-orchestrator": "1.2.295",
47
- "@camstack/addon-post-analysis": "1.2.340",
48
- "@camstack/sdk": "1.2.124",
49
- "@camstack/system": "1.2.371",
50
- "@camstack/types": "1.2.322",
51
- "@camstack/ui-library": "1.2.246",
45
+ "@camstack/addon-pipeline": "1.2.344",
46
+ "@camstack/addon-pipeline-orchestrator": "1.2.297",
47
+ "@camstack/addon-post-analysis": "1.2.342",
48
+ "@camstack/sdk": "1.2.126",
49
+ "@camstack/system": "1.2.373",
50
+ "@camstack/types": "1.2.324",
51
+ "@camstack/ui-library": "1.2.248",
52
52
  "@fastify/compress": "^9.0.0",
53
53
  "@fastify/cookie": "^11.0.2",
54
54
  "@fastify/cors": "^11.2.0",
@@ -1,109 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.createBridgeAddonsRouter = createBridgeAddonsRouter;
4
- const zod_1 = require("zod");
5
- const server_1 = require("@trpc/server");
6
- const trpc_middleware_1 = require("./trpc/trpc.middleware");
7
- function createBridgeAddonsRouter(bridge, addonSearch, addonRegistry, toastService) {
8
- return (0, trpc_middleware_1.trpcRouter)({
9
- /** List all addon packages installed in the addons directory */
10
- listPackages: trpc_middleware_1.protectedProcedure.query(() => {
11
- const installer = bridge.getInstaller();
12
- if (!installer)
13
- return [];
14
- return installer.listInstalled();
15
- }),
16
- /** List all available addons across all loaded packages */
17
- listAddons: trpc_middleware_1.protectedProcedure.query(() => {
18
- return bridge.listAvailableAddons().map((id) => {
19
- const addon = bridge.getLoader().getAddon(id);
20
- return {
21
- id,
22
- packageName: addon?.packageName ?? 'unknown',
23
- slot: addon?.declaration.slot ?? null,
24
- };
25
- });
26
- }),
27
- /** Install a community addon package from npm */
28
- installPackage: trpc_middleware_1.adminProcedure
29
- .input(zod_1.z.object({ packageName: zod_1.z.string(), version: zod_1.z.string().optional() }))
30
- .mutation(async ({ input }) => {
31
- const installer = bridge.getInstaller();
32
- if (!installer) {
33
- throw new server_1.TRPCError({
34
- code: 'PRECONDITION_FAILED',
35
- message: 'Addon installer not available — bridge may have failed to initialize',
36
- });
37
- }
38
- await installer.install(input.packageName, input.version);
39
- await bridge.reloadPackages();
40
- const result = addonRegistry
41
- ? await addonRegistry.loadNewAddons()
42
- : { loaded: [], failed: [] };
43
- toastService?.broadcast({
44
- title: 'Addon Installed',
45
- message: `${input.packageName} installed successfully${result.loaded.length ? ` (${result.loaded.join(', ')})` : ''}`,
46
- severity: result.failed.length ? 'warning' : 'info',
47
- });
48
- return { success: true, loaded: result.loaded, failed: result.failed };
49
- }),
50
- /** Uninstall a community addon package */
51
- uninstallPackage: trpc_middleware_1.adminProcedure
52
- .input(zod_1.z.object({ packageName: zod_1.z.string() }))
53
- .mutation(async ({ input }) => {
54
- // Server-side guard: prevent uninstalling required packages.
55
- // After Phase D bundle merge, the pipeline-related packages
56
- // (stream-broker, detection-pipeline, motion-wasm, decoders,
57
- // audio) all live in @camstack/addon-pipeline.
58
- const REQUIRED = new Set([
59
- '@camstack/system',
60
- '@camstack/addon-pipeline',
61
- '@camstack/addon-pipeline-orchestrator',
62
- '@camstack/addon-post-analysis',
63
- '@camstack/addon-admin-ui',
64
- ]);
65
- if (REQUIRED.has(input.packageName)) {
66
- throw new server_1.TRPCError({
67
- code: 'FORBIDDEN',
68
- message: `Package ${input.packageName} is required and cannot be uninstalled`,
69
- });
70
- }
71
- const installer = bridge.getInstaller();
72
- if (!installer) {
73
- throw new server_1.TRPCError({
74
- code: 'PRECONDITION_FAILED',
75
- message: 'Addon installer not available — bridge may have failed to initialize',
76
- });
77
- }
78
- await installer.uninstall(input.packageName);
79
- await bridge.reloadPackages();
80
- if (addonRegistry)
81
- await addonRegistry.loadNewAddons();
82
- toastService?.broadcast({
83
- title: 'Addon Uninstalled',
84
- message: `${input.packageName} has been removed`,
85
- severity: 'info',
86
- });
87
- return { success: true };
88
- }),
89
- /** Force reload all addon packages (re-scan directories, re-import modules) */
90
- reloadPackages: trpc_middleware_1.adminProcedure.mutation(async () => {
91
- await bridge.reloadPackages();
92
- return { success: true, message: 'Addon packages reloaded' };
93
- }),
94
- /** Search npm for available CamStack addons */
95
- searchAvailable: trpc_middleware_1.protectedProcedure
96
- .input(zod_1.z.object({ query: zod_1.z.string().optional() }).optional())
97
- .query(async ({ input }) => {
98
- const results = await addonSearch.searchAddons(input?.query);
99
- // Enrich with install status from locally installed packages
100
- const installed = bridge.getInstaller()?.listInstalled() ?? [];
101
- const installedMap = new Map(installed.map((p) => [p.name, p.version]));
102
- return results.map((r) => ({
103
- ...r,
104
- installed: installedMap.has(r.name),
105
- installedVersion: installedMap.get(r.name),
106
- }));
107
- }),
108
- });
109
- }