@camstack/server 1.1.62 → 1.1.63

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,6 +38,7 @@ 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");
@@ -84,6 +85,15 @@ function isSettingsStore(backend) {
84
85
  function isAddonRoutesInvoker(provider) {
85
86
  return typeof Reflect.get(provider, 'invoke') === 'function';
86
87
  }
88
+ /**
89
+ * Window within which a second `restart:true` respawn of the SAME group runner
90
+ * is coalesced to a no-op (a package reload loops `restartAddon` over every
91
+ * group member — see `recentGroupRespawns`). Chosen comfortably longer than a
92
+ * single group respawn (child fork + init) so all members of one reload pass
93
+ * fall inside it, and short enough that a genuinely independent later restart
94
+ * still respawns.
95
+ */
96
+ const GROUP_RESPAWN_COALESCE_MS = 10_000;
87
97
  const ROUTE_METHODS = [
88
98
  'GET',
89
99
  'POST',
@@ -197,6 +207,17 @@ class AddonRegistryService {
197
207
  * `restartAddon` completes (success or failure) or by a 90s safety timer.
198
208
  */
199
209
  restartingAddons = new Map();
210
+ /**
211
+ * `runnerId → last-respawn epoch-ms`, used ONLY to coalesce redundant group
212
+ * respawns. A package reload loops `restartAddon` over EVERY addon in the
213
+ * package (`tryReloadPackage`); for a co-location group that maps N members
214
+ * onto ONE runner, the first member's restart already respawns the whole
215
+ * group (all members reboot from fresh on-disk code), so the 2nd..Nth
216
+ * restarts must NOT respawn again — that churns the runner N times and risks
217
+ * tripping the crash circuit-breaker. Consulted by `ensureForkedRunner`; only
218
+ * groups (roster > 1) are coalesced, so solo-addon restart stays byte-identical.
219
+ */
220
+ recentGroupRespawns = new Map();
200
221
  logger;
201
222
  addonLoader;
202
223
  healthMonitor;
@@ -1096,17 +1117,22 @@ class AddonRegistryService {
1096
1117
  // `@camstack/system` builtins reach the in-process path below) it
1097
1118
  // boots in-process on the hub.
1098
1119
  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.)
1120
+ // D5 + co-location: a forked addon ALWAYS boots in a runner — there is
1121
+ // NO in-process-on-the-hub fallback. `ensureForkedRunner` resolves the
1122
+ // addon's runner id (`resolveRunnerId`) and spawns/joins the FULL
1123
+ // co-located roster: a groupless addon gets its own runner (one addon,
1124
+ // one process unchanged), while a `execution.group` member co-locates
1125
+ // with its siblings so cross-addon cap calls resolve in-process (zero
1126
+ // copy). Idempotent when a sibling already brought the group up. If the
1127
+ // spawn fails, the addon is `failed`, surfaced as `addon.error` +
1128
+ // recorded on the health monitor; it does not silently run on the hub.
1129
+ // (Task 7's circuit breaker governs the retry policy on top of this.)
1130
+ //
1131
+ // Provider registration for forkable addons is delegated to the
1132
+ // `CapabilityBridge` (see `MoleculerService.onProviderConnected`);
1133
+ // custom actions are (re-)registered inside `ensureForkedRunner`.
1105
1134
  try {
1106
- await this.broker.call('$process.spawnRunner', {
1107
- runnerId: id,
1108
- addons: [{ addonId: id, addonDir: entry.addonDir }],
1109
- });
1135
+ await this.ensureForkedRunner(id, { restart: false });
1110
1136
  }
1111
1137
  catch (err) {
1112
1138
  const msg = (0, types_1.errMsg)(err);
@@ -1121,23 +1147,6 @@ class AddonRegistryService {
1121
1147
  this.healthMonitor.recordFailure(entry.packageName, err, id);
1122
1148
  throw new Error(`Failed to spawn runner for addon "${id}": ${msg}`, { cause: err });
1123
1149
  }
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
1150
  entry.initialized = true;
1142
1151
  this.logger.info('Addon spawned as isolated process', { tags: { addonId: id } });
1143
1152
  this.emitAddonLifecycleEvent('addon.started', id);
@@ -1357,23 +1366,40 @@ class AddonRegistryService {
1357
1366
  // restarts. The Moleculer `$node.disconnected` handler skips entries
1358
1367
  // present in `restartingAddons`; a 90s safety timer clears the flag
1359
1368
  // 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);
1369
+ // Suppress the banner for the restarted addon AND — for a co-location
1370
+ // group — every SIBLING sharing its runner. `ensureForkedRunner` respawns
1371
+ // the WHOLE group process, so all members briefly `$node.disconnect`; without
1372
+ // suppressing the siblings too, each would flash a spurious "Failed to load".
1373
+ const suppressed = new Set([addonId]);
1374
+ if (this.isForkedAddonEntry(entry)) {
1375
+ const runnerId = (0, types_1.resolveRunnerId)(entry.declaration, addonId);
1376
+ for (const { addonId: siblingId } of this.buildAddonGroupPlan([
1377
+ ...this.addonEntries.keys(),
1378
+ ]).get(runnerId) ?? []) {
1379
+ suppressed.add(siblingId);
1380
+ }
1381
+ }
1382
+ for (const id of suppressed) {
1383
+ const prior = this.restartingAddons.get(id);
1384
+ if (prior)
1385
+ clearTimeout(prior);
1386
+ const timer = setTimeout(() => {
1387
+ this.restartingAddons.delete(id);
1388
+ }, 90_000);
1389
+ this.restartingAddons.set(id, timer);
1390
+ }
1367
1391
  try {
1368
- // Group-runner-hosted addon — delegate to $process.restart for the group
1392
+ // Group-runner-hosted addon — converge the runner topology onto the
1393
+ // declared group, then respawn it. `ensureForkedRunner({ restart: true })`
1394
+ // both (a) adopts a NEW roster if `execution.group` changed since the
1395
+ // runner last spawned (stop stale solo runners → spawn the group; a plain
1396
+ // `$process.restart` would respawn the OLD roster forever), and (b) on the
1397
+ // common path respawns the group in place so the child reboots the WHOLE
1398
+ // roster from the freshly-swapped on-disk bundle. Updating ONE member of a
1399
+ // group therefore respawns the ENTIRE group — they share a process.
1369
1400
  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
1401
+ await this.ensureForkedRunner(addonId, { restart: true });
1402
+ // The respawn resolves as soon as the child is up, not when its
1377
1403
  // capabilities are re-registered. Callers (integrations.create, UI forms) may
1378
1404
  // immediately try to route to the provider and hit a transient null. Block here
1379
1405
  // until every declared capability is back on the registry so the restart is
@@ -1413,16 +1439,10 @@ class AddonRegistryService {
1413
1439
  `(re-register asynchronously — e.g. device-scoped caps awaiting devices): ${missing.join(', ')}`, { tags: { addonId } });
1414
1440
  }
1415
1441
  }
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);
1442
+ // Custom actions are re-registered inside `ensureForkedRunner` for
1443
+ // every roster member (custom actions live in the hub-side
1444
+ // `CustomActionRegistry`, which the process spawn never touches a
1445
+ // hot-update would otherwise silently drop them).
1426
1446
  this.logAddonLifecycle('restarted', addonId, 'isolated');
1427
1447
  this.emitAddonLifecycleEvent('addon.restarted', addonId);
1428
1448
  return { success: true };
@@ -1483,13 +1503,16 @@ class AddonRegistryService {
1483
1503
  return { success: false, error: msg };
1484
1504
  }
1485
1505
  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);
1506
+ // Clear the suppression flags (restarted addon + any group siblings)
1507
+ // regardless of success/failure if the restart failed, the operator
1508
+ // sees the real error via the mutation result rather than a misleading
1509
+ // transient health blip.
1510
+ for (const id of suppressed) {
1511
+ const timer = this.restartingAddons.get(id);
1512
+ if (timer)
1513
+ clearTimeout(timer);
1514
+ this.restartingAddons.delete(id);
1515
+ }
1493
1516
  }
1494
1517
  }
1495
1518
  getAddon(id) {
@@ -2705,6 +2728,107 @@ class AddonRegistryService {
2705
2728
  meta: { runnerId, addonCount: addons.length, addonIds: addons.map((a) => a.addonId) },
2706
2729
  });
2707
2730
  }
2731
+ /**
2732
+ * Snapshot of the live runner subprocesses (`$process.list`), reduced to the
2733
+ * `{ runnerId, addonIds }` shape the convergence planner needs. Returns `[]`
2734
+ * on any broker error so the caller degrades to a fresh spawn rather than
2735
+ * throwing — a missing list can only mean "nothing is running".
2736
+ */
2737
+ async listRunnerProcesses() {
2738
+ try {
2739
+ // `$process.*` is an untyped Moleculer infra boundary (returns `unknown`);
2740
+ // the process-service owns the `ProcessInfo` shape. Narrow to the two
2741
+ // fields the planner reads.
2742
+ const procs = (await this.broker.call('$process.list'));
2743
+ return procs.map((p) => ({ name: p.name, addonIds: p.addonIds }));
2744
+ }
2745
+ catch {
2746
+ return [];
2747
+ }
2748
+ }
2749
+ /**
2750
+ * Group-aware, convergent forked-runner spawn — the SINGLE authority for
2751
+ * bringing an addon's runner up OUTSIDE the boot plan (hot-install, retry,
2752
+ * operator restart, deploy hot-reload). The boot plan
2753
+ * (`buildAddonGroupPlan` → `initializeAddonGroup`) already groups; this makes
2754
+ * every OTHER lifecycle path honour `execution.group` too, so co-location is
2755
+ * not silently lost the moment an addon is (re)spawned post-boot.
2756
+ *
2757
+ * Resolves the runner id via `resolveRunnerId` and the FULL co-located roster
2758
+ * via `buildAddonGroupPlan` (the same authority the boot plan uses), then
2759
+ * converges the live process topology onto that roster (see
2760
+ * `planRunnerConvergence`): stop stale/mismatched runners, then spawn or
2761
+ * restart the group runner. Idempotent — safe to call once per roster member
2762
+ * (the 2nd..Nth calls see the correct topology and no-op). A groupless addon
2763
+ * keys to a size-1 roster whose runner id is its own id, so its behaviour is
2764
+ * byte-identical to the pre-group solo path.
2765
+ *
2766
+ * `opts.restart` respawns the group in place even when the roster already
2767
+ * matches — the deploy hot-reload path, so the child reboots the WHOLE roster
2768
+ * from the freshly-swapped on-disk bundle. This is why updating ONE member of
2769
+ * a group respawns the ENTIRE group (they share a process): the operator's
2770
+ * "update one → respawn the whole group" invariant.
2771
+ */
2772
+ async ensureForkedRunner(addonId, opts) {
2773
+ const entry = this.addonEntries.get(addonId);
2774
+ if (!entry?.declaration || !entry.addonDir) {
2775
+ throw new Error(`ensureForkedRunner("${addonId}") requires an on-disk forked addon`);
2776
+ }
2777
+ const runnerId = (0, types_1.resolveRunnerId)(entry.declaration, addonId);
2778
+ const roster = this.buildAddonGroupPlan([...this.addonEntries.keys()]).get(runnerId) ?? [
2779
+ { addonId, addonDir: entry.addonDir },
2780
+ ];
2781
+ // Coalesce redundant group respawns: a package reload loops `restartAddon`
2782
+ // over every member of a co-location group, but the FIRST member's restart
2783
+ // already respawned the whole runner (every member rebooted from fresh disk
2784
+ // code). For a GROUP (roster > 1) whose runner was respawned inside the
2785
+ // coalesce window, downgrade a restart request to a plain topology check so
2786
+ // the 2nd..Nth members don't churn the runner. Solo addons (roster == 1) are
2787
+ // never coalesced — their behaviour stays byte-identical.
2788
+ const effectiveRestart = opts.restart &&
2789
+ roster.length > 1 &&
2790
+ Date.now() - (this.recentGroupRespawns.get(runnerId) ?? 0) < GROUP_RESPAWN_COALESCE_MS
2791
+ ? false
2792
+ : opts.restart;
2793
+ const running = await this.listRunnerProcesses();
2794
+ const plan = (0, runner_convergence_1.planRunnerConvergence)(runnerId, roster.map((r) => r.addonId), running, { restart: effectiveRestart });
2795
+ // 1. Tear down every stale/mismatched runner that hosts a roster member.
2796
+ for (const name of plan.stop) {
2797
+ await this.broker.call('$process.stop', { name }).catch((err) => {
2798
+ this.logger.warn('ensureForkedRunner: failed to stop stale runner (continuing)', {
2799
+ meta: { runnerId, stale: name, error: (0, types_1.errMsg)(err) },
2800
+ });
2801
+ });
2802
+ }
2803
+ // 2. Bring the target runner to the correct roster.
2804
+ if (plan.action === 'spawn') {
2805
+ await this.broker.call('$process.spawnRunner', { runnerId, addons: roster }).catch((err) => {
2806
+ // A concurrent path may have won the spawn race — treat "already
2807
+ // running" as success (idempotent), rethrow anything else.
2808
+ if (!/already running/i.test((0, types_1.errMsg)(err)))
2809
+ throw err;
2810
+ });
2811
+ this.recentGroupRespawns.set(runnerId, Date.now());
2812
+ }
2813
+ else if (plan.action === 'restart') {
2814
+ const res = (await this.broker.call('$process.restart', { name: runnerId }));
2815
+ if (!res.success) {
2816
+ throw new Error(`Process restart failed: ${res.reason ?? 'unknown'}`);
2817
+ }
2818
+ this.recentGroupRespawns.set(runnerId, Date.now());
2819
+ }
2820
+ // 3. Bookkeeping for EVERY roster member. Custom actions live in the
2821
+ // hub-side registry the process spawn never touches; a hot-update would
2822
+ // otherwise silently drop them (the boot plan is the only other place
2823
+ // they register). Mark each member initialized so the in-process
2824
+ // core-builtin boot passes skip them.
2825
+ for (const { addonId: memberId } of roster) {
2826
+ await this.registerForkedAddonCustomActions(memberId, runnerId);
2827
+ const memberEntry = this.addonEntries.get(memberId);
2828
+ if (memberEntry)
2829
+ memberEntry.initialized = true;
2830
+ }
2831
+ }
2708
2832
  /**
2709
2833
  * (Re-)register the custom-action catalog for a forked / group-hosted
2710
2834
  * 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.63",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist",
@@ -23,18 +23,18 @@
23
23
  "test:watch": "vitest"
24
24
  },
25
25
  "dependencies": {
26
- "@camstack/addon-admin-ui": "1.1.51",
26
+ "@camstack/addon-admin-ui": "1.1.52",
27
27
  "@camstack/addon-advanced-notifier": "1.1.24",
28
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",
29
+ "@camstack/addon-decoder-nodeav": "1.1.12",
30
+ "@camstack/addon-notifiers": "1.1.24",
31
+ "@camstack/addon-pipeline": "1.1.57",
32
32
  "@camstack/addon-pipeline-orchestrator": "1.1.46",
33
- "@camstack/addon-post-analysis": "1.1.28",
33
+ "@camstack/addon-post-analysis": "1.1.29",
34
34
  "@camstack/sdk": "1.1.24",
35
35
  "@camstack/shm-ring": "1.0.23",
36
- "@camstack/system": "1.1.49",
37
- "@camstack/types": "1.1.44",
36
+ "@camstack/system": "1.1.50",
37
+ "@camstack/types": "1.1.45",
38
38
  "@camstack/ui-library": "1.1.36",
39
39
  "@fastify/compress": "^9.0.0",
40
40
  "@fastify/cookie": "^11.0.2",