@camstack/server 1.2.127 → 1.2.128

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.
@@ -63,12 +63,13 @@ exports.agentRuntimeManifestEntry = agentRuntimeManifestEntry;
63
63
  const node_crypto_1 = require("node:crypto");
64
64
  const fs = __importStar(require("node:fs"));
65
65
  const path = __importStar(require("node:path"));
66
- const index_js_1 = require("../server-root/index.js");
67
- const system_exec_npm_js_1 = require("../core/server-update/system-exec-npm.js");
68
- const system_ensure_prebuilds_js_1 = require("../core/server-update/system-ensure-prebuilds.js");
69
66
  const system_1 = require("@camstack/system");
70
67
  const types_1 = require("@camstack/types");
68
+ const system_ensure_prebuilds_js_1 = require("../core/server-update/system-ensure-prebuilds.js");
69
+ const system_exec_npm_js_1 = require("../core/server-update/system-exec-npm.js");
71
70
  const update_availability_emitter_js_1 = require("../core/update-availability-emitter.js");
71
+ const update_availability_store_js_1 = require("../core/update-availability-store.js");
72
+ const index_js_1 = require("../server-root/index.js");
72
73
  /**
73
74
  * The synthetic addonId the agent's OWN runtime registers infra providers
74
75
  * under (no addon owns them — the bootstrap does). Appears in the agent's
@@ -151,6 +152,8 @@ class AgentUpdateService extends index_js_1.RootUpdateService {
151
152
  eventBus;
152
153
  nodeId;
153
154
  agentDataDir;
155
+ /** The base class keeps `logger` private; hold our own for the store. */
156
+ agentLogger;
154
157
  constructor(options) {
155
158
  const restartAgent = options.restartAgent ??
156
159
  ((requestedBy) => scheduleAgentRestart(options.logger, requestedBy, options.dataDir));
@@ -189,22 +192,22 @@ class AgentUpdateService extends index_js_1.RootUpdateService {
189
192
  });
190
193
  this.nodeId = options.nodeId ?? process.env['CAMSTACK_NODE_ID'] ?? 'agent';
191
194
  this.agentDataDir = options.dataDir;
195
+ this.agentLogger = options.logger;
192
196
  this.eventBus = options.eventBus ?? null;
193
197
  this.updateAvailability =
194
198
  options.eventBus !== undefined
195
- ? new update_availability_emitter_js_1.UpdateAvailabilityEmitter(options.eventBus, {
196
- type: 'core',
197
- id: 'agent-update-service',
198
- })
199
+ ? new update_availability_emitter_js_1.UpdateAvailabilityEmitter(options.eventBus, { type: 'core', id: 'agent-update-service' }, options.updateAvailabilityStore)
199
200
  : null;
200
201
  }
202
+ /**
203
+ * Bind the agent's real event bus once the mesh is up. The dedup state is
204
+ * persisted under the agent's data dir from here on — an agent restarts on
205
+ * every root update, which is exactly when its availability list is longest.
206
+ */
201
207
  bindAvailability(eventBus, nodeId) {
202
208
  this.eventBus = eventBus;
203
209
  this.nodeId = nodeId;
204
- this.updateAvailability = new update_availability_emitter_js_1.UpdateAvailabilityEmitter(eventBus, {
205
- type: 'core',
206
- id: 'agent-update-service',
207
- });
210
+ this.updateAvailability = new update_availability_emitter_js_1.UpdateAvailabilityEmitter(eventBus, { type: 'core', id: 'agent-update-service' }, new update_availability_store_js_1.FileUpdateAvailabilityStore(this.agentDataDir, 'agent-update', this.agentLogger));
208
211
  }
209
212
  async checkServerUpdate() {
210
213
  const result = await super.checkServerUpdate();
@@ -76,6 +76,7 @@ const server_1 = require("@trpc/server");
76
76
  const integration_id_backfill_1 = require("../../boot/integration-id-backfill");
77
77
  const addon_package_service_js_1 = require("../../core/addon/addon-package.service.js");
78
78
  const lifecycle_runner_singleton_js_1 = require("../../core/lifecycle/lifecycle-runner.singleton.js");
79
+ const agent_installed_packages_js_1 = require("../../core/updates/agent-installed-packages.js");
79
80
  const collection_preference_js_1 = require("./collection-preference.js");
80
81
  const site_location_js_1 = require("./site-location.js");
81
82
  // ── system ──────────────────────────────────────────────────────────
@@ -1220,18 +1221,6 @@ function isHubNode(nodeId) {
1220
1221
  * filter it out to make the list look clean — the number reaching zero is the
1221
1222
  * point of the number.
1222
1223
  */
1223
- async function fetchAgentInstalledPackages(broker, nodeId) {
1224
- const status = await broker.call('$agent.status', {}, { nodeID: nodeId, timeout: 5_000 });
1225
- const out = [];
1226
- for (const a of status.addons ?? []) {
1227
- if (typeof a.packageName !== 'string' || typeof a.version !== 'string')
1228
- continue;
1229
- if ((0, addon_package_service_js_1.isFrameworkPackage)(a.packageName))
1230
- continue;
1231
- out.push({ name: a.packageName, version: a.version });
1232
- }
1233
- return out;
1234
- }
1235
1224
  function buildAddonsProvider(ar, ps, ls, moleculer, configService, ctx) {
1236
1225
  const broker = moleculer.broker;
1237
1226
  const frameworkAllowSet = new Set([addon_package_service_js_1.SYSTEM_PACKAGE]);
@@ -1277,7 +1266,7 @@ function buildAddonsProvider(ar, ps, ls, moleculer, configService, ctx) {
1277
1266
  const nodeId = input.nodeId;
1278
1267
  const updates = nodeId === undefined || isHubNode(nodeId)
1279
1268
  ? await ps.checkUpdates()
1280
- : await ps.checkUpdatesForInstalled(await fetchAgentInstalledPackages(broker, nodeId), nodeId);
1269
+ : await ps.checkUpdatesForInstalled(await (0, agent_installed_packages_js_1.fetchAgentInstalledPackages)(broker, nodeId), nodeId);
1281
1270
  return updates.map((u) => ({ ...u, isSystem: frameworkAllowSet.has(u.name) }));
1282
1271
  },
1283
1272
  updatePackage: async (input) => {
@@ -1331,7 +1320,7 @@ function buildAddonsProvider(ar, ps, ls, moleculer, configService, ctx) {
1331
1320
  if (nodeId === undefined || isHubNode(nodeId))
1332
1321
  return ps.checkUpdates(true);
1333
1322
  // Agent rosters carry no hub-side cache — the diff is always live.
1334
- const installed = await fetchAgentInstalledPackages(broker, nodeId);
1323
+ const installed = await (0, agent_installed_packages_js_1.fetchAgentInstalledPackages)(broker, nodeId);
1335
1324
  return ps.checkUpdatesForInstalled(installed, nodeId);
1336
1325
  },
1337
1326
  restartServer: async () => ps.restartServer(ctx.user?.username ?? ctx.user?.id),
@@ -1399,7 +1388,7 @@ function buildAddonsProvider(ar, ps, ls, moleculer, configService, ctx) {
1399
1388
  return { success: true };
1400
1389
  },
1401
1390
  getAutoUpdateSettings: async () => ps.getAutoUpdateSettings(),
1402
- setAutoUpdateSettings: async (input) => ps.setAutoUpdateSettings(input.channel, input.intervalSeconds),
1391
+ setAutoUpdateSettings: async (input) => ps.setAutoUpdateSettings(input.channel, input.intervalSeconds, input.updateCheckIntervalSeconds),
1403
1392
  getAddonAutoUpdate: async (input) => ps.getAddonAutoUpdate(input.addonId),
1404
1393
  setAddonAutoUpdate: async (input) => ps.setAddonAutoUpdate(input.addonId, input.channel),
1405
1394
  applyAutoUpdateToAll: async (input) => {
@@ -45,8 +45,9 @@ const path = __importStar(require("node:path"));
45
45
  const node_util_1 = require("node:util");
46
46
  const system_1 = require("@camstack/system");
47
47
  const types_1 = require("@camstack/types");
48
- const package_dir_utils_js_1 = require("./package-dir-utils.js");
49
48
  const update_availability_emitter_js_1 = require("../update-availability-emitter.js");
49
+ const update_check_scheduler_js_1 = require("../updates/update-check-scheduler.js");
50
+ const package_dir_utils_js_1 = require("./package-dir-utils.js");
50
51
  const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
51
52
  /**
52
53
  * The primary host-provided framework package (kernel + core). Kept as a named
@@ -193,10 +194,20 @@ class AddonPackageService {
193
194
  updateAvailability;
194
195
  // -- Auto-update state ----------------------------------------------------
195
196
  autoUpdateConfig = {
196
- global: { channel: 'off', intervalSeconds: 21600 },
197
+ global: {
198
+ channel: 'off',
199
+ intervalSeconds: 21600,
200
+ updateCheckIntervalSeconds: update_check_scheduler_js_1.DEFAULT_UPDATE_CHECK_INTERVAL_SECONDS,
201
+ },
197
202
  overrides: {},
198
203
  };
199
204
  autoUpdateTimer = null;
205
+ /**
206
+ * Re-arm hook for the availability poller, wired at boot (manual-boot). Kept
207
+ * as a callback rather than a service reference so this module never imports
208
+ * the scheduler's dependencies back.
209
+ */
210
+ updateCheckRescheduler = null;
200
211
  // -- Timing constants -----------------------------------------------------
201
212
  // Short TTL — operators expect to see freshly-published versions
202
213
  // within minutes of `npm publish`, not hours. The Addons page kicks
@@ -207,7 +218,12 @@ class AddonPackageService {
207
218
  static VERSION_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
208
219
  static NPM_REGISTRY = 'https://registry.npmjs.org';
209
220
  static REGISTRY_TIMEOUT_MS = 10_000;
210
- constructor(loggingService, eventBusService, configService, addonRegistry, notificationService, toastService) {
221
+ constructor(loggingService, eventBusService, configService, addonRegistry, notificationService, toastService,
222
+ /**
223
+ * Durable dedup state for `update.available`. Injected by boot wiring
224
+ * (manual-boot) — unit tests leave it out and get the RAM-only emitter.
225
+ */
226
+ updateAvailabilityStore) {
211
227
  this.loggingService = loggingService;
212
228
  this.eventBusService = eventBusService;
213
229
  this.configService = configService;
@@ -215,10 +231,7 @@ class AddonPackageService {
215
231
  this.notificationService = notificationService;
216
232
  this.toastService = toastService;
217
233
  this.logger = this.loggingService.createLogger('AddonPackageService');
218
- this.updateAvailability = new update_availability_emitter_js_1.UpdateAvailabilityEmitter(this.eventBusService, {
219
- type: 'core',
220
- id: 'addon-package-service',
221
- });
234
+ this.updateAvailability = new update_availability_emitter_js_1.UpdateAvailabilityEmitter(this.eventBusService, { type: 'core', id: 'addon-package-service' }, updateAvailabilityStore);
222
235
  // Initialize installer eagerly (no async needed).
223
236
  // Ensures install/uninstall works before full module init completes.
224
237
  try {
@@ -613,8 +626,8 @@ class AddonPackageService {
613
626
  this.logger.info('Checking for package updates...');
614
627
  const updates = [];
615
628
  // Check installed addon packages in addons dir
616
- const addonUpdates = await this.checkAddonPackageUpdates();
617
- updates.push(...addonUpdates);
629
+ const sweep = await this.checkAddonPackageUpdates();
630
+ updates.push(...sweep.updates);
618
631
  this.logger.info('Found package updates', {
619
632
  meta: {
620
633
  count: updates.length,
@@ -629,12 +642,7 @@ class AddonPackageService {
629
642
  updates,
630
643
  expiresAt: now + AddonPackageService.UPDATE_CACHE_TTL_MS,
631
644
  };
632
- this.updateAvailability.publishSnapshot('addon', updates.map((update) => ({
633
- target: 'addon',
634
- packageName: update.name,
635
- currentVersion: update.currentVersion,
636
- latestVersion: update.latestVersion,
637
- })));
645
+ this.publishAddonAvailability(updates, sweep.failures);
638
646
  return updates;
639
647
  }
640
648
  /** Clear the cached update check results */
@@ -659,6 +667,7 @@ class AddonPackageService {
659
667
  seen.set(pkg.name, pkg.version);
660
668
  }
661
669
  const updates = [];
670
+ let failures = 0;
662
671
  await Promise.all([...seen].map(async ([name, version]) => {
663
672
  if (!this.isAllowedPackage(name))
664
673
  return;
@@ -667,7 +676,16 @@ class AddonPackageService {
667
676
  // keeps the UI honest (no dead "Update" button for @camstack/server).
668
677
  if (exports.AUTO_UPDATE_EXCLUDED_PACKAGES.has(name))
669
678
  return;
670
- const latestVersion = await this.fetchLatestVersion(name);
679
+ const lookup = await this.lookupLatestVersion(name);
680
+ if (!lookup.ok) {
681
+ failures += 1;
682
+ this.logger.warn('Registry lookup failed for installed package', {
683
+ tags: { nodeId: nodeId ?? 'hub' },
684
+ meta: { name, error: lookup.error },
685
+ });
686
+ return;
687
+ }
688
+ const latestVersion = lookup.latestVersion;
671
689
  if (latestVersion === null || !isVersionNewer(latestVersion, version))
672
690
  return;
673
691
  const category = this.categorize(name);
@@ -679,13 +697,7 @@ class AddonPackageService {
679
697
  requiresRestart: category === 'core',
680
698
  });
681
699
  }));
682
- this.updateAvailability.publishSnapshot('addon', updates.map((update) => ({
683
- target: 'addon',
684
- packageName: update.name,
685
- currentVersion: update.currentVersion,
686
- latestVersion: update.latestVersion,
687
- ...(nodeId !== undefined ? { nodeId } : {}),
688
- })), nodeId);
700
+ this.publishAddonAvailability(updates, failures, nodeId);
689
701
  return updates;
690
702
  }
691
703
  /**
@@ -1165,6 +1177,7 @@ class AddonPackageService {
1165
1177
  const hubPkgDir = (0, package_dir_utils_js_1.resolveHubClosurePackageDir)(packageName);
1166
1178
  const buildId = hubPkgDir !== null ? (0, package_dir_utils_js_1.computeDistBuildId)(path.join(hubPkgDir, 'dist')) : null;
1167
1179
  let latestVersion = null;
1180
+ let lookupFailed = false;
1168
1181
  try {
1169
1182
  const args = [
1170
1183
  'view',
@@ -1177,6 +1190,7 @@ class AddonPackageService {
1177
1190
  latestVersion = trimmed.length > 0 ? trimmed : null;
1178
1191
  }
1179
1192
  catch (err) {
1193
+ lookupFailed = true;
1180
1194
  this.logger.debug('listFrameworkPackages: npm view failed', {
1181
1195
  meta: { packageName, error: (0, types_1.errMsg)(err) },
1182
1196
  });
@@ -1188,10 +1202,53 @@ class AddonPackageService {
1188
1202
  latestVersion,
1189
1203
  hasUpdate,
1190
1204
  buildId,
1205
+ lookupFailed,
1191
1206
  ...(description !== undefined ? { description } : {}),
1192
1207
  };
1193
1208
  }));
1194
- return rows;
1209
+ this.publishFrameworkAvailability(rows);
1210
+ return rows.map(({ lookupFailed: _lookupFailed, ...row }) => row);
1211
+ }
1212
+ /**
1213
+ * Announce framework updates. This surface SHOWED `@camstack/system` had an
1214
+ * update and emitted nothing, so the operator was never told — it was the
1215
+ * only discovery path with no publish at all.
1216
+ *
1217
+ * Two rules it must not break:
1218
+ * - **Strictly newer only.** `hasUpdate` is `latest !== current`, which is
1219
+ * true for a hub running a `-dev.<timestamp>` build AHEAD of the npm tag.
1220
+ * Announcing that would be telling the operator to downgrade.
1221
+ * - **A failed lookup publishes nothing.** Not even an empty snapshot: an
1222
+ * empty snapshot means "up to date" and would clear the dedup state, so
1223
+ * the next successful poll would re-announce everything.
1224
+ */
1225
+ publishFrameworkAvailability(rows) {
1226
+ const failed = rows.filter((row) => row.lookupFailed);
1227
+ if (failed.length > 0) {
1228
+ this.logger.warn('Framework update check incomplete — availability not published', {
1229
+ meta: { packages: failed.map((row) => row.packageName) },
1230
+ });
1231
+ return;
1232
+ }
1233
+ const candidates = [];
1234
+ for (const row of rows) {
1235
+ if (row.latestVersion === null || row.currentVersion === 'unknown')
1236
+ continue;
1237
+ if (!isVersionNewer(row.latestVersion, row.currentVersion))
1238
+ continue;
1239
+ candidates.push({
1240
+ target: 'server',
1241
+ packageName: row.packageName,
1242
+ currentVersion: row.currentVersion,
1243
+ latestVersion: row.latestVersion,
1244
+ nodeId: this.resolveNodeId(),
1245
+ });
1246
+ }
1247
+ this.updateAvailability.publishSnapshot('server', candidates, this.resolveNodeId());
1248
+ }
1249
+ /** Node id stamped on availability events emitted by THIS process. */
1250
+ resolveNodeId() {
1251
+ return process.env['CAMSTACK_NODE_ID'] ?? 'hub';
1195
1252
  }
1196
1253
  // =========================================================================
1197
1254
  // Reload
@@ -1224,21 +1281,39 @@ class AddonPackageService {
1224
1281
  // =========================================================================
1225
1282
  // Auto-update settings
1226
1283
  // =========================================================================
1284
+ /**
1285
+ * Register the availability poller's re-arm hook. Called once at boot so a
1286
+ * `setAutoUpdateSettings` write takes effect without a restart.
1287
+ */
1288
+ setUpdateCheckRescheduler(reschedule) {
1289
+ this.updateCheckRescheduler = reschedule;
1290
+ }
1227
1291
  /** Get global auto-update settings */
1228
1292
  getAutoUpdateSettings() {
1229
1293
  return { ...this.autoUpdateConfig.global };
1230
1294
  }
1231
1295
  /** Set global auto-update settings and restart the timer */
1232
- async setAutoUpdateSettings(channel, intervalSeconds) {
1296
+ async setAutoUpdateSettings(channel, intervalSeconds, updateCheckIntervalSeconds) {
1297
+ const previousCheckInterval = this.autoUpdateConfig.global.updateCheckIntervalSeconds;
1298
+ const nextCheckInterval = updateCheckIntervalSeconds === undefined
1299
+ ? previousCheckInterval
1300
+ : (0, update_check_scheduler_js_1.clampUpdateCheckInterval)(updateCheckIntervalSeconds);
1233
1301
  this.autoUpdateConfig = {
1234
1302
  ...this.autoUpdateConfig,
1235
1303
  global: {
1236
1304
  channel,
1237
1305
  intervalSeconds: intervalSeconds ?? this.autoUpdateConfig.global.intervalSeconds,
1306
+ updateCheckIntervalSeconds: nextCheckInterval,
1238
1307
  },
1239
1308
  };
1240
1309
  this.saveAutoUpdateConfig();
1241
1310
  this.scheduleAutoUpdate();
1311
+ if (updateCheckIntervalSeconds === undefined)
1312
+ return;
1313
+ this.logger.info('Update-check interval changed', {
1314
+ meta: { from: previousCheckInterval, to: nextCheckInterval },
1315
+ });
1316
+ this.updateCheckRescheduler?.();
1242
1317
  }
1243
1318
  /** Get per-addon auto-update override */
1244
1319
  getAddonAutoUpdate(addonId) {
@@ -1395,10 +1470,14 @@ class AddonPackageService {
1395
1470
  const global = asRecord(raw['global']);
1396
1471
  const channel = asString(global['channel']);
1397
1472
  const validChannel = channel === 'latest' || channel === 'beta' ? channel : 'off';
1473
+ const rawCheckInterval = global['updateCheckIntervalSeconds'];
1398
1474
  return {
1399
1475
  global: {
1400
1476
  channel: validChannel,
1401
1477
  intervalSeconds: typeof global['intervalSeconds'] === 'number' ? global['intervalSeconds'] : 3600,
1478
+ updateCheckIntervalSeconds: typeof rawCheckInterval === 'number'
1479
+ ? (0, update_check_scheduler_js_1.clampUpdateCheckInterval)(rawCheckInterval)
1480
+ : update_check_scheduler_js_1.DEFAULT_UPDATE_CHECK_INTERVAL_SECONDS,
1402
1481
  },
1403
1482
  overrides: Object.fromEntries(Object.entries(asRecord(raw['overrides'])).map(([k, v]) => {
1404
1483
  const s = asString(v);
@@ -1414,7 +1493,14 @@ class AddonPackageService {
1414
1493
  meta: { error: (0, types_1.errMsg)(err) },
1415
1494
  });
1416
1495
  }
1417
- return { global: { channel: 'off', intervalSeconds: 21600 }, overrides: {} };
1496
+ return {
1497
+ global: {
1498
+ channel: 'off',
1499
+ intervalSeconds: 21600,
1500
+ updateCheckIntervalSeconds: update_check_scheduler_js_1.DEFAULT_UPDATE_CHECK_INTERVAL_SECONDS,
1501
+ },
1502
+ overrides: {},
1503
+ };
1418
1504
  }
1419
1505
  /** Save auto-update config to disk */
1420
1506
  saveAutoUpdateConfig() {
@@ -1440,12 +1526,17 @@ class AddonPackageService {
1440
1526
  /**
1441
1527
  * Check addon packages for updates by reading installed versions from
1442
1528
  * data/addons/{name}/package.json and comparing against npm registry.
1529
+ *
1530
+ * Reports `failures` alongside the diff: a registry lookup that could not be
1531
+ * MADE is not evidence that a package is up to date, and the availability
1532
+ * publish branches on it.
1443
1533
  */
1444
1534
  async checkAddonPackageUpdates() {
1445
1535
  const addonsDir = this.resolveAddonsDir();
1446
1536
  const updates = [];
1537
+ let failures = 0;
1447
1538
  if (!fs.existsSync(addonsDir))
1448
- return updates;
1539
+ return { updates, failures };
1449
1540
  // Collect all package.json paths -- handles both flat and scoped layouts
1450
1541
  const pkgJsonPaths = [];
1451
1542
  const topDirs = fs
@@ -1491,7 +1582,16 @@ class AddonPackageService {
1491
1582
  if (source === 'workspace')
1492
1583
  continue;
1493
1584
  }
1494
- const latestVersion = await this.fetchLatestVersion(name);
1585
+ const lookup = await this.lookupLatestVersion(name);
1586
+ if (!lookup.ok) {
1587
+ failures += 1;
1588
+ this.logger.warn('Registry lookup failed for installed addon package', {
1589
+ tags: { nodeId: this.resolveNodeId() },
1590
+ meta: { name, error: lookup.error },
1591
+ });
1592
+ continue;
1593
+ }
1594
+ const latestVersion = lookup.latestVersion;
1495
1595
  if (!latestVersion)
1496
1596
  continue;
1497
1597
  if (isVersionNewer(latestVersion, version)) {
@@ -1505,13 +1605,16 @@ class AddonPackageService {
1505
1605
  }
1506
1606
  }
1507
1607
  catch (error) {
1608
+ // The package could not be evaluated at all (unreadable manifest, …).
1609
+ // Counted as a failure for the same reason a registry miss is.
1610
+ failures += 1;
1508
1611
  const msg = (0, types_1.errMsg)(error);
1509
- this.logger.debug('Failed to check updates for addon', {
1612
+ this.logger.warn('Failed to check updates for addon', {
1510
1613
  meta: { pkgJsonPath, error: msg },
1511
1614
  });
1512
1615
  }
1513
1616
  }
1514
- return updates;
1617
+ return { updates, failures };
1515
1618
  }
1516
1619
  // =========================================================================
1517
1620
  // Private: npm registry helpers
@@ -1522,7 +1625,7 @@ class AddonPackageService {
1522
1625
  * Honours `CAMSTACK_NPM_REGISTRY` so update checks resolve against
1523
1626
  * the same registry the installer/pack paths use. Without this, a
1524
1627
  * per-node `listUpdates` (which diffs an agent's roster via
1525
- * `checkUpdatesForInstalled` → `fetchLatestVersion`) would bypass a
1628
+ * `checkUpdatesForInstalled` → `lookupLatestVersion`) would bypass a
1526
1629
  * private registry — including the e2e harness's verdaccio — and
1527
1630
  * silently report "no update" for packages that only exist there.
1528
1631
  * Trailing slashes are stripped so the `${base}/${name}` join is clean.
@@ -1532,29 +1635,61 @@ class AddonPackageService {
1532
1635
  const base = override && override.length > 0 ? override : AddonPackageService.NPM_REGISTRY;
1533
1636
  return base.replace(/\/+$/, '');
1534
1637
  }
1535
- /** Fetch the latest published version of a package from the npm registry */
1536
- async fetchLatestVersion(packageName) {
1638
+ /**
1639
+ * Ask the registry for a package's latest version, distinguishing "there is
1640
+ * no such package" (a definitive answer) from "I could not ask" (a failure).
1641
+ * The difference decides whether an availability SNAPSHOT may be published —
1642
+ * see {@link RegistryLookup}.
1643
+ */
1644
+ async lookupLatestVersion(packageName) {
1537
1645
  try {
1538
1646
  const encodedName = packageName.replace('/', '%2F');
1539
1647
  const url = `${this.resolveRegistryBase()}/${encodedName}/latest`;
1540
1648
  const response = await fetch(url, {
1541
1649
  signal: AbortSignal.timeout(AddonPackageService.REGISTRY_TIMEOUT_MS),
1542
1650
  });
1651
+ if (response.status === 404)
1652
+ return { ok: true, latestVersion: null };
1543
1653
  if (!response.ok) {
1544
1654
  this.logger.debug('Registry returned non-ok status', {
1545
1655
  meta: { packageName, status: response.status },
1546
1656
  });
1547
- return null;
1657
+ return { ok: false, error: `registry status ${response.status}` };
1548
1658
  }
1549
1659
  const data = await fetchJsonObject(response);
1550
1660
  const version = asString(data['version']);
1551
- return version || null;
1661
+ return { ok: true, latestVersion: version || null };
1552
1662
  }
1553
1663
  catch (error) {
1554
- const msg = (0, types_1.errMsg)(error);
1555
- this.logger.debug('Failed to fetch latest version', { meta: { packageName, error: msg } });
1556
- return null;
1664
+ return { ok: false, error: (0, types_1.errMsg)(error) };
1665
+ }
1666
+ }
1667
+ /**
1668
+ * Announce the addon-update set for one scope (hub or a node).
1669
+ *
1670
+ * `failures > 0` means at least one registry lookup could not be made, so
1671
+ * the set is INCOMPLETE — publish it as candidates (additive) rather than a
1672
+ * snapshot (authoritative). A snapshot built from a partial sweep silently
1673
+ * marks the unreachable packages "up to date", clears their dedup state and
1674
+ * re-announces them on the next good poll.
1675
+ */
1676
+ publishAddonAvailability(updates, failures, nodeId) {
1677
+ const candidates = updates.map((update) => ({
1678
+ target: 'addon',
1679
+ packageName: update.name,
1680
+ currentVersion: update.currentVersion,
1681
+ latestVersion: update.latestVersion,
1682
+ ...(nodeId !== undefined ? { nodeId } : {}),
1683
+ }));
1684
+ if (failures > 0) {
1685
+ this.logger.warn('Addon update check incomplete — availability published as partial', {
1686
+ tags: { nodeId: nodeId ?? 'hub' },
1687
+ meta: { failures, published: candidates.length },
1688
+ });
1689
+ this.updateAvailability.publishCandidates(candidates);
1690
+ return;
1557
1691
  }
1692
+ this.updateAvailability.publishSnapshot('addon', candidates, nodeId);
1558
1693
  }
1559
1694
  /** Fetch npm search results for camstack addon packages (cached 5 min) */
1560
1695
  async fetchSearchFromNpm() {
@@ -53,9 +53,9 @@ exports.ServerUpdateService = void 0;
53
53
  */
54
54
  const path = __importStar(require("node:path"));
55
55
  const index_js_1 = require("../../server-root/index.js");
56
- const system_exec_npm_js_1 = require("./system-exec-npm.js");
57
- const system_ensure_prebuilds_js_1 = require("./system-ensure-prebuilds.js");
58
56
  const update_availability_emitter_js_1 = require("../update-availability-emitter.js");
57
+ const system_ensure_prebuilds_js_1 = require("./system-ensure-prebuilds.js");
58
+ const system_exec_npm_js_1 = require("./system-exec-npm.js");
59
59
  class ServerUpdateService extends index_js_1.RootUpdateService {
60
60
  updateAvailability;
61
61
  nodeId;
@@ -95,10 +95,7 @@ class ServerUpdateService extends index_js_1.RootUpdateService {
95
95
  });
96
96
  this.updateAvailability =
97
97
  options.eventBus !== undefined
98
- ? new update_availability_emitter_js_1.UpdateAvailabilityEmitter(options.eventBus, {
99
- type: 'core',
100
- id: 'server-update-service',
101
- })
98
+ ? new update_availability_emitter_js_1.UpdateAvailabilityEmitter(options.eventBus, { type: 'core', id: 'server-update-service' }, options.updateAvailabilityStore)
102
99
  : null;
103
100
  this.nodeId = options.nodeId ?? 'hub';
104
101
  }
@@ -14,15 +14,33 @@ const types_1 = require("@camstack/types");
14
14
  *
15
15
  * A successful empty snapshot clears prior state for that scope; failed checks
16
16
  * should not call this method.
17
+ *
18
+ * When a {@link IUpdateAvailabilityStore} is injected the dedup state is
19
+ * DURABLE: a restart does not re-announce what the operator already saw, and a
20
+ * genuinely newer version still announces. Without one the emitter stays
21
+ * RAM-only (the shape every unit test uses).
17
22
  */
18
23
  class UpdateAvailabilityEmitter {
19
24
  eventBus;
20
25
  source;
26
+ store;
21
27
  latestByKey = new Map();
22
28
  held = new Map();
23
- constructor(eventBus, source) {
29
+ /** Set when in-memory state diverged from what the store last saw. */
30
+ dirty = false;
31
+ constructor(eventBus, source, store) {
24
32
  this.eventBus = eventBus;
25
33
  this.source = source;
34
+ this.store = store;
35
+ const persisted = store?.load() ?? null;
36
+ if (persisted === null)
37
+ return;
38
+ for (const [key, version] of Object.entries(persisted.latestByKey)) {
39
+ this.latestByKey.set(key, version);
40
+ }
41
+ for (const [key, candidate] of Object.entries(persisted.held)) {
42
+ this.held.set(key, candidate);
43
+ }
26
44
  }
27
45
  publishSnapshot(target, candidates, nodeId) {
28
46
  const scope = nodeId ?? 'hub';
@@ -44,7 +62,9 @@ class UpdateAvailabilityEmitter {
44
62
  continue;
45
63
  this.held.delete(key);
46
64
  this.latestByKey.delete(key);
65
+ this.dirty = true;
47
66
  }
67
+ this.persistIfDirty();
48
68
  if (!latestChanged)
49
69
  return;
50
70
  this.emitList(target);
@@ -58,6 +78,7 @@ class UpdateAvailabilityEmitter {
58
78
  if (this.note(candidate))
59
79
  latestChanged = true;
60
80
  }
81
+ this.persistIfDirty();
61
82
  if (!latestChanged)
62
83
  return;
63
84
  for (const target of targets)
@@ -66,10 +87,30 @@ class UpdateAvailabilityEmitter {
66
87
  note(candidate) {
67
88
  const key = this.key(candidate);
68
89
  const previous = this.latestByKey.get(key);
90
+ const previousHeld = this.held.get(key);
91
+ if (previousHeld === undefined ||
92
+ previousHeld.currentVersion !== candidate.currentVersion ||
93
+ previousHeld.latestVersion !== candidate.latestVersion ||
94
+ previousHeld.nodeId !== candidate.nodeId) {
95
+ this.dirty = true;
96
+ }
69
97
  this.held.set(key, candidate);
70
98
  this.latestByKey.set(key, candidate.latestVersion);
71
99
  return previous !== candidate.latestVersion;
72
100
  }
101
+ /**
102
+ * Flush to the durable store. Called BEFORE the emit so a crash between the
103
+ * two costs a duplicate announcement, never a silent one.
104
+ */
105
+ persistIfDirty() {
106
+ if (!this.dirty)
107
+ return;
108
+ this.dirty = false;
109
+ this.store?.save({
110
+ latestByKey: Object.fromEntries(this.latestByKey),
111
+ held: Object.fromEntries(this.held),
112
+ });
113
+ }
73
114
  emitList(target) {
74
115
  const list = [...this.held.values()].filter((candidate) => candidate.target === target);
75
116
  if (list.length === 0)
@@ -0,0 +1,148 @@
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.FileUpdateAvailabilityStore = exports.UPDATE_AVAILABILITY_DIR = void 0;
37
+ /**
38
+ * Durable backing for {@link UpdateAvailabilityEmitter}'s dedup state.
39
+ *
40
+ * The emitter's whole job is "announce a (package, version) ONCE". Keeping
41
+ * that in RAM meant every hub restart re-announced everything the operator had
42
+ * already seen — and hubs restart on every framework update, which is exactly
43
+ * the moment the list is longest.
44
+ *
45
+ * The mechanism is deliberately the one the neighbouring update code already
46
+ * uses: a small JSON file under the node's data dir, next to
47
+ * `auto-update.json`, `runtime-state.json` and `.restart-pending`. No new
48
+ * database, no settings-store dependency (the emitter runs on the AGENT too,
49
+ * where the hub's SQLite settings store does not exist).
50
+ */
51
+ const fs = __importStar(require("node:fs"));
52
+ const path = __importStar(require("node:path"));
53
+ const types_1 = require("@camstack/types");
54
+ /** Directory under the data dir that holds one file per emitter scope. */
55
+ exports.UPDATE_AVAILABILITY_DIR = 'update-availability';
56
+ /**
57
+ * One file per emitter scope (`addon-packages`, `server-update`,
58
+ * `agent-update`) so two emitters in the same process never overwrite each
59
+ * other's dedup state.
60
+ */
61
+ class FileUpdateAvailabilityStore {
62
+ scopeId;
63
+ logger;
64
+ filePath;
65
+ constructor(dataDir, scopeId, logger) {
66
+ this.scopeId = scopeId;
67
+ this.logger = logger;
68
+ this.filePath = path.join(dataDir, exports.UPDATE_AVAILABILITY_DIR, `${scopeId}.json`);
69
+ }
70
+ load() {
71
+ try {
72
+ if (!fs.existsSync(this.filePath))
73
+ return null;
74
+ const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf-8'));
75
+ return parseState(parsed);
76
+ }
77
+ catch (error) {
78
+ // A corrupt file must not look like "nothing was ever announced" AND
79
+ // must not crash boot. It re-announces once, then heals on the next save.
80
+ this.logger.warn('Update-availability state unreadable; starting from empty', {
81
+ meta: { scopeId: this.scopeId, path: this.filePath, error: (0, types_1.errMsg)(error) },
82
+ });
83
+ return null;
84
+ }
85
+ }
86
+ save(state) {
87
+ try {
88
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
89
+ const tmp = `${this.filePath}.tmp`;
90
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
91
+ fs.renameSync(tmp, this.filePath);
92
+ }
93
+ catch (error) {
94
+ // Losing a write degrades to the old RAM-only behaviour (a re-announce
95
+ // after restart) — never a failed update check.
96
+ this.logger.warn('Failed to persist update-availability state', {
97
+ meta: { scopeId: this.scopeId, path: this.filePath, error: (0, types_1.errMsg)(error) },
98
+ });
99
+ }
100
+ }
101
+ }
102
+ exports.FileUpdateAvailabilityStore = FileUpdateAvailabilityStore;
103
+ function parseState(raw) {
104
+ if (raw === null || typeof raw !== 'object')
105
+ return null;
106
+ const latestRaw = Reflect.get(raw, 'latestByKey');
107
+ const heldRaw = Reflect.get(raw, 'held');
108
+ if (latestRaw === null || typeof latestRaw !== 'object')
109
+ return null;
110
+ if (heldRaw === null || typeof heldRaw !== 'object')
111
+ return null;
112
+ const latestByKey = {};
113
+ for (const [key, value] of Object.entries(latestRaw)) {
114
+ if (typeof value === 'string')
115
+ latestByKey[key] = value;
116
+ }
117
+ const held = {};
118
+ for (const [key, value] of Object.entries(heldRaw)) {
119
+ const candidate = parseCandidate(value);
120
+ if (candidate !== null)
121
+ held[key] = candidate;
122
+ }
123
+ return { latestByKey, held };
124
+ }
125
+ function parseCandidate(raw) {
126
+ if (raw === null || typeof raw !== 'object')
127
+ return null;
128
+ const target = Reflect.get(raw, 'target');
129
+ const packageName = Reflect.get(raw, 'packageName');
130
+ const currentVersion = Reflect.get(raw, 'currentVersion');
131
+ const latestVersion = Reflect.get(raw, 'latestVersion');
132
+ const nodeId = Reflect.get(raw, 'nodeId');
133
+ if (target !== 'addon' && target !== 'server')
134
+ return null;
135
+ if (typeof packageName !== 'string')
136
+ return null;
137
+ if (typeof currentVersion !== 'string')
138
+ return null;
139
+ if (typeof latestVersion !== 'string')
140
+ return null;
141
+ return {
142
+ target,
143
+ packageName,
144
+ currentVersion,
145
+ latestVersion,
146
+ ...(typeof nodeId === 'string' ? { nodeId } : {}),
147
+ };
148
+ }
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AGENT_STATUS_TIMEOUT_MS = void 0;
4
+ exports.fetchAgentInstalledPackages = fetchAgentInstalledPackages;
5
+ /**
6
+ * The agent addon roster, as the hub sees it.
7
+ *
8
+ * An agent has no npm runtime: it reports what it has installed via
9
+ * `$agent.status` and the hub does the registry diff
10
+ * (`AddonPackageService.checkUpdatesForInstalled`). Extracted from
11
+ * `cap-providers.ts` so the availability poller and the `addons.listUpdates`
12
+ * cap route read the roster through ONE function — a second copy would drift
13
+ * the framework filter and make the two surfaces disagree about what is
14
+ * installed on a node.
15
+ */
16
+ const addon_package_service_js_1 = require("../addon/addon-package.service.js");
17
+ /** Timeout for `$agent.status`; a node that cannot answer this fast is unwell. */
18
+ exports.AGENT_STATUS_TIMEOUT_MS = 5_000;
19
+ async function fetchAgentInstalledPackages(broker, nodeId) {
20
+ const status = await broker.call('$agent.status', {}, { nodeID: nodeId, timeout: exports.AGENT_STATUS_TIMEOUT_MS });
21
+ const out = [];
22
+ for (const addon of status.addons ?? []) {
23
+ if (typeof addon.packageName !== 'string' || typeof addon.version !== 'string')
24
+ continue;
25
+ if ((0, addon_package_service_js_1.isFrameworkPackage)(addon.packageName))
26
+ continue;
27
+ out.push({ name: addon.packageName, version: addon.version });
28
+ }
29
+ return out;
30
+ }
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UpdateCheckScheduler = exports.MAX_UPDATE_CHECK_INTERVAL_SECONDS = exports.MIN_UPDATE_CHECK_INTERVAL_SECONDS = exports.DEFAULT_UPDATE_CHECK_INTERVAL_SECONDS = void 0;
4
+ exports.clampUpdateCheckInterval = clampUpdateCheckInterval;
5
+ const types_1 = require("@camstack/types");
6
+ /**
7
+ * Default poll cadence: 6 hours. Registry lookups are cheap but not free, and
8
+ * an operator who is told about a publish within a quarter-day is told in time.
9
+ */
10
+ exports.DEFAULT_UPDATE_CHECK_INTERVAL_SECONDS = 6 * 60 * 60;
11
+ /**
12
+ * Floor: 15 minutes. Below this the sweep starts overlapping its own npm
13
+ * lookups on a cluster of any size, and the notification stops being a
14
+ * notification. `setAutoUpdateSettings` clamps to this rather than refusing,
15
+ * so a bad value degrades to "poll briskly" instead of "poll never".
16
+ */
17
+ exports.MIN_UPDATE_CHECK_INTERVAL_SECONDS = 15 * 60;
18
+ /** Ceiling: 7 days. Anything longer is indistinguishable from disabled. */
19
+ exports.MAX_UPDATE_CHECK_INTERVAL_SECONDS = 7 * 24 * 60 * 60;
20
+ /** Grace after boot before the first sweep, so registry I/O never races boot. */
21
+ const DEFAULT_INITIAL_DELAY_MS = 90_000;
22
+ /** Clamp an operator-supplied interval into the supported window. */
23
+ function clampUpdateCheckInterval(seconds) {
24
+ if (!Number.isFinite(seconds))
25
+ return exports.DEFAULT_UPDATE_CHECK_INTERVAL_SECONDS;
26
+ const whole = Math.floor(seconds);
27
+ if (whole < exports.MIN_UPDATE_CHECK_INTERVAL_SECONDS)
28
+ return exports.MIN_UPDATE_CHECK_INTERVAL_SECONDS;
29
+ if (whole > exports.MAX_UPDATE_CHECK_INTERVAL_SECONDS)
30
+ return exports.MAX_UPDATE_CHECK_INTERVAL_SECONDS;
31
+ return whole;
32
+ }
33
+ class UpdateCheckScheduler {
34
+ logger;
35
+ targets;
36
+ getIntervalSeconds;
37
+ initialDelayMs;
38
+ timer = null;
39
+ bootTimer = null;
40
+ intervalSeconds = exports.DEFAULT_UPDATE_CHECK_INTERVAL_SECONDS;
41
+ sweeping = false;
42
+ constructor(options) {
43
+ this.logger = options.logger;
44
+ this.targets = options.targets;
45
+ this.getIntervalSeconds = options.getIntervalSeconds;
46
+ this.initialDelayMs = options.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS;
47
+ }
48
+ /** Interval currently armed, after clamping. Exposed for assertions + logs. */
49
+ currentIntervalSeconds() {
50
+ return this.intervalSeconds;
51
+ }
52
+ /** Arm the poller. Safe to call twice — the previous timers are replaced. */
53
+ start() {
54
+ this.arm();
55
+ if (this.bootTimer !== null)
56
+ clearTimeout(this.bootTimer);
57
+ this.bootTimer = setTimeout(() => {
58
+ this.bootTimer = null;
59
+ void this.runSweep();
60
+ }, this.initialDelayMs);
61
+ this.bootTimer.unref?.();
62
+ }
63
+ /** Re-read the interval from settings and re-arm if it moved. */
64
+ reschedule() {
65
+ const next = clampUpdateCheckInterval(this.getIntervalSeconds());
66
+ if (this.timer !== null && next === this.intervalSeconds)
67
+ return;
68
+ this.arm();
69
+ }
70
+ stop() {
71
+ if (this.timer !== null)
72
+ clearInterval(this.timer);
73
+ if (this.bootTimer !== null)
74
+ clearTimeout(this.bootTimer);
75
+ this.timer = null;
76
+ this.bootTimer = null;
77
+ }
78
+ /**
79
+ * One full sweep. Never throws and never runs concurrently with itself — an
80
+ * overlapping tick (slow registry, long agent fan-out) is dropped WITH a log
81
+ * rather than doubling the npm traffic.
82
+ */
83
+ async runSweep() {
84
+ if (this.sweeping) {
85
+ this.logger.warn('Update check skipped — previous sweep still running');
86
+ return;
87
+ }
88
+ this.sweeping = true;
89
+ try {
90
+ const nodes = this.targets.listNodes();
91
+ await this.attempt('hub addon packages', 'hub', () => this.targets.checkHubAddons());
92
+ await this.attempt('framework packages', 'hub', () => this.targets.checkFrameworkPackages());
93
+ for (const node of nodes) {
94
+ if (!node.isOnline) {
95
+ // D58: never dispatch at a node the registry already called offline —
96
+ // and a dropped branch is never silent.
97
+ this.logger.warn('Update check skipped — node offline', { tags: { nodeId: node.id } });
98
+ continue;
99
+ }
100
+ await this.attempt('node server package', node.id, () => this.targets.checkNodeServerUpdate(node.id, node.isHub));
101
+ if (node.isHub)
102
+ continue;
103
+ await this.attempt('agent addon packages', node.id, () => this.targets.checkAgentAddons(node.id));
104
+ }
105
+ }
106
+ finally {
107
+ this.sweeping = false;
108
+ }
109
+ }
110
+ arm() {
111
+ if (this.timer !== null)
112
+ clearInterval(this.timer);
113
+ this.intervalSeconds = clampUpdateCheckInterval(this.getIntervalSeconds());
114
+ this.logger.info('Update check scheduled', {
115
+ meta: { intervalSeconds: this.intervalSeconds },
116
+ });
117
+ this.timer = setInterval(() => {
118
+ void this.runSweep();
119
+ }, this.intervalSeconds * 1_000);
120
+ this.timer.unref?.();
121
+ }
122
+ async attempt(what, nodeId, run) {
123
+ try {
124
+ await run();
125
+ }
126
+ catch (error) {
127
+ // Logged, not published: an empty snapshot would read as "up to date"
128
+ // and destroy the dedup state the operator's notifications rely on.
129
+ this.logger.warn(`Update check failed — ${what}`, {
130
+ tags: { nodeId },
131
+ meta: { error: (0, types_1.errMsg)(error) },
132
+ });
133
+ }
134
+ }
135
+ }
136
+ exports.UpdateCheckScheduler = UpdateCheckScheduler;
@@ -77,6 +77,9 @@ const server_update_service_1 = require("./core/server-update/server-update.serv
77
77
  const storage_service_1 = require("./core/storage/storage.service");
78
78
  const stream_probe_service_1 = require("./core/streaming/stream-probe.service");
79
79
  const topology_emitter_service_1 = require("./core/topology/topology-emitter.service");
80
+ const update_availability_store_js_1 = require("./core/update-availability-store.js");
81
+ const agent_installed_packages_js_1 = require("./core/updates/agent-installed-packages.js");
82
+ const update_check_scheduler_js_1 = require("./core/updates/update-check-scheduler.js");
80
83
  // ---------------------------------------------------------------------------
81
84
  // Service container — narrowing via `instanceof`, no casts.
82
85
  // ---------------------------------------------------------------------------
@@ -157,7 +160,10 @@ async function bootManual(opts) {
157
160
  // AddonWidgetsService — needs AddonRegistryService for the bundled-addon
158
161
  // dist sub-folder lookup (see service docstring).
159
162
  const addonWidgetsService = new addon_widgets_service_1.AddonWidgetsService(loggingService, capabilityService, addonRegistryService);
160
- const addonPackageService = new addon_package_service_1.AddonPackageService(loggingService, eventBusService, configService, addonRegistryService, notificationWrapper, toastWrapper);
163
+ // Durable `update.available` dedup lives next to `auto-update.json`, so a
164
+ // hub restart never re-announces what the operator already saw.
165
+ const availabilityDataDir = path.resolve(process.env['CAMSTACK_DATA'] ?? 'camstack-data');
166
+ const addonPackageService = new addon_package_service_1.AddonPackageService(loggingService, eventBusService, configService, addonRegistryService, notificationWrapper, toastWrapper, new update_availability_store_js_1.FileUpdateAvailabilityStore(availabilityDataDir, 'addon-packages', loggingService.createLogger('UpdateAvailability')));
161
167
  // Addon back-fill delivery seam. Wired here (not in the AgentRegistryService
162
168
  // constructor) because it needs AddonPackageService, which is a later layer.
163
169
  // Bytes come from THE HUB'S OWN INSTALLED COPY whenever it has one, and only
@@ -234,9 +240,45 @@ async function bootManual(opts) {
234
240
  logger: loggingService.createLogger('ServerUpdate'),
235
241
  restartServer: (requestedBy) => addonPackageService.restartServer(requestedBy),
236
242
  eventBus: eventBusService,
243
+ updateAvailabilityStore: new update_availability_store_js_1.FileUpdateAvailabilityStore(availabilityDataDir, 'server-update', loggingService.createLogger('UpdateAvailability')),
237
244
  });
238
245
  const topologyEmitterService = new topology_emitter_service_1.TopologyEmitterService(eventBusService, agentRegistryService, addonRegistryService, (0, cap_providers_1.createNodeRootPackageLookup)(moleculerService, serverUpdateService));
239
246
  const postBootService = new post_boot_service_1.PostBootService(addonRegistryService, eventBusService, loggingService);
247
+ // ---- Update availability poller ---------------------------------------
248
+ // "Tell me, don't install." Deliberately NOT gated on the auto-update
249
+ // channel — that gate is why the only periodic publisher was dead on every
250
+ // live hub (`{"channel":"off"}`). See UpdateCheckScheduler.
251
+ const updateCheckLogger = loggingService.createLogger('UpdateCheck');
252
+ const updateCheckScheduler = new update_check_scheduler_js_1.UpdateCheckScheduler({
253
+ logger: updateCheckLogger,
254
+ getIntervalSeconds: () => addonPackageService.getAutoUpdateSettings().updateCheckIntervalSeconds,
255
+ targets: {
256
+ listNodes: () => agentRegistryService.listNodeLiveness(),
257
+ checkHubAddons: () => addonPackageService.checkUpdates(true),
258
+ checkFrameworkPackages: () => addonPackageService.listFrameworkPackages(),
259
+ checkAgentAddons: async (nodeId) => {
260
+ const broker = moleculerService.broker;
261
+ const installed = await (0, agent_installed_packages_js_1.fetchAgentInstalledPackages)(broker, nodeId);
262
+ return addonPackageService.checkUpdatesForInstalled(installed, nodeId);
263
+ },
264
+ checkNodeServerUpdate: async (nodeId, isHub) => {
265
+ if (isHub)
266
+ return serverUpdateService.checkServerUpdate();
267
+ const proxy = moleculerService.createCapabilityProxy('server-management', nodeId);
268
+ if (proxy === null) {
269
+ // A node with no reachable `server-management` cannot be checked —
270
+ // say so rather than counting it as up to date.
271
+ updateCheckLogger.warn('Update check skipped — server-management unreachable', {
272
+ tags: { nodeId },
273
+ });
274
+ return undefined;
275
+ }
276
+ return proxy['checkServerUpdate']?.({});
277
+ },
278
+ },
279
+ });
280
+ addonPackageService.setUpdateCheckRescheduler(() => updateCheckScheduler.reschedule());
281
+ updateCheckScheduler.start();
240
282
  // ---- Container ---------------------------------------------------------
241
283
  const container = new ServiceContainer();
242
284
  container.register(config_service_1.ConfigService, configService);
@@ -262,6 +304,7 @@ async function bootManual(opts) {
262
304
  container.register(server_update_service_1.ServerUpdateService, serverUpdateService);
263
305
  container.register(topology_emitter_service_1.TopologyEmitterService, topologyEmitterService);
264
306
  container.register(post_boot_service_1.PostBootService, postBootService);
307
+ container.register(update_check_scheduler_js_1.UpdateCheckScheduler, updateCheckScheduler);
265
308
  // ---- Fastify instance --------------------------------------------------
266
309
  const fastify = (0, fastify_1.default)(fastifyOpts);
267
310
  // /health and /health/* — registered in `main.ts` via
@@ -278,6 +321,12 @@ async function bootManual(opts) {
278
321
  const logErr = (label, err) => {
279
322
  console.error(`[manual-boot] ${label} destroy failed:`, err);
280
323
  };
324
+ try {
325
+ updateCheckScheduler.stop();
326
+ }
327
+ catch (err) {
328
+ logErr('UpdateCheckScheduler', err);
329
+ }
281
330
  try {
282
331
  topologyEmitterService.onModuleDestroy();
283
332
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.127",
3
+ "version": "1.2.128",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist",
@@ -33,18 +33,18 @@
33
33
  ]
34
34
  },
35
35
  "dependencies": {
36
- "@camstack/addon-admin-ui": "1.2.76",
36
+ "@camstack/addon-admin-ui": "1.2.77",
37
37
  "@camstack/addon-agent-ui": "1.2.20",
38
38
  "@camstack/addon-auth": "1.2.22",
39
39
  "@camstack/addon-decoder-nodeav": "1.2.19",
40
40
  "@camstack/addon-notifiers": "1.2.24",
41
41
  "@camstack/addon-pipeline": "1.2.93",
42
- "@camstack/addon-pipeline-orchestrator": "1.2.75",
43
- "@camstack/addon-post-analysis": "1.2.90",
42
+ "@camstack/addon-pipeline-orchestrator": "1.2.76",
43
+ "@camstack/addon-post-analysis": "1.2.91",
44
44
  "@camstack/sdk": "1.2.22",
45
45
  "@camstack/shm-ring": "1.1.19",
46
46
  "@camstack/system": "1.2.102",
47
- "@camstack/types": "1.2.84",
47
+ "@camstack/types": "1.2.85",
48
48
  "@camstack/ui-library": "1.2.58",
49
49
  "@fastify/compress": "^9.0.0",
50
50
  "@fastify/cookie": "^11.0.2",