@camstack/server 1.2.127 → 1.2.129

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,21 +1190,73 @@ 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
  });
1183
1197
  }
1184
- const hasUpdate = latestVersion !== null && currentVersion !== 'unknown' && latestVersion !== currentVersion;
1198
+ // STRICTLY newer, never merely different. The hub runs
1199
+ // `1.2.x-dev.<timestamp>` builds that are AHEAD of the npm tag, and
1200
+ // `latest !== current` lit an "update available" badge whose only
1201
+ // possible outcome was a downgrade. `publishFrameworkAvailability`
1202
+ // already gated on this; the row the UI reads did not, so the same
1203
+ // page disagreed with itself.
1204
+ const hasUpdate = latestVersion !== null &&
1205
+ currentVersion !== 'unknown' &&
1206
+ isVersionNewer(latestVersion, currentVersion);
1185
1207
  return {
1186
1208
  packageName,
1187
1209
  currentVersion,
1188
1210
  latestVersion,
1189
1211
  hasUpdate,
1190
1212
  buildId,
1213
+ lookupFailed,
1191
1214
  ...(description !== undefined ? { description } : {}),
1192
1215
  };
1193
1216
  }));
1194
- return rows;
1217
+ this.publishFrameworkAvailability(rows);
1218
+ return rows.map(({ lookupFailed: _lookupFailed, ...row }) => row);
1219
+ }
1220
+ /**
1221
+ * Announce framework updates. This surface SHOWED `@camstack/system` had an
1222
+ * update and emitted nothing, so the operator was never told — it was the
1223
+ * only discovery path with no publish at all.
1224
+ *
1225
+ * Two rules it must not break:
1226
+ * - **Strictly newer only.** `hasUpdate` is `latest !== current`, which is
1227
+ * true for a hub running a `-dev.<timestamp>` build AHEAD of the npm tag.
1228
+ * Announcing that would be telling the operator to downgrade.
1229
+ * - **A failed lookup publishes nothing.** Not even an empty snapshot: an
1230
+ * empty snapshot means "up to date" and would clear the dedup state, so
1231
+ * the next successful poll would re-announce everything.
1232
+ */
1233
+ publishFrameworkAvailability(rows) {
1234
+ const failed = rows.filter((row) => row.lookupFailed);
1235
+ if (failed.length > 0) {
1236
+ this.logger.warn('Framework update check incomplete — availability not published', {
1237
+ meta: { packages: failed.map((row) => row.packageName) },
1238
+ });
1239
+ return;
1240
+ }
1241
+ const candidates = [];
1242
+ for (const row of rows) {
1243
+ if (row.latestVersion === null || row.currentVersion === 'unknown')
1244
+ continue;
1245
+ if (!isVersionNewer(row.latestVersion, row.currentVersion))
1246
+ continue;
1247
+ candidates.push({
1248
+ target: 'server',
1249
+ packageName: row.packageName,
1250
+ currentVersion: row.currentVersion,
1251
+ latestVersion: row.latestVersion,
1252
+ nodeId: this.resolveNodeId(),
1253
+ });
1254
+ }
1255
+ this.updateAvailability.publishSnapshot('server', candidates, this.resolveNodeId());
1256
+ }
1257
+ /** Node id stamped on availability events emitted by THIS process. */
1258
+ resolveNodeId() {
1259
+ return process.env['CAMSTACK_NODE_ID'] ?? 'hub';
1195
1260
  }
1196
1261
  // =========================================================================
1197
1262
  // Reload
@@ -1224,21 +1289,39 @@ class AddonPackageService {
1224
1289
  // =========================================================================
1225
1290
  // Auto-update settings
1226
1291
  // =========================================================================
1292
+ /**
1293
+ * Register the availability poller's re-arm hook. Called once at boot so a
1294
+ * `setAutoUpdateSettings` write takes effect without a restart.
1295
+ */
1296
+ setUpdateCheckRescheduler(reschedule) {
1297
+ this.updateCheckRescheduler = reschedule;
1298
+ }
1227
1299
  /** Get global auto-update settings */
1228
1300
  getAutoUpdateSettings() {
1229
1301
  return { ...this.autoUpdateConfig.global };
1230
1302
  }
1231
1303
  /** Set global auto-update settings and restart the timer */
1232
- async setAutoUpdateSettings(channel, intervalSeconds) {
1304
+ async setAutoUpdateSettings(channel, intervalSeconds, updateCheckIntervalSeconds) {
1305
+ const previousCheckInterval = this.autoUpdateConfig.global.updateCheckIntervalSeconds;
1306
+ const nextCheckInterval = updateCheckIntervalSeconds === undefined
1307
+ ? previousCheckInterval
1308
+ : (0, update_check_scheduler_js_1.clampUpdateCheckInterval)(updateCheckIntervalSeconds);
1233
1309
  this.autoUpdateConfig = {
1234
1310
  ...this.autoUpdateConfig,
1235
1311
  global: {
1236
1312
  channel,
1237
1313
  intervalSeconds: intervalSeconds ?? this.autoUpdateConfig.global.intervalSeconds,
1314
+ updateCheckIntervalSeconds: nextCheckInterval,
1238
1315
  },
1239
1316
  };
1240
1317
  this.saveAutoUpdateConfig();
1241
1318
  this.scheduleAutoUpdate();
1319
+ if (updateCheckIntervalSeconds === undefined)
1320
+ return;
1321
+ this.logger.info('Update-check interval changed', {
1322
+ meta: { from: previousCheckInterval, to: nextCheckInterval },
1323
+ });
1324
+ this.updateCheckRescheduler?.();
1242
1325
  }
1243
1326
  /** Get per-addon auto-update override */
1244
1327
  getAddonAutoUpdate(addonId) {
@@ -1395,10 +1478,14 @@ class AddonPackageService {
1395
1478
  const global = asRecord(raw['global']);
1396
1479
  const channel = asString(global['channel']);
1397
1480
  const validChannel = channel === 'latest' || channel === 'beta' ? channel : 'off';
1481
+ const rawCheckInterval = global['updateCheckIntervalSeconds'];
1398
1482
  return {
1399
1483
  global: {
1400
1484
  channel: validChannel,
1401
1485
  intervalSeconds: typeof global['intervalSeconds'] === 'number' ? global['intervalSeconds'] : 3600,
1486
+ updateCheckIntervalSeconds: typeof rawCheckInterval === 'number'
1487
+ ? (0, update_check_scheduler_js_1.clampUpdateCheckInterval)(rawCheckInterval)
1488
+ : update_check_scheduler_js_1.DEFAULT_UPDATE_CHECK_INTERVAL_SECONDS,
1402
1489
  },
1403
1490
  overrides: Object.fromEntries(Object.entries(asRecord(raw['overrides'])).map(([k, v]) => {
1404
1491
  const s = asString(v);
@@ -1414,7 +1501,14 @@ class AddonPackageService {
1414
1501
  meta: { error: (0, types_1.errMsg)(err) },
1415
1502
  });
1416
1503
  }
1417
- return { global: { channel: 'off', intervalSeconds: 21600 }, overrides: {} };
1504
+ return {
1505
+ global: {
1506
+ channel: 'off',
1507
+ intervalSeconds: 21600,
1508
+ updateCheckIntervalSeconds: update_check_scheduler_js_1.DEFAULT_UPDATE_CHECK_INTERVAL_SECONDS,
1509
+ },
1510
+ overrides: {},
1511
+ };
1418
1512
  }
1419
1513
  /** Save auto-update config to disk */
1420
1514
  saveAutoUpdateConfig() {
@@ -1440,12 +1534,17 @@ class AddonPackageService {
1440
1534
  /**
1441
1535
  * Check addon packages for updates by reading installed versions from
1442
1536
  * data/addons/{name}/package.json and comparing against npm registry.
1537
+ *
1538
+ * Reports `failures` alongside the diff: a registry lookup that could not be
1539
+ * MADE is not evidence that a package is up to date, and the availability
1540
+ * publish branches on it.
1443
1541
  */
1444
1542
  async checkAddonPackageUpdates() {
1445
1543
  const addonsDir = this.resolveAddonsDir();
1446
1544
  const updates = [];
1545
+ let failures = 0;
1447
1546
  if (!fs.existsSync(addonsDir))
1448
- return updates;
1547
+ return { updates, failures };
1449
1548
  // Collect all package.json paths -- handles both flat and scoped layouts
1450
1549
  const pkgJsonPaths = [];
1451
1550
  const topDirs = fs
@@ -1491,7 +1590,16 @@ class AddonPackageService {
1491
1590
  if (source === 'workspace')
1492
1591
  continue;
1493
1592
  }
1494
- const latestVersion = await this.fetchLatestVersion(name);
1593
+ const lookup = await this.lookupLatestVersion(name);
1594
+ if (!lookup.ok) {
1595
+ failures += 1;
1596
+ this.logger.warn('Registry lookup failed for installed addon package', {
1597
+ tags: { nodeId: this.resolveNodeId() },
1598
+ meta: { name, error: lookup.error },
1599
+ });
1600
+ continue;
1601
+ }
1602
+ const latestVersion = lookup.latestVersion;
1495
1603
  if (!latestVersion)
1496
1604
  continue;
1497
1605
  if (isVersionNewer(latestVersion, version)) {
@@ -1505,13 +1613,16 @@ class AddonPackageService {
1505
1613
  }
1506
1614
  }
1507
1615
  catch (error) {
1616
+ // The package could not be evaluated at all (unreadable manifest, …).
1617
+ // Counted as a failure for the same reason a registry miss is.
1618
+ failures += 1;
1508
1619
  const msg = (0, types_1.errMsg)(error);
1509
- this.logger.debug('Failed to check updates for addon', {
1620
+ this.logger.warn('Failed to check updates for addon', {
1510
1621
  meta: { pkgJsonPath, error: msg },
1511
1622
  });
1512
1623
  }
1513
1624
  }
1514
- return updates;
1625
+ return { updates, failures };
1515
1626
  }
1516
1627
  // =========================================================================
1517
1628
  // Private: npm registry helpers
@@ -1522,7 +1633,7 @@ class AddonPackageService {
1522
1633
  * Honours `CAMSTACK_NPM_REGISTRY` so update checks resolve against
1523
1634
  * the same registry the installer/pack paths use. Without this, a
1524
1635
  * per-node `listUpdates` (which diffs an agent's roster via
1525
- * `checkUpdatesForInstalled` → `fetchLatestVersion`) would bypass a
1636
+ * `checkUpdatesForInstalled` → `lookupLatestVersion`) would bypass a
1526
1637
  * private registry — including the e2e harness's verdaccio — and
1527
1638
  * silently report "no update" for packages that only exist there.
1528
1639
  * Trailing slashes are stripped so the `${base}/${name}` join is clean.
@@ -1532,29 +1643,61 @@ class AddonPackageService {
1532
1643
  const base = override && override.length > 0 ? override : AddonPackageService.NPM_REGISTRY;
1533
1644
  return base.replace(/\/+$/, '');
1534
1645
  }
1535
- /** Fetch the latest published version of a package from the npm registry */
1536
- async fetchLatestVersion(packageName) {
1646
+ /**
1647
+ * Ask the registry for a package's latest version, distinguishing "there is
1648
+ * no such package" (a definitive answer) from "I could not ask" (a failure).
1649
+ * The difference decides whether an availability SNAPSHOT may be published —
1650
+ * see {@link RegistryLookup}.
1651
+ */
1652
+ async lookupLatestVersion(packageName) {
1537
1653
  try {
1538
1654
  const encodedName = packageName.replace('/', '%2F');
1539
1655
  const url = `${this.resolveRegistryBase()}/${encodedName}/latest`;
1540
1656
  const response = await fetch(url, {
1541
1657
  signal: AbortSignal.timeout(AddonPackageService.REGISTRY_TIMEOUT_MS),
1542
1658
  });
1659
+ if (response.status === 404)
1660
+ return { ok: true, latestVersion: null };
1543
1661
  if (!response.ok) {
1544
1662
  this.logger.debug('Registry returned non-ok status', {
1545
1663
  meta: { packageName, status: response.status },
1546
1664
  });
1547
- return null;
1665
+ return { ok: false, error: `registry status ${response.status}` };
1548
1666
  }
1549
1667
  const data = await fetchJsonObject(response);
1550
1668
  const version = asString(data['version']);
1551
- return version || null;
1669
+ return { ok: true, latestVersion: version || null };
1552
1670
  }
1553
1671
  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;
1672
+ return { ok: false, error: (0, types_1.errMsg)(error) };
1673
+ }
1674
+ }
1675
+ /**
1676
+ * Announce the addon-update set for one scope (hub or a node).
1677
+ *
1678
+ * `failures > 0` means at least one registry lookup could not be made, so
1679
+ * the set is INCOMPLETE — publish it as candidates (additive) rather than a
1680
+ * snapshot (authoritative). A snapshot built from a partial sweep silently
1681
+ * marks the unreachable packages "up to date", clears their dedup state and
1682
+ * re-announces them on the next good poll.
1683
+ */
1684
+ publishAddonAvailability(updates, failures, nodeId) {
1685
+ const candidates = updates.map((update) => ({
1686
+ target: 'addon',
1687
+ packageName: update.name,
1688
+ currentVersion: update.currentVersion,
1689
+ latestVersion: update.latestVersion,
1690
+ ...(nodeId !== undefined ? { nodeId } : {}),
1691
+ }));
1692
+ if (failures > 0) {
1693
+ this.logger.warn('Addon update check incomplete — availability published as partial', {
1694
+ tags: { nodeId: nodeId ?? 'hub' },
1695
+ meta: { failures, published: candidates.length },
1696
+ });
1697
+ this.updateAvailability.publishCandidates(candidates);
1698
+ return;
1557
1699
  }
1700
+ this.updateAvailability.publishSnapshot('addon', candidates, nodeId);
1558
1701
  }
1559
1702
  /** Fetch npm search results for camstack addon packages (cached 5 min) */
1560
1703
  async fetchSearchFromNpm() {
@@ -28,11 +28,14 @@ class LoggingService extends system_1.LogManager {
28
28
  // keep their sparse history. `eventBus.ringBufferSize` still sizes the
29
29
  // separate system-event ring; logs get their own per-addon cap.
30
30
  const perAddonCapacity = configService.get('eventBus.perAddonLogBufferSize') ?? 5000;
31
- // Soft total ceiling across all buckets, null (unbounded) by default so this
32
- // changes nothing until an operator opts in. `?? null` rather than a numeric
33
- // default on purpose: the per-addon rings are already a hard bound, and picking
34
- // a total for someone would silently start discarding their debug history.
35
- const maxTotalEntries = configService.get('eventBus.maxTotalLogBufferSize') ?? null;
31
+ // Total ceiling across all buckets. Left UNSET here on purpose so the buffer
32
+ // applies its own default (`DEFAULT_MAX_TOTAL_LOG_ENTRIES`): the per-addon
33
+ // rings are a hard bound only per bucket, and their product grows with the
34
+ // roster hub-main ingests from every local runner AND every agent, so
35
+ // "unbounded by default" meant ~300k retained entries (~92MB) on the one
36
+ // process that must fit inside the cgroup. An operator who genuinely wants
37
+ // no aggregate bound writes an explicit `null`, which is preserved.
38
+ const maxTotalEntries = configService.get('eventBus.maxTotalLogBufferSize');
36
39
  // Only entries at or below this level are ever discarded to meet the total.
37
40
  const pruneLevel = configService.get('eventBus.logBufferPruneLevel') ?? 'debug';
38
41
  super(perAddonCapacity, { maxTotalEntries, pruneLevel });
@@ -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
  }
@@ -3,17 +3,38 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.StreamProbeService = void 0;
4
4
  const child_process_1 = require("child_process");
5
5
  const util_1 = require("util");
6
+ const ttl_cache_1 = require("./ttl-cache");
6
7
  const types_1 = require("@camstack/types");
7
8
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
8
9
  const CACHE_TTL_MS = 3_600_000; // 1 hour
9
10
  const PROBE_TIMEOUT_MS = 5_000;
11
+ /**
12
+ * Ceiling on cached probes.
13
+ *
14
+ * The cache is keyed by stream URL, and a URL is not a bounded quantity: every
15
+ * credential rotation, every edited field, every per-camera probe of a value
16
+ * that was later changed mints a new key. 512 is far above any real camera
17
+ * roster (the live cluster runs 27), so a working deployment never evicts;
18
+ * the ceiling exists so the key space cannot be walked into the OOM.
19
+ */
20
+ const CACHE_MAX_ENTRIES = 512;
10
21
  /** Codec aliases normalised to canonical names. */
11
22
  const CODEC_ALIASES = {
12
23
  hevc: 'h265',
13
24
  };
14
25
  class StreamProbeService {
15
26
  logger;
16
- cache = new Map();
27
+ /**
28
+ * Probe results, bounded by BOTH a swept TTL and an entry ceiling.
29
+ *
30
+ * It used to be a plain `Map` whose age was consulted only when the same URL
31
+ * was probed again — so an entry nobody asked about a second time was never
32
+ * found expired and never removed. See {@link TtlCache}.
33
+ */
34
+ cache = new ttl_cache_1.TtlCache({
35
+ ttlMs: CACHE_TTL_MS,
36
+ maxEntries: CACHE_MAX_ENTRIES,
37
+ });
17
38
  constructor(loggingService) {
18
39
  this.logger = loggingService.createLogger('StreamProbeService');
19
40
  }
@@ -25,12 +46,11 @@ class StreamProbeService {
25
46
  const force = options?.force ?? false;
26
47
  if (!force) {
27
48
  const cached = this.cache.get(url);
28
- if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
29
- return cached.metadata;
30
- }
49
+ if (cached)
50
+ return cached;
31
51
  }
32
52
  const metadata = await this.runProbe(url);
33
- this.cache.set(url, { metadata, timestamp: Date.now() });
53
+ this.cache.set(url, metadata);
34
54
  return metadata;
35
55
  }
36
56
  /**
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TtlCache = void 0;
4
+ class TtlCache {
5
+ entries = new Map();
6
+ ttlMs;
7
+ maxEntries;
8
+ now;
9
+ constructor(options) {
10
+ this.ttlMs = options.ttlMs;
11
+ this.maxEntries = options.maxEntries;
12
+ this.now = options.now ?? (() => Date.now());
13
+ }
14
+ /** The live value, or undefined when absent or expired. An expired entry is
15
+ * DELETED here, not merely reported missing. */
16
+ get(key) {
17
+ const slot = this.entries.get(key);
18
+ if (slot === undefined)
19
+ return undefined;
20
+ const at = this.now();
21
+ if (at - slot.storedAt >= this.ttlMs) {
22
+ this.entries.delete(key);
23
+ return undefined;
24
+ }
25
+ slot.lastAccessAt = at;
26
+ return slot.value;
27
+ }
28
+ /**
29
+ * Store a value, then bring the map back inside both bounds.
30
+ *
31
+ * The sweep runs on WRITE and not on a timer on purpose: a timer would have
32
+ * to be owned, unref'd and stopped by every holder of a cache, and a cache
33
+ * that is never written to is a cache that is not growing.
34
+ */
35
+ set(key, value) {
36
+ const at = this.now();
37
+ this.entries.set(key, { value, storedAt: at, lastAccessAt: at });
38
+ this.sweepExpired(at);
39
+ this.enforceMaxEntries(key);
40
+ }
41
+ delete(key) {
42
+ this.entries.delete(key);
43
+ }
44
+ clear() {
45
+ this.entries.clear();
46
+ }
47
+ /** Entries currently retained. The number that used to only go up. */
48
+ size() {
49
+ return this.entries.size;
50
+ }
51
+ sweepExpired(at) {
52
+ for (const [key, slot] of this.entries) {
53
+ if (at - slot.storedAt >= this.ttlMs)
54
+ this.entries.delete(key);
55
+ }
56
+ }
57
+ /** Evict least-recently-USED first. `protectedKey` is the entry just written —
58
+ * evicting it would make `set` a no-op. */
59
+ enforceMaxEntries(protectedKey) {
60
+ if (this.entries.size <= this.maxEntries)
61
+ return;
62
+ const coldestFirst = [...this.entries.entries()]
63
+ .filter(([key]) => key !== protectedKey)
64
+ .toSorted((a, b) => a[1].lastAccessAt - b[1].lastAccessAt);
65
+ for (const [key] of coldestFirst) {
66
+ if (this.entries.size <= this.maxEntries)
67
+ return;
68
+ this.entries.delete(key);
69
+ }
70
+ }
71
+ }
72
+ exports.TtlCache = TtlCache;
@@ -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.129",
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
- "@camstack/addon-pipeline": "1.2.93",
42
- "@camstack/addon-pipeline-orchestrator": "1.2.75",
43
- "@camstack/addon-post-analysis": "1.2.90",
41
+ "@camstack/addon-pipeline": "1.2.94",
42
+ "@camstack/addon-pipeline-orchestrator": "1.2.77",
43
+ "@camstack/addon-post-analysis": "1.2.92",
44
44
  "@camstack/sdk": "1.2.22",
45
45
  "@camstack/shm-ring": "1.1.19",
46
- "@camstack/system": "1.2.102",
47
- "@camstack/types": "1.2.84",
46
+ "@camstack/system": "1.2.103",
47
+ "@camstack/types": "1.2.86",
48
48
  "@camstack/ui-library": "1.2.58",
49
49
  "@fastify/compress": "^9.0.0",
50
50
  "@fastify/cookie": "^11.0.2",