@camstack/server 1.2.13 → 1.2.15

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.
@@ -316,22 +316,24 @@ class AddonPackageService {
316
316
  // version change itself, not the direction).
317
317
  this.addonRegistry.refreshPackageVersion(name, rolledBackTo);
318
318
  this.addonRegistry.emitUpdateEvent(name, previousVersion, rolledBackTo);
319
- const addonId = this.extractAddonId(name);
320
- if (addonId) {
321
- try {
322
- await this.addonRegistry.restartAddon(addonId);
323
- this.logger.info('Addon restarted after rollback', {
324
- tags: { addonId },
325
- meta: { rolledBackTo },
319
+ try {
320
+ const restart = await this.addonRegistry.restartPackage(name);
321
+ if (restart.success) {
322
+ this.logger.info('Addon(s) restarted after rollback', {
323
+ meta: { name, rolledBackTo, restarted: restart.restarted },
326
324
  });
327
325
  }
328
- catch (reloadError) {
329
- this.logger.warn('Restart failed after rollback', {
330
- tags: { addonId },
331
- meta: { error: (0, types_1.errMsg)(reloadError) },
326
+ else {
327
+ this.logger.warn('Restart after rollback did not confirm', {
328
+ meta: { name, errors: restart.errors },
332
329
  });
333
330
  }
334
331
  }
332
+ catch (reloadError) {
333
+ this.logger.warn('Restart failed after rollback', {
334
+ meta: { name, error: (0, types_1.errMsg)(reloadError) },
335
+ });
336
+ }
335
337
  // Clear update cache so the UI reflects the new state.
336
338
  this.cachedUpdates = null;
337
339
  return { rolledBackTo };
@@ -780,6 +782,41 @@ class AddonPackageService {
780
782
  // =========================================================================
781
783
  // Update / restart
782
784
  // =========================================================================
785
+ /**
786
+ * The post-update health check: restart every addon the package declares
787
+ * and decide what happens to the pre-update backup.
788
+ *
789
+ * SUCCESS ⇒ the backup is removed and `lastBackupDir` cleared. This is the
790
+ * only place that knows the new version actually came up; deleting inside
791
+ * `applyUpdate` would destroy the rollback exactly when it is needed.
792
+ * FAILURE ⇒ the backup is retained so the operator can roll back.
793
+ *
794
+ * Never throws. `clearBackup` swallows its own IO failures, and any
795
+ * surprise from the restart path is logged rather than propagated: an
796
+ * undeleted backup is a disk-space problem, a throw after a successful
797
+ * update is an outage.
798
+ */
799
+ async confirmOrRetainBackup(name, backupDir) {
800
+ try {
801
+ const result = await this.addonRegistry.restartPackage(name);
802
+ if (result.success) {
803
+ this.logger.info('Addon(s) restarted after update', {
804
+ meta: { name, restarted: result.restarted },
805
+ });
806
+ if (backupDir != null)
807
+ await this.installer.clearBackup(name);
808
+ return;
809
+ }
810
+ this.logger.warn('Restart after update did not confirm — backup retained for rollback', {
811
+ meta: { name, backupDir, errors: result.errors },
812
+ });
813
+ }
814
+ catch (err) {
815
+ this.logger.warn('Post-update confirmation failed — backup retained for rollback', {
816
+ meta: { name, backupDir, error: (0, types_1.errMsg)(err) },
817
+ });
818
+ }
819
+ }
783
820
  /**
784
821
  * Update a specific package. For addon packages, triggers hot-reload.
785
822
  * For core packages, installs in the server root and returns requiresRestart: true.
@@ -835,25 +872,7 @@ class AddonPackageService {
835
872
  this.logger.info('Addon package updated, triggering hot-reload', {
836
873
  meta: { name, updatedVersion },
837
874
  });
838
- const addonId = this.extractAddonId(name);
839
- if (addonId) {
840
- try {
841
- await this.addonRegistry.restartAddon(addonId);
842
- this.logger.info('Addon restarted after update', { tags: { addonId } });
843
- // Restart succeeded — drop the backup. We keep it on failure
844
- // (caller can roll back manually via UI).
845
- if (result.backupDir != null) {
846
- addonInstaller.clearBackup(name);
847
- }
848
- }
849
- catch (reloadError) {
850
- const msg = (0, types_1.errMsg)(reloadError);
851
- this.logger.warn('Hot-reload failed for addon — backup retained for rollback', {
852
- tags: { addonId },
853
- meta: { error: msg, backupDir: result.backupDir },
854
- });
855
- }
856
- }
875
+ await this.confirmOrRetainBackup(name, result.backupDir);
857
876
  // Clear update cache so next check reflects new state
858
877
  this.cachedUpdates = null;
859
878
  this.sendUpdateNotification(name, updatedVersion);
@@ -927,22 +946,7 @@ class AddonPackageService {
927
946
  this.logger.info('Staged addon swap complete, triggering hot-reload', {
928
947
  meta: { name, updatedVersion },
929
948
  });
930
- const addonId = this.extractAddonId(name);
931
- if (addonId) {
932
- try {
933
- await this.addonRegistry.restartAddon(addonId);
934
- this.logger.info('Addon restarted after staged update', { tags: { addonId } });
935
- // Restart succeeded — drop the backup
936
- addonInstaller.clearBackup(name);
937
- }
938
- catch (reloadError) {
939
- const msg = (0, types_1.errMsg)(reloadError);
940
- this.logger.warn('Hot-reload failed for addon — backup retained for rollback', {
941
- tags: { addonId },
942
- meta: { error: msg, backupDir: r.backupDir },
943
- });
944
- }
945
- }
949
+ await this.confirmOrRetainBackup(name, r.backupDir);
946
950
  // Clear update cache so next check reflects new state
947
951
  this.cachedUpdates = null;
948
952
  this.sendUpdateNotification(name, updatedVersion);
@@ -1533,11 +1537,11 @@ class AddonPackageService {
1533
1537
  }
1534
1538
  return name.includes('/addon-') ? 'addon' : 'core';
1535
1539
  }
1536
- /** Extract addon ID from package name: '@camstack/addon-benchmark' -> 'benchmark' */
1537
- extractAddonId(packageName) {
1538
- const match = packageName.match(/@camstack\/addon-(.+)/);
1539
- return match?.[1] ?? null;
1540
- }
1540
+ // `extractAddonId` (package name single addon id) is GONE. It was wrong
1541
+ // for every multi-addon package — `@camstack/addon-pipeline` declares six
1542
+ // addons and none of them is `pipeline` — so the post-update restart it fed
1543
+ // silently targeted an addon that does not exist. Use
1544
+ // `addonRegistry.restartPackage(name)` / `listAddonIdsForPackage(name)`.
1541
1545
  /** Resolve the addons directory from config or fall back to default */
1542
1546
  resolveAddonsDir() {
1543
1547
  // Must match where the runtime LOADS addons (launcher.ts +
@@ -1382,6 +1382,64 @@ class AddonRegistryService {
1382
1382
  }
1383
1383
  }
1384
1384
  }
1385
+ /** Addon ids this node has loaded from `packageName`. */
1386
+ listAddonIdsForPackage(packageName) {
1387
+ const ids = [];
1388
+ for (const [id, entry] of this.addonEntries) {
1389
+ if (entry.packageName === packageName)
1390
+ ids.push(id);
1391
+ }
1392
+ return ids;
1393
+ }
1394
+ /**
1395
+ * Restart every addon a package declares, deduped by runner.
1396
+ *
1397
+ * A package is not an addon: `@camstack/addon-pipeline` declares six
1398
+ * (`pipeline-runner`, `detection-pipeline`, `motion-wasm`,
1399
+ * `audio-analyzer`, `stream-broker`, `recorder`), and NONE of them is
1400
+ * called `pipeline`. Deriving a single addon id from the package name
1401
+ * therefore restarts nothing for exactly the packages that matter most —
1402
+ * which is why the post-update confirmation had no idea whether the new
1403
+ * version actually came up.
1404
+ *
1405
+ * `restartAddon` respawns the WHOLE co-location runner (D2), so members
1406
+ * sharing a runner are collapsed into one restart.
1407
+ *
1408
+ * `success` is true only when every runner came back. The caller uses it
1409
+ * to decide whether to drop the rollback backup.
1410
+ */
1411
+ async restartPackage(packageName) {
1412
+ const ids = this.listAddonIdsForPackage(packageName);
1413
+ if (ids.length === 0) {
1414
+ return {
1415
+ success: false,
1416
+ restarted: [],
1417
+ errors: [`No loaded addon belongs to package "${packageName}"`],
1418
+ };
1419
+ }
1420
+ const restarted = [];
1421
+ const errors = [];
1422
+ const doneRunners = new Set();
1423
+ for (const addonId of ids) {
1424
+ const runnerId = this.childIdForAddon(addonId);
1425
+ if (doneRunners.has(runnerId)) {
1426
+ restarted.push(addonId);
1427
+ continue;
1428
+ }
1429
+ doneRunners.add(runnerId);
1430
+ try {
1431
+ const res = await this.restartAddon(addonId);
1432
+ if (res.success)
1433
+ restarted.push(addonId);
1434
+ else
1435
+ errors.push(res.error ?? `restart of "${addonId}" reported failure`);
1436
+ }
1437
+ catch (err) {
1438
+ errors.push(`restart of "${addonId}" threw: ${(0, types_1.errMsg)(err)}`);
1439
+ }
1440
+ }
1441
+ return { success: errors.length === 0, restarted, errors };
1442
+ }
1385
1443
  /**
1386
1444
  * Restart an addon by ID.
1387
1445
  * Shuts down, unregisters capabilities, re-initializes, and re-wires.
@@ -0,0 +1,168 @@
1
+ "use strict";
2
+ /**
3
+ * Node addon back-fill — converge a node to the addon roster THAT NODE last
4
+ * declared.
5
+ *
6
+ * WHY THIS EXISTS
7
+ * `AgentRegistryService.reconcileAgentAddons` used to be undeploy-only: it read
8
+ * the node's live roster, removed what no longer belonged, and had no branch for
9
+ * "installed on the hub but missing on the node". A node whose `<dataDir>/addons`
10
+ * had been wiped therefore came back with only its bootstrap packages
11
+ * (`@camstack/system` + `camstack.agentBootstrapAddons`) and the hub never sent
12
+ * the rest — measured live as 4 of 14 packages on `little-unraid`. Under the
13
+ * volume-only image (no addons baked in) an empty addons dir is the ORDINARY
14
+ * first-boot state of every new or rebuilt node, so that gap becomes the normal
15
+ * case rather than an anomaly.
16
+ *
17
+ * THE INTENDED SET
18
+ * A node's intended set is the roster the node itself last declared while
19
+ * online, remembered durably hub-side (`ClusterNodeHistoryStore`). It is not
20
+ * invented by the hub and it is not "every agent-deployable package the hub has
21
+ * installed" — that would over-install and would override deliberate per-node
22
+ * operator choices. The record GROWS when a node declares a package and SHRINKS
23
+ * only on an explicit undeploy (operator gesture or the stale-reconcile), so
24
+ * both mutations are attributable actions rather than heuristics.
25
+ *
26
+ * THE INTENDED VERSION
27
+ * The hub's own installed version wins, falling back to the version the node
28
+ * last reported. Never `latest` — resolving `latest` per node is exactly the
29
+ * silent per-node drift the bootstrap installer already suffers from
30
+ * (`addon-installer.ts` installs unpinned and `ensureRequiredPackages` never
31
+ * re-installs, so whatever `latest` meant at a node's first boot is what that
32
+ * node runs forever). A package with no resolvable version is reported, not
33
+ * guessed at.
34
+ *
35
+ * BOUNDING
36
+ * Delivery failures are counted per `(node, package, version)` and the pair is
37
+ * retired after {@link MAX_BACKFILL_ATTEMPTS}, exactly as `CrashSupervisor`
38
+ * bounds respawns (D6). A reconnect flap therefore cannot turn a broken package
39
+ * into an endless push loop. A different hub version resets the count — a new
40
+ * version is a new fact worth one more attempt.
41
+ *
42
+ * This module is pure/seam-driven on purpose: the decision of what a node should
43
+ * have is asserted in unit tests with no Moleculer, filesystem or npm registry.
44
+ */
45
+ Object.defineProperty(exports, "__esModule", { value: true });
46
+ exports.BackfillAttemptTracker = exports.MAX_BACKFILL_ATTEMPTS = void 0;
47
+ exports.computeBackfillPlan = computeBackfillPlan;
48
+ exports.executeBackfill = executeBackfill;
49
+ /**
50
+ * Decide what a node is missing relative to its own last-declared roster.
51
+ * Pure — no I/O, no clock, no randomness.
52
+ */
53
+ function computeBackfillPlan(input) {
54
+ const liveNames = new Set(input.live.map((p) => p.name));
55
+ const targets = [];
56
+ const retired = [];
57
+ const unresolvable = [];
58
+ for (const entry of input.recorded) {
59
+ if (liveNames.has(entry.name))
60
+ continue;
61
+ if (input.bootstrap.has(entry.name))
62
+ continue;
63
+ if (input.undeployed.has(entry.name))
64
+ continue;
65
+ if (input.retired.has(entry.name)) {
66
+ retired.push(entry.name);
67
+ continue;
68
+ }
69
+ const version = input.hubVersions.get(entry.name) ?? entry.version;
70
+ if (version === null || version === undefined || version.length === 0) {
71
+ unresolvable.push(entry.name);
72
+ continue;
73
+ }
74
+ targets.push({ name: entry.name, version });
75
+ }
76
+ return { targets, retired, unresolvable };
77
+ }
78
+ /**
79
+ * How many consecutive delivery failures retire a `(node, package)` pair.
80
+ * Mirrors `CrashSupervisor.MAX_CRASHES_IN_WINDOW` in intent: bounded, terminal,
81
+ * loud — never an infinite retry loop.
82
+ */
83
+ exports.MAX_BACKFILL_ATTEMPTS = 3;
84
+ /**
85
+ * Per-`(node, package)` delivery circuit breaker. In-memory and process-scoped
86
+ * on purpose: it bounds retries within one hub lifetime, and a hub restart is an
87
+ * operator-visible event that legitimately re-opens the breaker (same shape as
88
+ * `CrashSupervisor`). It stores no intent — the durable record of what a node
89
+ * should have lives in `ClusterNodeHistoryStore`, not here.
90
+ */
91
+ class BackfillAttemptTracker {
92
+ failures = new Map();
93
+ forNode(nodeId) {
94
+ const existing = this.failures.get(nodeId);
95
+ if (existing)
96
+ return existing;
97
+ const created = new Map();
98
+ this.failures.set(nodeId, created);
99
+ return created;
100
+ }
101
+ /** Packages this node has exhausted its attempts on. */
102
+ retiredFor(nodeId) {
103
+ const node = this.failures.get(nodeId);
104
+ if (!node)
105
+ return new Set();
106
+ const retired = new Set();
107
+ for (const [name, state] of node) {
108
+ if (state.attempts >= exports.MAX_BACKFILL_ATTEMPTS)
109
+ retired.add(name);
110
+ }
111
+ return retired;
112
+ }
113
+ /** Every non-clean delivery record for a node — attempted, failing or retired. */
114
+ failuresFor(nodeId) {
115
+ const node = this.failures.get(nodeId);
116
+ if (!node)
117
+ return [];
118
+ return [...node].map(([name, state]) => ({
119
+ name,
120
+ version: state.version,
121
+ attempts: state.attempts,
122
+ retired: state.attempts >= exports.MAX_BACKFILL_ATTEMPTS,
123
+ lastError: state.lastError,
124
+ }));
125
+ }
126
+ recordFailure(nodeId, name, version, error = '') {
127
+ const node = this.forNode(nodeId);
128
+ const prior = node.get(name);
129
+ // A different hub version is a new fact — it earns a fresh set of attempts.
130
+ const attempts = prior && prior.version === version ? prior.attempts + 1 : 1;
131
+ node.set(name, { version, attempts, lastError: error });
132
+ return { attempts, retired: attempts >= exports.MAX_BACKFILL_ATTEMPTS };
133
+ }
134
+ recordSuccess(nodeId, name) {
135
+ this.failures.get(nodeId)?.delete(name);
136
+ }
137
+ /** Drop everything remembered about a node (e.g. "Forget node"). */
138
+ forget(nodeId) {
139
+ this.failures.delete(nodeId);
140
+ }
141
+ }
142
+ exports.BackfillAttemptTracker = BackfillAttemptTracker;
143
+ /**
144
+ * Deliver every planned package, then reload the node ONCE. Per-package failures
145
+ * are isolated and counted — one broken package must not stop the others, and
146
+ * must not retry forever.
147
+ */
148
+ async function executeBackfill(seam, nodeId, plan, tracker) {
149
+ const delivered = [];
150
+ const failed = [];
151
+ for (const target of plan.targets) {
152
+ try {
153
+ const bundle = await seam.pack(target.name, target.version);
154
+ await seam.deploy(nodeId, target.name, bundle);
155
+ tracker.recordSuccess(nodeId, target.name);
156
+ delivered.push(target);
157
+ }
158
+ catch (err) {
159
+ const error = err instanceof Error ? err.message : String(err);
160
+ const { retired } = tracker.recordFailure(nodeId, target.name, target.version, error);
161
+ failed.push({ name: target.name, version: target.version, error, retired });
162
+ }
163
+ }
164
+ if (delivered.length > 0) {
165
+ await seam.reload(nodeId);
166
+ }
167
+ return { delivered, failed, skipped: plan.retired, unresolvable: plan.unresolvable };
168
+ }