@camstack/server 1.1.62 → 1.1.64

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.
@@ -38,19 +38,21 @@ exports.shouldEmitProviderRegisteredReady = shouldEmitProviderRegisteredReady;
38
38
  /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access -- pre-existing lint debt across this 2200-line orchestration class. The flagged sites (StorageService.setLocationManager / setSettingsBackend, LoggingService.addDestination, RouteRegistry, etc.) are typed as `unknown` by their owning services to break circular construction-order dependencies; runtime contracts are validated structurally. Tracked separately; do not amend in unrelated edits. */
39
39
  const os = __importStar(require("node:os"));
40
40
  const addon_row_manifest_1 = require("./addon-row-manifest");
41
+ const runner_convergence_1 = require("./runner-convergence");
41
42
  const types_1 = require("@camstack/types");
42
43
  const system_1 = require("@camstack/system");
43
44
  const system_2 = require("@camstack/system");
44
45
  const client_1 = require("@trpc/client");
45
46
  const system_3 = require("@camstack/system");
46
47
  const system_4 = require("@camstack/system");
48
+ const system_5 = require("@camstack/system");
47
49
  const node_crypto_1 = require("node:crypto");
48
50
  const path = __importStar(require("node:path"));
49
51
  const fs = __importStar(require("node:fs"));
50
52
  const node_url_1 = require("node:url");
51
53
  const addon_settings_provider_js_1 = require("./addon-settings-provider.js");
52
54
  const addon_call_gateway_js_1 = require("./addon-call-gateway.js");
53
- const system_5 = require("@camstack/system");
55
+ const system_6 = require("@camstack/system");
54
56
  const types_2 = require("@camstack/types");
55
57
  /**
56
58
  * Type predicate: true when an `ISettingsBackend` also satisfies the
@@ -84,6 +86,15 @@ function isSettingsStore(backend) {
84
86
  function isAddonRoutesInvoker(provider) {
85
87
  return typeof Reflect.get(provider, 'invoke') === 'function';
86
88
  }
89
+ /**
90
+ * Window within which a second `restart:true` respawn of the SAME group runner
91
+ * is coalesced to a no-op (a package reload loops `restartAddon` over every
92
+ * group member — see `recentGroupRespawns`). Chosen comfortably longer than a
93
+ * single group respawn (child fork + init) so all members of one reload pass
94
+ * fall inside it, and short enough that a genuinely independent later restart
95
+ * still respawns.
96
+ */
97
+ const GROUP_RESPAWN_COALESCE_MS = 10_000;
87
98
  const ROUTE_METHODS = [
88
99
  'GET',
89
100
  'POST',
@@ -197,6 +208,17 @@ class AddonRegistryService {
197
208
  * `restartAddon` completes (success or failure) or by a 90s safety timer.
198
209
  */
199
210
  restartingAddons = new Map();
211
+ /**
212
+ * `runnerId → last-respawn epoch-ms`, used ONLY to coalesce redundant group
213
+ * respawns. A package reload loops `restartAddon` over EVERY addon in the
214
+ * package (`tryReloadPackage`); for a co-location group that maps N members
215
+ * onto ONE runner, the first member's restart already respawns the whole
216
+ * group (all members reboot from fresh on-disk code), so the 2nd..Nth
217
+ * restarts must NOT respawn again — that churns the runner N times and risks
218
+ * tripping the crash circuit-breaker. Consulted by `ensureForkedRunner`; only
219
+ * groups (roster > 1) are coalesced, so solo-addon restart stays byte-identical.
220
+ */
221
+ recentGroupRespawns = new Map();
200
222
  logger;
201
223
  addonLoader;
202
224
  healthMonitor;
@@ -355,7 +377,7 @@ class AddonRegistryService {
355
377
  // Register the addon-settings singleton provider — replaces the
356
378
  // former `$addonHost` Moleculer service. The provider resolves
357
379
  // addonId → local addon instance (hub) or remote Moleculer call.
358
- this.capabilityRegistry.declareCapability(system_5.addonSettingsCapability);
380
+ this.capabilityRegistry.declareCapability(system_6.addonSettingsCapability);
359
381
  // The SINGLE router for addon-level calls (routes / custom / settings).
360
382
  // It classifies in-process | hub-local-child (UDS) | remote-agent
361
383
  // (Moleculer) in ONE place — `resolveNode` reports only the BASE node
@@ -1096,17 +1118,22 @@ class AddonRegistryService {
1096
1118
  // `@camstack/system` builtins reach the in-process path below) it
1097
1119
  // boots in-process on the hub.
1098
1120
  if (this.isForkedAddonEntry(entry)) {
1099
- // D5: one-addon-one-process. A forked addon ALWAYS boots in its
1100
- // own runner — there is NO in-process-on-the-hub fallback. If the
1101
- // runner spawn fails, the addon is `failed`, surfaced as
1102
- // `addon.error` + recorded on the health monitor; it does not
1103
- // silently run on the hub. (Task 7's circuit breaker governs the
1104
- // retry policy on top of this.)
1121
+ // D5 + co-location: a forked addon ALWAYS boots in a runner — there is
1122
+ // NO in-process-on-the-hub fallback. `ensureForkedRunner` resolves the
1123
+ // addon's runner id (`resolveRunnerId`) and spawns/joins the FULL
1124
+ // co-located roster: a groupless addon gets its own runner (one addon,
1125
+ // one process unchanged), while a `execution.group` member co-locates
1126
+ // with its siblings so cross-addon cap calls resolve in-process (zero
1127
+ // copy). Idempotent when a sibling already brought the group up. If the
1128
+ // spawn fails, the addon is `failed`, surfaced as `addon.error` +
1129
+ // recorded on the health monitor; it does not silently run on the hub.
1130
+ // (Task 7's circuit breaker governs the retry policy on top of this.)
1131
+ //
1132
+ // Provider registration for forkable addons is delegated to the
1133
+ // `CapabilityBridge` (see `MoleculerService.onProviderConnected`);
1134
+ // custom actions are (re-)registered inside `ensureForkedRunner`.
1105
1135
  try {
1106
- await this.broker.call('$process.spawnRunner', {
1107
- runnerId: id,
1108
- addons: [{ addonId: id, addonDir: entry.addonDir }],
1109
- });
1136
+ await this.ensureForkedRunner(id, { restart: false });
1110
1137
  }
1111
1138
  catch (err) {
1112
1139
  const msg = (0, types_1.errMsg)(err);
@@ -1121,23 +1148,6 @@ class AddonRegistryService {
1121
1148
  this.healthMonitor.recordFailure(entry.packageName, err, id);
1122
1149
  throw new Error(`Failed to spawn runner for addon "${id}": ${msg}`, { cause: err });
1123
1150
  }
1124
- // Provider registration for forkable addons is delegated to the
1125
- // `CapabilityBridge` (see `MoleculerService.onProviderConnected`).
1126
- // When the spawned child announces its Moleculer service via
1127
- // NODE_INFO, the bridge builds a proxy from the capability
1128
- // definition (`{ id, nodeId, ...methods }`) and registers it.
1129
- //
1130
- // Custom actions: read the catalog fresh from the on-disk bundle
1131
- // and register it against the hub-side `CustomActionRegistry`.
1132
- // `registerForkedAddonCustomActions` re-`import()`s the entry
1133
- // (cache-busted), so it covers the hot-load path — where the addon
1134
- // was registered via `freshLoader` and `this.addonLoader`'s cached
1135
- // `module` namespace is stale or absent. Dispatch routes through
1136
- // `broker.call('<addonId>.custom.<action>')`, the only divergence
1137
- // vs in-process being the transport, exactly like cap methods.
1138
- await this.registerForkedAddonCustomActions(id,
1139
- // `isForkedAddonEntry` narrowed `entry.declaration` to non-null.
1140
- (0, types_1.resolveRunnerId)(entry.declaration, id));
1141
1151
  entry.initialized = true;
1142
1152
  this.logger.info('Addon spawned as isolated process', { tags: { addonId: id } });
1143
1153
  this.emitAddonLifecycleEvent('addon.started', id);
@@ -1357,23 +1367,40 @@ class AddonRegistryService {
1357
1367
  // restarts. The Moleculer `$node.disconnected` handler skips entries
1358
1368
  // present in `restartingAddons`; a 90s safety timer clears the flag
1359
1369
  // even if the restart path throws before the finally block runs.
1360
- const existingTimer = this.restartingAddons.get(addonId);
1361
- if (existingTimer)
1362
- clearTimeout(existingTimer);
1363
- const safetyTimer = setTimeout(() => {
1364
- this.restartingAddons.delete(addonId);
1365
- }, 90_000);
1366
- this.restartingAddons.set(addonId, safetyTimer);
1370
+ // Suppress the banner for the restarted addon AND — for a co-location
1371
+ // group — every SIBLING sharing its runner. `ensureForkedRunner` respawns
1372
+ // the WHOLE group process, so all members briefly `$node.disconnect`; without
1373
+ // suppressing the siblings too, each would flash a spurious "Failed to load".
1374
+ const suppressed = new Set([addonId]);
1375
+ if (this.isForkedAddonEntry(entry)) {
1376
+ const runnerId = (0, types_1.resolveRunnerId)(entry.declaration, addonId);
1377
+ for (const { addonId: siblingId } of this.buildAddonGroupPlan([
1378
+ ...this.addonEntries.keys(),
1379
+ ]).get(runnerId) ?? []) {
1380
+ suppressed.add(siblingId);
1381
+ }
1382
+ }
1383
+ for (const id of suppressed) {
1384
+ const prior = this.restartingAddons.get(id);
1385
+ if (prior)
1386
+ clearTimeout(prior);
1387
+ const timer = setTimeout(() => {
1388
+ this.restartingAddons.delete(id);
1389
+ }, 90_000);
1390
+ this.restartingAddons.set(id, timer);
1391
+ }
1367
1392
  try {
1368
- // Group-runner-hosted addon — delegate to $process.restart for the group
1393
+ // Group-runner-hosted addon — converge the runner topology onto the
1394
+ // declared group, then respawn it. `ensureForkedRunner({ restart: true })`
1395
+ // both (a) adopts a NEW roster if `execution.group` changed since the
1396
+ // runner last spawned (stop stale solo runners → spawn the group; a plain
1397
+ // `$process.restart` would respawn the OLD roster forever), and (b) on the
1398
+ // common path respawns the group in place so the child reboots the WHOLE
1399
+ // roster from the freshly-swapped on-disk bundle. Updating ONE member of a
1400
+ // group therefore respawns the ENTIRE group — they share a process.
1369
1401
  if (this.isForkedAddonEntry(entry)) {
1370
- const result = (await this.broker.call('$process.restart', {
1371
- name: addonId,
1372
- }));
1373
- if (!result.success) {
1374
- throw new Error(`Process restart failed: ${result.reason ?? 'unknown'}`);
1375
- }
1376
- // $process.restart resolves as soon as the child is respawned, not when its
1402
+ await this.ensureForkedRunner(addonId, { restart: true });
1403
+ // The respawn resolves as soon as the child is up, not when its
1377
1404
  // capabilities are re-registered. Callers (integrations.create, UI forms) may
1378
1405
  // immediately try to route to the provider and hit a transient null. Block here
1379
1406
  // until every declared capability is back on the registry so the restart is
@@ -1413,16 +1440,10 @@ class AddonRegistryService {
1413
1440
  `(re-register asynchronously — e.g. device-scoped caps awaiting devices): ${missing.join(', ')}`, { tags: { addonId } });
1414
1441
  }
1415
1442
  }
1416
- // Re-register the addon's custom-action catalog. `$process.restart`
1417
- // respawns the group child and `CapabilityBridge` re-registers cap
1418
- // providers but custom actions live in the hub-side
1419
- // `CustomActionRegistry`, which the restart path never touched.
1420
- // Without this, every hot-update of a group-hosted addon silently
1421
- // drops its custom actions (the catalog is only registered once,
1422
- // at boot, in `initializeAddonGroup`). Reads a fresh catalog from
1423
- // the just-updated on-disk bundle.
1424
- const runnerId = (0, types_1.resolveRunnerId)(entry.declaration, addonId);
1425
- await this.registerForkedAddonCustomActions(addonId, runnerId);
1443
+ // Custom actions are re-registered inside `ensureForkedRunner` for
1444
+ // every roster member (custom actions live in the hub-side
1445
+ // `CustomActionRegistry`, which the process spawn never touches a
1446
+ // hot-update would otherwise silently drop them).
1426
1447
  this.logAddonLifecycle('restarted', addonId, 'isolated');
1427
1448
  this.emitAddonLifecycleEvent('addon.restarted', addonId);
1428
1449
  return { success: true };
@@ -1483,13 +1504,16 @@ class AddonRegistryService {
1483
1504
  return { success: false, error: msg };
1484
1505
  }
1485
1506
  finally {
1486
- // Clear the suppression flag regardless of success/failure if the
1487
- // restart failed, the operator will see the real error via the
1488
- // mutation result rather than a misleading transient health blip.
1489
- const timer = this.restartingAddons.get(addonId);
1490
- if (timer)
1491
- clearTimeout(timer);
1492
- this.restartingAddons.delete(addonId);
1507
+ // Clear the suppression flags (restarted addon + any group siblings)
1508
+ // regardless of success/failure if the restart failed, the operator
1509
+ // sees the real error via the mutation result rather than a misleading
1510
+ // transient health blip.
1511
+ for (const id of suppressed) {
1512
+ const timer = this.restartingAddons.get(id);
1513
+ if (timer)
1514
+ clearTimeout(timer);
1515
+ this.restartingAddons.delete(id);
1516
+ }
1493
1517
  }
1494
1518
  }
1495
1519
  getAddon(id) {
@@ -2488,6 +2512,25 @@ class AddonRegistryService {
2488
2512
  capHandleCache.set(key, handle);
2489
2513
  return handle;
2490
2514
  }
2515
+ // In-process HTTP data-plane for co-located hub builtins (e.g. the snapshot
2516
+ // wrapper's per-device image endpoint). The facility binds a `127.0.0.1`
2517
+ // listener in the hub process on first `serve()`; the sink publishes its
2518
+ // endpoints straight into the hub's `DataPlaneRegistry` (bare `addonId`, the
2519
+ // same key the `/addon/:addonId/*` reverse-proxy matches), so a builtin
2520
+ // serves media exactly like a forked addon — no per-builtin proxy plumbing.
2521
+ // `mountAddonDataPlanes` handles only FORKED addons; this covers the
2522
+ // co-located path it defers. Disposed via the addon's disposer chain.
2523
+ const dataPlaneSink = {
2524
+ set: (id, endpoints) => {
2525
+ registry.dataPlaneRegistry?.registerAddon(id, endpoints);
2526
+ },
2527
+ };
2528
+ const dataPlaneFacility = (0, system_5.createAddonDataPlaneFacility)({
2529
+ addonId,
2530
+ logger,
2531
+ sink: dataPlaneSink,
2532
+ });
2533
+ this.getOrCreateDisposerChain(addonId).add(() => dataPlaneFacility.dispose());
2491
2534
  const ctx = {
2492
2535
  id: `addon:${addonId}`,
2493
2536
  logger,
@@ -2495,6 +2538,7 @@ class AddonRegistryService {
2495
2538
  addonConfig: bootstrapConfig,
2496
2539
  dataDir,
2497
2540
  nodeDataDir,
2541
+ dataPlane: dataPlaneFacility.dataPlane,
2498
2542
  get api() {
2499
2543
  return registry.getBrokerApi();
2500
2544
  },
@@ -2705,6 +2749,107 @@ class AddonRegistryService {
2705
2749
  meta: { runnerId, addonCount: addons.length, addonIds: addons.map((a) => a.addonId) },
2706
2750
  });
2707
2751
  }
2752
+ /**
2753
+ * Snapshot of the live runner subprocesses (`$process.list`), reduced to the
2754
+ * `{ runnerId, addonIds }` shape the convergence planner needs. Returns `[]`
2755
+ * on any broker error so the caller degrades to a fresh spawn rather than
2756
+ * throwing — a missing list can only mean "nothing is running".
2757
+ */
2758
+ async listRunnerProcesses() {
2759
+ try {
2760
+ // `$process.*` is an untyped Moleculer infra boundary (returns `unknown`);
2761
+ // the process-service owns the `ProcessInfo` shape. Narrow to the two
2762
+ // fields the planner reads.
2763
+ const procs = (await this.broker.call('$process.list'));
2764
+ return procs.map((p) => ({ name: p.name, addonIds: p.addonIds }));
2765
+ }
2766
+ catch {
2767
+ return [];
2768
+ }
2769
+ }
2770
+ /**
2771
+ * Group-aware, convergent forked-runner spawn — the SINGLE authority for
2772
+ * bringing an addon's runner up OUTSIDE the boot plan (hot-install, retry,
2773
+ * operator restart, deploy hot-reload). The boot plan
2774
+ * (`buildAddonGroupPlan` → `initializeAddonGroup`) already groups; this makes
2775
+ * every OTHER lifecycle path honour `execution.group` too, so co-location is
2776
+ * not silently lost the moment an addon is (re)spawned post-boot.
2777
+ *
2778
+ * Resolves the runner id via `resolveRunnerId` and the FULL co-located roster
2779
+ * via `buildAddonGroupPlan` (the same authority the boot plan uses), then
2780
+ * converges the live process topology onto that roster (see
2781
+ * `planRunnerConvergence`): stop stale/mismatched runners, then spawn or
2782
+ * restart the group runner. Idempotent — safe to call once per roster member
2783
+ * (the 2nd..Nth calls see the correct topology and no-op). A groupless addon
2784
+ * keys to a size-1 roster whose runner id is its own id, so its behaviour is
2785
+ * byte-identical to the pre-group solo path.
2786
+ *
2787
+ * `opts.restart` respawns the group in place even when the roster already
2788
+ * matches — the deploy hot-reload path, so the child reboots the WHOLE roster
2789
+ * from the freshly-swapped on-disk bundle. This is why updating ONE member of
2790
+ * a group respawns the ENTIRE group (they share a process): the operator's
2791
+ * "update one → respawn the whole group" invariant.
2792
+ */
2793
+ async ensureForkedRunner(addonId, opts) {
2794
+ const entry = this.addonEntries.get(addonId);
2795
+ if (!entry?.declaration || !entry.addonDir) {
2796
+ throw new Error(`ensureForkedRunner("${addonId}") requires an on-disk forked addon`);
2797
+ }
2798
+ const runnerId = (0, types_1.resolveRunnerId)(entry.declaration, addonId);
2799
+ const roster = this.buildAddonGroupPlan([...this.addonEntries.keys()]).get(runnerId) ?? [
2800
+ { addonId, addonDir: entry.addonDir },
2801
+ ];
2802
+ // Coalesce redundant group respawns: a package reload loops `restartAddon`
2803
+ // over every member of a co-location group, but the FIRST member's restart
2804
+ // already respawned the whole runner (every member rebooted from fresh disk
2805
+ // code). For a GROUP (roster > 1) whose runner was respawned inside the
2806
+ // coalesce window, downgrade a restart request to a plain topology check so
2807
+ // the 2nd..Nth members don't churn the runner. Solo addons (roster == 1) are
2808
+ // never coalesced — their behaviour stays byte-identical.
2809
+ const effectiveRestart = opts.restart &&
2810
+ roster.length > 1 &&
2811
+ Date.now() - (this.recentGroupRespawns.get(runnerId) ?? 0) < GROUP_RESPAWN_COALESCE_MS
2812
+ ? false
2813
+ : opts.restart;
2814
+ const running = await this.listRunnerProcesses();
2815
+ const plan = (0, runner_convergence_1.planRunnerConvergence)(runnerId, roster.map((r) => r.addonId), running, { restart: effectiveRestart });
2816
+ // 1. Tear down every stale/mismatched runner that hosts a roster member.
2817
+ for (const name of plan.stop) {
2818
+ await this.broker.call('$process.stop', { name }).catch((err) => {
2819
+ this.logger.warn('ensureForkedRunner: failed to stop stale runner (continuing)', {
2820
+ meta: { runnerId, stale: name, error: (0, types_1.errMsg)(err) },
2821
+ });
2822
+ });
2823
+ }
2824
+ // 2. Bring the target runner to the correct roster.
2825
+ if (plan.action === 'spawn') {
2826
+ await this.broker.call('$process.spawnRunner', { runnerId, addons: roster }).catch((err) => {
2827
+ // A concurrent path may have won the spawn race — treat "already
2828
+ // running" as success (idempotent), rethrow anything else.
2829
+ if (!/already running/i.test((0, types_1.errMsg)(err)))
2830
+ throw err;
2831
+ });
2832
+ this.recentGroupRespawns.set(runnerId, Date.now());
2833
+ }
2834
+ else if (plan.action === 'restart') {
2835
+ const res = (await this.broker.call('$process.restart', { name: runnerId }));
2836
+ if (!res.success) {
2837
+ throw new Error(`Process restart failed: ${res.reason ?? 'unknown'}`);
2838
+ }
2839
+ this.recentGroupRespawns.set(runnerId, Date.now());
2840
+ }
2841
+ // 3. Bookkeeping for EVERY roster member. Custom actions live in the
2842
+ // hub-side registry the process spawn never touches; a hot-update would
2843
+ // otherwise silently drop them (the boot plan is the only other place
2844
+ // they register). Mark each member initialized so the in-process
2845
+ // core-builtin boot passes skip them.
2846
+ for (const { addonId: memberId } of roster) {
2847
+ await this.registerForkedAddonCustomActions(memberId, runnerId);
2848
+ const memberEntry = this.addonEntries.get(memberId);
2849
+ if (memberEntry)
2850
+ memberEntry.initialized = true;
2851
+ }
2852
+ }
2708
2853
  /**
2709
2854
  * (Re-)register the custom-action catalog for a forked / group-hosted
2710
2855
  * addon against the shared `CustomActionRegistry`.
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ /**
3
+ * Group-runner convergence planner (pure).
4
+ *
5
+ * Group membership is decided ONCE, at a runner's FIRST spawn — the `addons`
6
+ * array handed to `$process.spawnRunner`. Every later lifecycle op preserves
7
+ * it: `$process.restart` respawns a runner from its CAPTURED `runnerAddons`,
8
+ * never from a freshly-declared roster. That means the boot plan
9
+ * (`buildAddonGroupPlan` → `initializeAddonGroup`) is not the only place that
10
+ * has to be group-aware: any path that (re)spawns an addon's runner outside
11
+ * boot (hot-install, retry, operator restart, deploy hot-reload) must converge
12
+ * the LIVE process topology onto the addon's DECLARED runner id
13
+ * (`resolveRunnerId`) and its full co-located roster.
14
+ *
15
+ * This module holds the decision as a pure function so it is unit-testable
16
+ * without the heavyweight `AddonRegistryService`. The caller
17
+ * (`ensureForkedRunner`) executes the plan against `$process.*`.
18
+ *
19
+ * Why not just `$process.restart` the addon? Because restart respawns the
20
+ * runner's OLD roster. A runner named `detection` that was spawned solo (with
21
+ * only `pipeline-runner`, before the group manifest shipped) would be respawned
22
+ * solo forever. To adopt a NEW roster the stale runner(s) must be STOPPED and
23
+ * the group spawned fresh — the convergence this planner encodes.
24
+ */
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.planRunnerConvergence = planRunnerConvergence;
27
+ function sameRoster(addonIds, rosterSet) {
28
+ return addonIds.length === rosterSet.size && addonIds.every((a) => rosterSet.has(a));
29
+ }
30
+ /**
31
+ * Decide how to converge the live runner topology onto the declared group.
32
+ *
33
+ * @param runnerId the addon's resolved runner id (`resolveRunnerId`).
34
+ * @param rosterAddonIds the FULL co-located roster (every eligible addon that
35
+ * resolves to `runnerId`). Always includes the addon
36
+ * itself; size-1 for a groupless addon.
37
+ * @param running current runner subprocesses (`$process.list`).
38
+ * @param opts.restart true when the caller wants an in-place respawn even if
39
+ * the roster already matches (deploy hot-reload path).
40
+ */
41
+ function planRunnerConvergence(runnerId, rosterAddonIds, running, opts) {
42
+ const rosterSet = new Set(rosterAddonIds);
43
+ const target = running.find((p) => p.name === runnerId);
44
+ const targetIsCorrect = target !== undefined && sameRoster(target.addonIds, rosterSet);
45
+ if (targetIsCorrect) {
46
+ // The exact group runner is already up. Only stragglers to stop are OTHER
47
+ // runners that (wrongly) host a roster member.
48
+ const stop = running
49
+ .filter((p) => p.name !== runnerId && p.addonIds.some((a) => rosterSet.has(a)))
50
+ .map((p) => p.name);
51
+ return { stop, action: opts.restart ? 'restart' : 'none' };
52
+ }
53
+ // No correct runner: either nothing hosts the roster, or a same-id runner
54
+ // carries the WRONG roster, or roster members are scattered across stale
55
+ // solo runners. Stop every runner that hosts a roster member (INCLUDING a
56
+ // wrong-roster same-id runner — restart would just re-spawn its stale
57
+ // roster), then spawn the group fresh.
58
+ const stop = running
59
+ .filter((p) => p.name === runnerId || p.addonIds.some((a) => rosterSet.has(a)))
60
+ .map((p) => p.name);
61
+ return { stop, action: 'spawn' };
62
+ }
package/dist/main.js CHANGED
@@ -721,7 +721,14 @@ async function bootstrap() {
721
721
  // Addon HTTP API route catch-all: /addon/:addonId/*
722
722
  // Only handles non-GET or routes that actually exist in the addon route registry.
723
723
  // GET requests that don't match an addon route are SPA pages — handled by the /* fallback.
724
- fastify.all('/addon/:addonId/*', async (request, reply) => {
724
+ //
725
+ // `compress: false`: the bridged reply body of a FORKED addon crosses the
726
+ // UDS route bridge as opaque bytes; piping those through the hub's global
727
+ // @fastify/compress emits an EMPTY brotli stream for any compressible body
728
+ // >1KiB (live-diagnosed 2026-07-16 — broke the notifier SVG icons; same
729
+ // class addon-upload.ts already dodges). Addons that care about coding own
730
+ // it end-to-end (see the notifier icon routes).
731
+ fastify.all('/addon/:addonId/*', { compress: false }, async (request, reply) => {
725
732
  const { addonId } = request.params;
726
733
  const subPath = request.params['*'] ?? '';
727
734
  const method = request.method;
@@ -98,6 +98,7 @@ var require_dist = __commonJS({
98
98
  planBoot: /* @__PURE__ */ __name(() => planBoot, "planBoot"),
99
99
  readDevUploadManifest: /* @__PURE__ */ __name(() => readDevUploadManifest, "readDevUploadManifest"),
100
100
  readRestartIntentMarker: /* @__PURE__ */ __name(() => readRestartIntentMarker, "readRestartIntentMarker"),
101
+ readSeedVersion: /* @__PURE__ */ __name(() => readSeedVersion, "readSeedVersion"),
101
102
  readServerRootState: /* @__PURE__ */ __name(() => readServerRootState, "readServerRootState"),
102
103
  registerActiveRootResolver: /* @__PURE__ */ __name(() => registerActiveRootResolver, "registerActiveRootResolver"),
103
104
  restartIntentMarkerPath: /* @__PURE__ */ __name(() => restartIntentMarkerPath, "restartIntentMarkerPath"),
@@ -351,7 +352,39 @@ var require_dist = __commonJS({
351
352
  return null;
352
353
  }
353
354
  __name(validateVersionDir, "validateVersionDir");
354
- function planBoot(state, isValidVersion, now) {
355
+ function parse(version) {
356
+ const dashIdx = version.indexOf("-");
357
+ const base = dashIdx === -1 ? version : version.slice(0, dashIdx);
358
+ const prerelease = dashIdx === -1 ? null : version.slice(dashIdx + 1);
359
+ const nums = base.split(".").map((seg) => {
360
+ const n = Number.parseInt(seg, 10);
361
+ return Number.isNaN(n) ? 0 : n;
362
+ });
363
+ return {
364
+ nums,
365
+ prerelease
366
+ };
367
+ }
368
+ __name(parse, "parse");
369
+ function compareSemver(a, b) {
370
+ const pa = parse(a);
371
+ const pb = parse(b);
372
+ const len = Math.max(pa.nums.length, pb.nums.length);
373
+ for (let i = 0; i < len; i++) {
374
+ const na = pa.nums[i] ?? 0;
375
+ const nb = pb.nums[i] ?? 0;
376
+ if (na < nb) return -1;
377
+ if (na > nb) return 1;
378
+ }
379
+ if (pa.prerelease === null && pb.prerelease === null) return 0;
380
+ if (pa.prerelease === null) return 1;
381
+ if (pb.prerelease === null) return -1;
382
+ if (pa.prerelease < pb.prerelease) return -1;
383
+ if (pa.prerelease > pb.prerelease) return 1;
384
+ return 0;
385
+ }
386
+ __name(compareSemver, "compareSemver");
387
+ function planBoot(state, isValidVersion, now, seedVersion = null) {
355
388
  if (state === null) {
356
389
  return {
357
390
  kind: "baked",
@@ -404,6 +437,19 @@ var require_dist = __commonJS({
404
437
  }
405
438
  if (next.currentVersion !== null) {
406
439
  if (isValidVersion(next.currentVersion)) {
440
+ if (!changed && seedVersion !== null && compareSemver(seedVersion, next.currentVersion) > 0) {
441
+ return {
442
+ kind: "baked",
443
+ reason: `baked seed ${seedVersion} is newer than active data-root ${next.currentVersion} \u2014 adopting the image seed`,
444
+ stateToWrite: {
445
+ ...next,
446
+ currentVersion: null,
447
+ previousVersion: null,
448
+ pendingBoot: null,
449
+ rolledBack: null
450
+ }
451
+ };
452
+ }
407
453
  return {
408
454
  kind: "data-root",
409
455
  version: next.currentVersion,
@@ -453,38 +499,6 @@ var require_dist = __commonJS({
453
499
  };
454
500
  }
455
501
  __name(planBoot, "planBoot");
456
- function parse(version) {
457
- const dashIdx = version.indexOf("-");
458
- const base = dashIdx === -1 ? version : version.slice(0, dashIdx);
459
- const prerelease = dashIdx === -1 ? null : version.slice(dashIdx + 1);
460
- const nums = base.split(".").map((seg) => {
461
- const n = Number.parseInt(seg, 10);
462
- return Number.isNaN(n) ? 0 : n;
463
- });
464
- return {
465
- nums,
466
- prerelease
467
- };
468
- }
469
- __name(parse, "parse");
470
- function compareSemver(a, b) {
471
- const pa = parse(a);
472
- const pb = parse(b);
473
- const len = Math.max(pa.nums.length, pb.nums.length);
474
- for (let i = 0; i < len; i++) {
475
- const na = pa.nums[i] ?? 0;
476
- const nb = pb.nums[i] ?? 0;
477
- if (na < nb) return -1;
478
- if (na > nb) return 1;
479
- }
480
- if (pa.prerelease === null && pb.prerelease === null) return 0;
481
- if (pa.prerelease === null) return 1;
482
- if (pb.prerelease === null) return -1;
483
- if (pa.prerelease < pb.prerelease) return -1;
484
- if (pa.prerelease > pb.prerelease) return 1;
485
- return 0;
486
- }
487
- __name(compareSemver, "compareSemver");
488
502
  var fs3 = __toESM2(require("fs"));
489
503
  var path3 = __toESM2(require("path"));
490
504
  function detectWorkspaceRoot(fromDir) {
@@ -504,6 +518,7 @@ var require_dist = __commonJS({
504
518
  }
505
519
  }
506
520
  __name(detectWorkspaceRoot, "detectWorkspaceRoot");
521
+ var fs4 = __toESM2(require("fs"));
507
522
  var path4 = __toESM2(require("path"));
508
523
  var import_node_url = require("url");
509
524
  var HOST_EXTERNAL_SPECIFIERS = [
@@ -546,6 +561,20 @@ var require_dist = __commonJS({
546
561
  registerHooks(hooks);
547
562
  }
548
563
  __name(registerActiveRootResolver, "registerActiveRootResolver");
564
+ function readSeedVersion(seedEntry) {
565
+ try {
566
+ const pkgJsonPath = path4.join(path4.dirname(seedEntry), "..", "package.json");
567
+ const raw = JSON.parse(fs4.readFileSync(pkgJsonPath, "utf-8"));
568
+ if (typeof raw === "object" && raw !== null) {
569
+ const version = raw["version"];
570
+ if (typeof version === "string") return version;
571
+ }
572
+ return null;
573
+ } catch {
574
+ return null;
575
+ }
576
+ }
577
+ __name(readSeedVersion, "readSeedVersion");
549
578
  function runNodeStarter(options) {
550
579
  const env = options.env ?? process.env;
551
580
  const now = options.now ?? Date.now;
@@ -572,7 +601,8 @@ var require_dist = __commonJS({
572
601
  if (reason !== null) console.warn(`[starter] version ${version} invalid: ${reason}`);
573
602
  return reason === null;
574
603
  }, "isValidVersion");
575
- const plan = planBoot(state, isValidVersion, now);
604
+ const seedVersion = readSeedVersion(options.seedEntry);
605
+ const plan = planBoot(state, isValidVersion, now, seedVersion);
576
606
  if (plan.stateToWrite !== null) {
577
607
  try {
578
608
  writeServerRootState(rootDir, plan.stateToWrite);
@@ -604,7 +634,7 @@ var require_dist = __commonJS({
604
634
  }
605
635
  __name(runNodeStarter, "runNodeStarter");
606
636
  var import_node_child_process = require("child_process");
607
- var fs4 = __toESM2(require("fs"));
637
+ var fs5 = __toESM2(require("fs"));
608
638
  var path5 = __toESM2(require("path"));
609
639
  var import_node_util = require("util");
610
640
  var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
@@ -622,7 +652,7 @@ var require_dist = __commonJS({
622
652
  var RESTART_REASON_PREFIX = "server-update";
623
653
  function readPackageVersion(pkgJsonPath) {
624
654
  try {
625
- const raw = JSON.parse(fs4.readFileSync(pkgJsonPath, "utf-8"));
655
+ const raw = JSON.parse(fs5.readFileSync(pkgJsonPath, "utf-8"));
626
656
  if (typeof raw === "object" && raw !== null) {
627
657
  const version = raw["version"];
628
658
  if (typeof version === "string") return version;
@@ -712,7 +742,7 @@ var require_dist = __commonJS({
712
742
  };
713
743
  return {
714
744
  state: emptyServerRootState(),
715
- corrupt: fs4.existsSync(stateFilePath(rootDir))
745
+ corrupt: fs5.existsSync(stateFilePath(rootDir))
716
746
  };
717
747
  }
718
748
  updateState(state) {
@@ -865,7 +895,7 @@ var require_dist = __commonJS({
865
895
  }
866
896
  if (isDevChannelVersion(target)) {
867
897
  const uploadsDir = devUploadVersionDir(this.rootDir(), target);
868
- if (!fs4.existsSync(uploadsDir)) {
898
+ if (!fs5.existsSync(uploadsDir)) {
869
899
  return {
870
900
  accepted: false,
871
901
  targetVersion: target,
@@ -926,11 +956,11 @@ var require_dist = __commonJS({
926
956
  async stageAndActivate(target) {
927
957
  const rootDir = this.rootDir();
928
958
  const vDir = versionsDir(rootDir);
929
- fs4.mkdirSync(vDir, {
959
+ fs5.mkdirSync(vDir, {
930
960
  recursive: true
931
961
  });
932
962
  const stagingDir = path5.join(vDir, `.staging-${target}-${process.pid}-${this.now()}`);
933
- fs4.mkdirSync(stagingDir, {
963
+ fs5.mkdirSync(stagingDir, {
934
964
  recursive: true
935
965
  });
936
966
  try {
@@ -943,8 +973,8 @@ var require_dist = __commonJS({
943
973
  "--no-fund",
944
974
  "--loglevel=error"
945
975
  ];
946
- if (fs4.existsSync(devDir)) {
947
- fs4.writeFileSync(path5.join(stagingDir, "package.json"), JSON.stringify(this.buildDevUploadsPackageJson(devDir, target), null, 2), "utf-8");
976
+ if (fs5.existsSync(devDir)) {
977
+ fs5.writeFileSync(path5.join(stagingDir, "package.json"), JSON.stringify(this.buildDevUploadsPackageJson(devDir, target), null, 2), "utf-8");
948
978
  await this.execNpm([
949
979
  ...installArgs,
950
980
  ...buildNpmRegistryArgs(registry)
@@ -953,7 +983,7 @@ var require_dist = __commonJS({
953
983
  timeoutMs: NPM_INSTALL_TIMEOUT_MS
954
984
  });
955
985
  } else {
956
- fs4.writeFileSync(path5.join(stagingDir, "package.json"), JSON.stringify({
986
+ fs5.writeFileSync(path5.join(stagingDir, "package.json"), JSON.stringify({
957
987
  name: "camstack-node-root",
958
988
  private: true
959
989
  }, null, 2), "utf-8");
@@ -967,7 +997,7 @@ var require_dist = __commonJS({
967
997
  });
968
998
  }
969
999
  const entry = rootEntryPath2(stagingDir, this.spec);
970
- if (!fs4.existsSync(entry)) {
1000
+ if (!fs5.existsSync(entry)) {
971
1001
  throw new Error(`staged closure is missing the root entry (${entry})`);
972
1002
  }
973
1003
  const stagedVersion = readPackageVersion(path5.join(rootPackageDir2(stagingDir, this.spec), "package.json"));
@@ -975,17 +1005,17 @@ var require_dist = __commonJS({
975
1005
  throw new Error(`staged closure version mismatch: expected ${target}, got ${stagedVersion ?? "unknown"}`);
976
1006
  }
977
1007
  const dest = versionDir(rootDir, target);
978
- if (fs4.existsSync(dest)) {
1008
+ if (fs5.existsSync(dest)) {
979
1009
  const aside = `${dest}.evicted-${this.now()}`;
980
- await fs4.promises.rename(dest, aside);
981
- await fs4.promises.rm(aside, {
1010
+ await fs5.promises.rename(dest, aside);
1011
+ await fs5.promises.rm(aside, {
982
1012
  recursive: true,
983
1013
  force: true
984
1014
  }).catch(() => void 0);
985
1015
  }
986
- await fs4.promises.rename(stagingDir, dest);
1016
+ await fs5.promises.rename(stagingDir, dest);
987
1017
  } catch (err) {
988
- await fs4.promises.rm(stagingDir, {
1018
+ await fs5.promises.rm(stagingDir, {
989
1019
  recursive: true,
990
1020
  force: true
991
1021
  }).catch(() => void 0);
@@ -1015,7 +1045,7 @@ var require_dist = __commonJS({
1015
1045
  }
1016
1046
  const fileRef = /* @__PURE__ */ __name((filename) => {
1017
1047
  const abs = path5.join(devDir, filename);
1018
- if (!fs4.existsSync(abs)) {
1048
+ if (!fs5.existsSync(abs)) {
1019
1049
  throw new Error(`dev-uploads tarball listed in the manifest is missing: ${abs}`);
1020
1050
  }
1021
1051
  return `file:${abs}`;
@@ -1194,7 +1224,7 @@ var require_dist = __commonJS({
1194
1224
  const vDir = versionsDir(this.rootDir());
1195
1225
  let entries;
1196
1226
  try {
1197
- entries = fs4.readdirSync(vDir);
1227
+ entries = fs5.readdirSync(vDir);
1198
1228
  } catch {
1199
1229
  return;
1200
1230
  }
@@ -1203,14 +1233,14 @@ var require_dist = __commonJS({
1203
1233
  const full = path5.join(vDir, entry);
1204
1234
  if (entry.startsWith(".")) {
1205
1235
  try {
1206
- const ageMs = this.now() - fs4.statSync(full).mtimeMs;
1236
+ const ageMs = this.now() - fs5.statSync(full).mtimeMs;
1207
1237
  if (ageMs < _RootUpdateService.STALE_TRANSIENT_MS) continue;
1208
1238
  } catch {
1209
1239
  continue;
1210
1240
  }
1211
1241
  }
1212
1242
  try {
1213
- fs4.rmSync(full, {
1243
+ fs5.rmSync(full, {
1214
1244
  recursive: true,
1215
1245
  force: true
1216
1246
  });
@@ -1241,15 +1271,15 @@ var require_dist = __commonJS({
1241
1271
  const dir = devUploadsDir(this.rootDir());
1242
1272
  let entries;
1243
1273
  try {
1244
- entries = fs4.readdirSync(dir);
1274
+ entries = fs5.readdirSync(dir);
1245
1275
  } catch {
1246
1276
  return;
1247
1277
  }
1248
1278
  const graveyard = path5.join(dir, ".sweeping");
1249
1279
  try {
1250
- for (const residue of fs4.readdirSync(graveyard)) {
1280
+ for (const residue of fs5.readdirSync(graveyard)) {
1251
1281
  try {
1252
- fs4.rmSync(path5.join(graveyard, residue), {
1282
+ fs5.rmSync(path5.join(graveyard, residue), {
1253
1283
  recursive: true,
1254
1284
  force: true
1255
1285
  });
@@ -1264,13 +1294,13 @@ var require_dist = __commonJS({
1264
1294
  ].sort((a, b) => (devChannelEpoch(b) ?? -1) - (devChannelEpoch(a) ?? -1));
1265
1295
  const doomed = byNewestFirst.slice(keepCount);
1266
1296
  if (doomed.length === 0) return;
1267
- fs4.mkdirSync(graveyard, {
1297
+ fs5.mkdirSync(graveyard, {
1268
1298
  recursive: true
1269
1299
  });
1270
1300
  for (const entry of doomed) {
1271
1301
  const aside = path5.join(graveyard, `${entry}-${this.now()}`);
1272
1302
  try {
1273
- fs4.renameSync(path5.join(dir, entry), aside);
1303
+ fs5.renameSync(path5.join(dir, entry), aside);
1274
1304
  } catch (err) {
1275
1305
  this.logger.warn("failed to move dev-uploads entry aside for sweep", {
1276
1306
  meta: {
@@ -1281,7 +1311,7 @@ var require_dist = __commonJS({
1281
1311
  continue;
1282
1312
  }
1283
1313
  try {
1284
- fs4.rmSync(aside, {
1314
+ fs5.rmSync(aside, {
1285
1315
  recursive: true,
1286
1316
  force: true
1287
1317
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.1.62",
3
+ "version": "1.1.64",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist",
@@ -23,19 +23,19 @@
23
23
  "test:watch": "vitest"
24
24
  },
25
25
  "dependencies": {
26
- "@camstack/addon-admin-ui": "1.1.51",
27
- "@camstack/addon-advanced-notifier": "1.1.24",
28
- "@camstack/addon-auth": "1.1.9",
29
- "@camstack/addon-decoder-nodeav": "1.1.11",
30
- "@camstack/addon-notifiers": "1.1.23",
31
- "@camstack/addon-pipeline": "1.1.56",
32
- "@camstack/addon-pipeline-orchestrator": "1.1.46",
33
- "@camstack/addon-post-analysis": "1.1.28",
34
- "@camstack/sdk": "1.1.24",
35
- "@camstack/shm-ring": "1.0.23",
36
- "@camstack/system": "1.1.49",
37
- "@camstack/types": "1.1.44",
38
- "@camstack/ui-library": "1.1.36",
26
+ "@camstack/addon-admin-ui": "1.1.53",
27
+ "@camstack/addon-advanced-notifier": "1.1.25",
28
+ "@camstack/addon-auth": "1.1.10",
29
+ "@camstack/addon-decoder-nodeav": "1.1.13",
30
+ "@camstack/addon-notifiers": "1.1.25",
31
+ "@camstack/addon-pipeline": "1.1.58",
32
+ "@camstack/addon-pipeline-orchestrator": "1.1.48",
33
+ "@camstack/addon-post-analysis": "1.1.30",
34
+ "@camstack/sdk": "1.1.25",
35
+ "@camstack/shm-ring": "1.0.24",
36
+ "@camstack/system": "1.1.51",
37
+ "@camstack/types": "1.1.46",
38
+ "@camstack/ui-library": "1.1.37",
39
39
  "@fastify/compress": "^9.0.0",
40
40
  "@fastify/cookie": "^11.0.2",
41
41
  "@fastify/multipart": "^10.0.0",