@camstack/server 1.2.268 → 1.2.270

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.
@@ -43,7 +43,6 @@ const node_crypto_1 = require("node:crypto");
43
43
  const fs = __importStar(require("node:fs"));
44
44
  const os = __importStar(require("node:os"));
45
45
  const path = __importStar(require("node:path"));
46
- const node_url_1 = require("node:url");
47
46
  const system_1 = require("@camstack/system");
48
47
  const types_1 = require("@camstack/types");
49
48
  const client_1 = require("@trpc/client");
@@ -53,8 +52,8 @@ const addon_settings_provider_js_1 = require("./addon-settings-provider.js");
53
52
  const device_meta_mirror_js_1 = require("./device-meta-mirror.js");
54
53
  const integration_visibility_js_1 = require("./integration-visibility.js");
55
54
  const package_dir_utils_1 = require("./package-dir-utils");
55
+ const forked_custom_actions_js_1 = require("./forked-custom-actions.js");
56
56
  const prune_misplaced_addons_js_1 = require("./prune-misplaced-addons.js");
57
- const require_cache_js_1 = require("./require-cache.js");
58
57
  const runner_convergence_1 = require("./runner-convergence");
59
58
  const runner_spawn_fanout_js_1 = require("./runner-spawn-fanout.js");
60
59
  /**
@@ -269,10 +268,14 @@ class AddonRegistryService {
269
268
  // through this from the `api.addons.custom` tRPC procedure.
270
269
  customActionRegistry = new system_1.CustomActionRegistry();
271
270
  /**
272
- * Keeps `purgeRequireCacheUnder` + `import()` atomic per addon directory now
273
- * that the boot runner plan overlaps (D171). See `runner-spawn-fanout.ts`.
271
+ * Fingerprint of the custom-action catalogs each UDS child last DESCRIBED
272
+ * (keyed by child id), and the addon ids registered from that description.
273
+ * A re-register carrying the same catalogs is a no-op with no line; a child
274
+ * whose new description drops an addon has that addon's actions withdrawn.
275
+ * See {@link applyChildCustomActions}.
274
276
  */
275
- moduleLoadSerializer = (0, runner_spawn_fanout_js_1.createKeyedSerializer)();
277
+ childCatalogFingerprints = new Map();
278
+ childCatalogAddons = new Map();
276
279
  /**
277
280
  * AddonIds whose group-runner disconnect is operator-initiated (update /
278
281
  * restart / uninstall). The Moleculer `$node.disconnected` handler skips
@@ -2375,6 +2378,11 @@ class AddonRegistryService {
2375
2378
  wireCapabilityConsumers() {
2376
2379
  if (!this.capabilityRegistry)
2377
2380
  return;
2381
+ // A forked runner's custom-action catalogs arrive in its register frame,
2382
+ // not as a capability event — a non-device runner's post-init register
2383
+ // carries the same caps as its pre-init one, so no `provider-registered`
2384
+ // event marks the moment the catalogs exist. See `applyChildCustomActions`.
2385
+ this.moleculer.onChildCustomActions((child) => this.applyChildCustomActions(child));
2378
2386
  this.eventBusService.subscribe({ category: 'capability:provider-registered' }, (event) => {
2379
2387
  const rawCapability = event.data['capability'];
2380
2388
  const rawAddonId = event.data['addonId'];
@@ -3100,12 +3108,12 @@ class AddonRegistryService {
3100
3108
  catch (err) {
3101
3109
  throw new Error(`Failed to spawn runner "${runnerId}" (${addons.length} addons): ${(0, types_1.errMsg)(err)}`, { cause: err });
3102
3110
  }
3103
- // Register custom actions for each addon on the runner. Provider
3104
- // registration for cap methods is handled by
3105
- // `CapabilityBridge.onProviderConnected` once the runner's
3106
- // INFO heartbeat lands.
3111
+ // Provider registration for cap methods is handled by
3112
+ // `CapabilityBridge.onProviderConnected` once the runner's INFO heartbeat
3113
+ // lands, and the custom-action catalogs arrive the same way — in the
3114
+ // runner's post-init register frame, applied by `applyChildCustomActions`.
3115
+ // Nothing about the runner is read from its bundle here (D444).
3107
3116
  for (const { addonId } of addons) {
3108
- await this.registerForkedAddonCustomActions(addonId, runnerId);
3109
3117
  // Mark the entry initialized so the in-process core-builtin boot
3110
3118
  // passes skip it (those passes only touch `@camstack/system`).
3111
3119
  const entry = this.addonEntries.get(addonId);
@@ -3207,146 +3215,78 @@ class AddonRegistryService {
3207
3215
  }
3208
3216
  this.recentGroupRespawns.set(runnerId, Date.now());
3209
3217
  }
3210
- // 3. Bookkeeping for EVERY roster member. Custom actions live in the
3211
- // hub-side registry the process spawn never touches; a hot-update would
3212
- // otherwise silently drop them (the boot plan is the only other place
3213
- // they register). Mark each member initialized so the in-process
3214
- // core-builtin boot passes skip them.
3218
+ // 3. Bookkeeping for EVERY roster member. Mark each member initialized so
3219
+ // the in-process core-builtin boot passes skip them. The custom-action
3220
+ // catalogs are NOT touched here: the respawned runner describes them in
3221
+ // its post-init register frame and `applyChildCustomActions` replaces
3222
+ // whatever the previous incarnation registered (D444).
3215
3223
  for (const { addonId: memberId } of roster) {
3216
- await this.registerForkedAddonCustomActions(memberId, runnerId);
3217
3224
  const memberEntry = this.addonEntries.get(memberId);
3218
3225
  if (memberEntry)
3219
3226
  memberEntry.initialized = true;
3220
3227
  }
3221
3228
  }
3222
3229
  /**
3223
- * (Re-)register the custom-action catalog for a forked / group-hosted
3224
- * addon against the shared `CustomActionRegistry`.
3230
+ * Apply the custom-action catalogs a UDS child DESCRIBED in its register
3231
+ * frame (`RegisteredChild.customActions`, schemas stripped) to the hub-wide
3232
+ * `CustomActionRegistry`, dispatching each action back to that child over
3233
+ * the addon-call plane.
3225
3234
  *
3226
- * The catalog (zod `input`/`output` specs) is read STATICALLY from the
3227
- * addon module's `customActions` named export — the handler dispatches
3228
- * over UDS via `LocalChildRegistry.callAddonOnChild(addonId,
3229
- * {target:'custom', action, args})` (F3 — replaces the removed per-addon
3230
- * Moleculer `custom.<action>` action), so the only divergence vs an
3231
- * in-process addon is the transport, exactly like cap methods. The hub's
3232
- * `CustomActionRegistry` validates input/output around this dispatch.
3235
+ * ## Why the child describes, and the hub never reads the bundle
3233
3236
  *
3234
- * Why a fresh import: `this.addonLoader`'s `module` namespace is
3235
- * captured once at boot. After a hot-update (`installFromTgz`
3236
- * `restartAddon`) the on-disk bundle is newer than that cached module,
3237
- * so re-reading the boot-time `module` would register a STALE catalog
3238
- * (or none at all, if the addon was first installed after boot). We
3239
- * re-`import()` the entry with a cache-busting query so Node's ESM
3240
- * loader hands back the current bundle.
3237
+ * Until D444 this was `registerForkedAddonCustomActions`: purge the require
3238
+ * cache, re-import the addon's entry INSIDE hub-main, read its
3239
+ * `customActions` export. Measured 2026-09-10 on the live hub: 9.3 MB of
3240
+ * `addon-pipeline-orchestrator/dist/index.js` evaluated in hub-main by a
3241
+ * sampling heap profile, ten deploys in the process's 23 h life, NONE of
3242
+ * which exported a catalog and the only line saying so was at `debug`,
3243
+ * which the hub does not write. Every evaluation pins whatever module-scope
3244
+ * state the evicted graph left behind, and the bundle-reload guard existed
3245
+ * only to keep that re-execution survivable (D284). The runner is the
3246
+ * process that owns the addon; it describes its catalog once its init loop
3247
+ * has produced it, in the post-init register frame, and validates the
3248
+ * action's input and output itself (`child-addon-call-dispatch.ts`).
3241
3249
  *
3242
- * Idempotent: drops any prior registration first. No-op (with a debug
3243
- * log) when the addon exports no `customActions` — most addons don't.
3250
+ * ## What one description does
3251
+ *
3252
+ * - identical to the child's last description → nothing, silently (a
3253
+ * native-cap change or reconnect re-sends the same frame);
3254
+ * - otherwise every addon in it is (re-)registered — an EMPTY catalog
3255
+ * withdraws that addon's actions — and an addon the previous description
3256
+ * named but this one does not is withdrawn too;
3257
+ * - and ONE `info` line per applied description names what it produced,
3258
+ * including "nothing": a load that produces nothing and says nothing is
3259
+ * the branch D391 forbids.
3244
3260
  */
3245
- async registerForkedAddonCustomActions(addonId, runnerId) {
3246
- // Always clear first so a restart that REMOVES custom actions (or an
3247
- // addon whose entry no longer exports them) doesn't leave stale
3248
- // entries resolvable.
3249
- this.customActionRegistry.unregisterAddon(addonId);
3250
- const entry = this.addonEntries.get(addonId);
3251
- const addonDir = entry?.addonDir;
3252
- const declarationEntry = entry?.declaration?.entry;
3253
- if (!addonDir || !declarationEntry)
3254
- return;
3255
- // Resolve the built entry the same way `AddonLoader.loadDeclaration`
3256
- // does: `./src/x.ts` → `dist/x.js`, with index.js fallbacks.
3257
- const entryFile = declarationEntry
3258
- .replace(/^\.\//, '')
3259
- .replace(/^src\//, 'dist/')
3260
- .replace(/\.ts$/, '.js');
3261
- let entryPath = path.resolve(addonDir, entryFile);
3262
- if (!fs.existsSync(entryPath)) {
3263
- const base = entryPath.replace(/\.(js|cjs|mjs)$/, '');
3264
- const alternatives = [
3265
- `${base}.cjs`,
3266
- `${base}.mjs`,
3267
- path.resolve(addonDir, 'dist', 'index.js'),
3268
- path.resolve(addonDir, 'dist', 'index.cjs'),
3269
- path.resolve(addonDir, 'dist', 'index.mjs'),
3270
- path.resolve(addonDir, declarationEntry),
3271
- ];
3272
- entryPath = alternatives.find((p) => fs.existsSync(p)) ?? entryPath;
3273
- }
3274
- if (!fs.existsSync(entryPath))
3275
- return;
3276
- let catalog;
3277
- try {
3278
- // ── The query string does NOT bust a CommonJS bundle ──────────────
3279
- //
3280
- // Every addon here builds to CJS, and Node's ESM loader serves a CJS
3281
- // module out of the REQUIRE cache, which is keyed by the resolved
3282
- // filename with the query stripped. So `?t=<now>` below busts nothing
3283
- // for them: this function re-registered the catalog captured at hub boot
3284
- // on every restart, forever.
3285
- //
3286
- // The consequence was invisible and total: an EXISTING action kept
3287
- // working, so nothing looked broken, while a NEWLY added one could never
3288
- // become reachable without restarting the whole hub process — which
3289
- // quietly falsifies the reason bridge actions exist ("no codegen, no
3290
- // republish, no train"). Found 2026-08-08, deploying `nc.injectTestEvent`:
3291
- // the deploy succeeded, the restart logged "custom actions registered",
3292
- // and the action 404'd.
3293
- //
3294
- // Dropping the addon's own modules from the require cache is what
3295
- // actually re-reads the bundle — see `require-cache.ts` for the scoping
3296
- // and the real-path match Node's cache keys demand.
3297
- //
3298
- // The purge mutates the PROCESS-GLOBAL require cache, so purge-then-import
3299
- // must stay atomic per BUNDLE now that the boot plan overlaps (D171):
3300
- // `@camstack/addon-remote-storage` ships four addon ids whose entries all
3301
- // resolve into one `dist/`, and a sibling's purge landing mid-import would
3302
- // evict a module graph while it is being evaluated. Different bundles
3303
- // still load concurrently.
3304
- //
3305
- // The scope is the BUNDLE (`<addonDir>/dist`), never `dirname(entryPath)`
3306
- // — see `bundleGraphRootFor`. The narrow scope evicted the entry chunk and
3307
- // kept the shared chunks, so the re-import re-ran the entry's module-scope
3308
- // declarations against singletons that had survived, and took the broker
3309
- // down on 2026-08-29 (D284). The serializer key MUST match the purge
3310
- // scope: two addon ids of one package now purge the same graph, so
3311
- // serializing them per entry-folder would let one evict the other's
3312
- // modules mid-evaluation — the exact race the key exists to prevent.
3313
- const graphRoot = (0, require_cache_js_1.bundleGraphRootFor)(addonDir, entryPath);
3314
- const modUnknown = await this.moduleLoadSerializer.run(graphRoot, async () => {
3315
- (0, require_cache_js_1.purgeRequireCacheUnder)(graphRoot);
3316
- // Kept for a genuinely ESM addon entry, where it IS the mechanism.
3317
- const cacheBustedUrl = `${(0, node_url_1.pathToFileURL)(entryPath).href}?t=${Date.now()}`;
3318
- // A plain `await import()` here is downleveled by tsc (the backend builds
3319
- // with `module: CommonJS`) into a `require()`-based shim. `require()` then
3320
- // treats the `?t=…` cache-bust query as part of the literal filename and
3321
- // throws MODULE_NOT_FOUND for EVERY forked addon — so no forked addon's
3322
- // custom-action catalog ever loaded. Use a native dynamic import the
3323
- // compiler won't rewrite, so Node's ESM loader handles the query string.
3324
- const nativeImport = new Function('specifier', 'return import(specifier)');
3325
- return nativeImport(cacheBustedUrl);
3326
- });
3327
- catalog =
3328
- modUnknown && typeof modUnknown === 'object'
3329
- ? modUnknown['customActions']
3330
- : undefined;
3331
- }
3332
- catch (err) {
3333
- this.logger.warn('Failed to load custom-action catalog for forked addon', {
3334
- tags: { addonId },
3335
- meta: { error: (0, types_1.errMsg)(err) },
3336
- });
3261
+ applyChildCustomActions(child) {
3262
+ const catalogs = child.customActions;
3263
+ if (catalogs === undefined)
3337
3264
  return;
3338
- }
3339
- if (!catalog || typeof catalog !== 'object') {
3340
- this.logger.debug('Forked addon exports no custom actions', {
3341
- tags: { addonId },
3342
- meta: { runnerId },
3343
- });
3265
+ const fingerprint = (0, forked_custom_actions_js_1.childCatalogsFingerprint)(catalogs);
3266
+ if (this.childCatalogFingerprints.get(child.childId) === fingerprint)
3344
3267
  return;
3268
+ this.childCatalogFingerprints.set(child.childId, fingerprint);
3269
+ const described = new Set(Object.keys(catalogs));
3270
+ for (const previous of this.childCatalogAddons.get(child.childId) ?? []) {
3271
+ if (!described.has(previous))
3272
+ this.customActionRegistry.unregisterAddon(previous);
3273
+ }
3274
+ this.childCatalogAddons.set(child.childId, described);
3275
+ for (const [addonId, descriptor] of Object.entries(catalogs)) {
3276
+ if (Object.keys(descriptor).length === 0) {
3277
+ this.customActionRegistry.unregisterAddon(addonId);
3278
+ continue;
3279
+ }
3280
+ this.customActionRegistry.registerAddon(addonId, (0, forked_custom_actions_js_1.toForkedCustomActionsSpec)(descriptor), (action, input, caller) => this.dispatchForkedCustomAction(addonId, action, input, caller));
3345
3281
  }
3346
- this.customActionRegistry.registerAddon(addonId, catalog, (action, input, caller) => this.dispatchForkedCustomAction(addonId, action, input, caller));
3347
- this.logger.info('Runner addon custom actions registered', {
3348
- tags: { addonId },
3349
- meta: { runnerId },
3282
+ const summary = (0, forked_custom_actions_js_1.summarizeChildCatalogs)(catalogs);
3283
+ this.logger.info('UDS child custom-action catalogs applied', {
3284
+ meta: {
3285
+ childId: child.childId,
3286
+ incarnation: child.incarnation,
3287
+ registered: summary.withActions.length > 0 ? summary.withActions.join(',') : '(none)',
3288
+ withoutActions: summary.withoutActions.length > 0 ? summary.withoutActions.join(',') : '(none)',
3289
+ },
3350
3290
  });
3351
3291
  }
3352
3292
  /**
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.toForkedCustomActionsSpec = toForkedCustomActionsSpec;
4
+ exports.childCatalogsFingerprint = childCatalogsFingerprint;
5
+ exports.summarizeChildCatalogs = summarizeChildCatalogs;
6
+ const zod_1 = require("zod");
7
+ /**
8
+ * Build the hub-side catalog for a forked addon.
9
+ *
10
+ * `input`/`output` are `z.unknown()` ON PURPOSE: the hub's
11
+ * `dispatchCustomAction` parses input and output around the dispatch, and for
12
+ * a forked addon both parses must be passthroughs — the runner owns the
13
+ * schemas and refuses what they refuse. `kind`, `auth`, `scope` and `caller`
14
+ * are the fields the hub actually gates on, and they arrive intact.
15
+ */
16
+ function toForkedCustomActionsSpec(descriptor) {
17
+ const spec = {};
18
+ for (const [name, action] of Object.entries(descriptor)) {
19
+ spec[name] = {
20
+ input: zod_1.z.unknown(),
21
+ output: zod_1.z.unknown(),
22
+ kind: action.kind,
23
+ auth: action.auth,
24
+ scope: action.scope,
25
+ ...(action.caller === 'required' ? { caller: 'required' } : {}),
26
+ };
27
+ }
28
+ return spec;
29
+ }
30
+ /**
31
+ * A stable identity for a child's whole catalog set, so a re-register that
32
+ * carries the SAME catalogs (native-cap change, reconnect — 246 re-registers in
33
+ * 30 minutes with zero respawns, measured 2026-08-25) registers nothing and
34
+ * logs nothing. Key order is normalised at every level: two descriptions of
35
+ * one catalog must never fingerprint differently because a runner enumerated
36
+ * its map in another order.
37
+ */
38
+ function childCatalogsFingerprint(catalogs) {
39
+ return JSON.stringify(sortKeysDeep(catalogs));
40
+ }
41
+ function sortKeysDeep(value) {
42
+ if (Array.isArray(value))
43
+ return value.map(sortKeysDeep);
44
+ if (value === null || typeof value !== 'object')
45
+ return value;
46
+ const out = {};
47
+ for (const key of Object.keys(value).sort()) {
48
+ out[key] = sortKeysDeep(Reflect.get(value, key));
49
+ }
50
+ return out;
51
+ }
52
+ function summarizeChildCatalogs(catalogs) {
53
+ const withActions = [];
54
+ const withoutActions = [];
55
+ for (const addonId of Object.keys(catalogs).sort()) {
56
+ const count = Object.keys(catalogs[addonId] ?? {}).length;
57
+ if (count > 0)
58
+ withActions.push(`${addonId}:${count}`);
59
+ else
60
+ withoutActions.push(addonId);
61
+ }
62
+ return { withActions, withoutActions };
63
+ }
@@ -49,6 +49,15 @@ class MoleculerService {
49
49
  * See {@link ChildManifestGate}.
50
50
  */
51
51
  childManifestGate = new ChildManifestGate();
52
+ /**
53
+ * Receives every child register frame that DESCRIBES its custom-action
54
+ * catalogs (the post-init one and every re-register after it). Consulted
55
+ * BEFORE {@link ChildManifestGate}: the gate compares caps, and a post-init
56
+ * register of a non-device runner carries the same caps as its pre-init one
57
+ * — skipping it would lose the only frame that carries the catalogs (D444).
58
+ * The consumer (`AddonRegistryService`) dedupes by catalog fingerprint.
59
+ */
60
+ childCustomActionsHandler = null;
52
61
  /**
53
62
  * Fixed-period agent-readiness snapshot sweep (D8 reconcile) — repairs
54
63
  * agent-origin readiness deltas lost while the agent stayed connected.
@@ -126,6 +135,14 @@ class MoleculerService {
126
135
  get childRegistry() {
127
136
  return this.localChildRegistry;
128
137
  }
138
+ /**
139
+ * Register the (single) handler for a child's described custom-action
140
+ * catalogs. See {@link childCustomActionsHandler}. Called once by
141
+ * `AddonRegistryService` when it wires its consumers.
142
+ */
143
+ onChildCustomActions(handler) {
144
+ this.childCustomActionsHandler = handler;
145
+ }
129
146
  /** The CapRouteResolver once onModuleInit has completed; null before that. */
130
147
  get capRouteResolver() {
131
148
  return this.resolver;
@@ -437,6 +454,20 @@ class MoleculerService {
437
454
  registry.onChildRegistered((child) => {
438
455
  const hubNodeId = this.brokerSafe.nodeID;
439
456
  const childNodeId = `${hubNodeId}/${child.childId}`;
457
+ // The catalogs first, and unconditionally: the manifest gate below
458
+ // judges CAPS, and a frame whose caps are unchanged may still be the
459
+ // one frame that describes the catalogs. An absent field is a pre-init
460
+ // register and says nothing about actions — it is not forwarded.
461
+ if (child.customActions !== undefined) {
462
+ try {
463
+ this.childCustomActionsHandler?.(child);
464
+ }
465
+ catch (err) {
466
+ logger.warn('UDS child custom-action catalogs could not be applied', {
467
+ meta: { nodeId: childNodeId, incarnation: child.incarnation, error: (0, types_1.errMsg)(err) },
468
+ });
469
+ }
470
+ }
440
471
  const params = buildChildUdsManifest(childNodeId, child.childId, child.caps);
441
472
  // `updateCaps()` re-sends the whole register frame on the same socket,
442
473
  // so this fires far more often than a runner starts: 246 rebuilds in 30
@@ -132,6 +132,11 @@ class UpdateAvailabilityEmitter {
132
132
  nodeId: scope,
133
133
  }));
134
134
  const head = list[0];
135
+ // `target: 'wrapper'` carries WHICH shell, and it has to survive the
136
+ // rebuild here or the notification loses the only thing that makes it
137
+ // actionable — a docker node would be told to press a button that cannot
138
+ // exist. Absent for every other target.
139
+ const wrapperKind = head.wrapperKind;
135
140
  this.eventBus.emit({
136
141
  id: `update.available:${target}:${scope}:${packages
137
142
  .map((pkg) => `${pkg.packageName}@${pkg.latestVersion}`)
@@ -148,6 +153,7 @@ class UpdateAvailabilityEmitter {
148
153
  packages,
149
154
  nodeId: scope,
150
155
  nodeIds: [scope],
156
+ ...(wrapperKind !== undefined ? { wrapperKind } : {}),
151
157
  },
152
158
  });
153
159
  }
@@ -98,6 +98,9 @@ class UpdateCheckScheduler {
98
98
  continue;
99
99
  }
100
100
  await this.attempt('node server package', node.id, () => this.targets.checkNodeServerUpdate(node.id, node.isHub));
101
+ // The shell, on EVERY node role — the hub is the one this failure was
102
+ // found on.
103
+ await this.attempt('node wrapper', node.id, () => this.targets.checkWrapperUpdate(node.id, node.isHub));
101
104
  if (node.isHub)
102
105
  continue;
103
106
  await this.attempt('agent addon packages', node.id, () => this.targets.checkAgentAddons(node.id));
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WrapperUpdateChecker = exports.WRAPPER_PACKAGE_NAME = void 0;
4
+ /**
5
+ * The availability row's package name. A shell is not an npm package, but the
6
+ * emitter keys dedup on `(target, node, packageName)` and a stable literal is
7
+ * what makes "one shell per node" the dedup slot. Never rendered: the
8
+ * notification body is chosen from the wrapper KIND.
9
+ */
10
+ exports.WRAPPER_PACKAGE_NAME = 'camstack-wrapper';
11
+ class WrapperUpdateChecker {
12
+ emitter;
13
+ logger;
14
+ readStatus;
15
+ constructor(options) {
16
+ this.emitter = options.emitter;
17
+ this.logger = options.logger;
18
+ this.readStatus = options.readStatus;
19
+ }
20
+ async check(nodeId, isHub) {
21
+ const status = await this.readStatus(nodeId, isHub);
22
+ const wrapper = status.wrapper;
23
+ const contract = status.imageContract;
24
+ if (wrapper === undefined || contract === undefined) {
25
+ // Version skew: this node's `@camstack/server` predates the wrapper
26
+ // field. Unanswerable, so it is an ERROR — reporting it as "no wrapper
27
+ // update" would be a claim nothing checked.
28
+ throw new Error(`node ${nodeId} reports no wrapper identity (server-management payload predates it)`);
29
+ }
30
+ if (contract.state === 'in-sync') {
31
+ // The one case where an empty snapshot is a fact rather than a guess.
32
+ this.emitter.publishSnapshot('wrapper', [], nodeId);
33
+ return;
34
+ }
35
+ const seedVersion = contract.seedVersion;
36
+ const contractVersion = contract.contractVersion;
37
+ const behind = contract.state === 'behind-patch' || contract.state === 'behind-series';
38
+ if (!behind || seedVersion === null || contractVersion === null) {
39
+ // `unknown` (no seed, or no registry answer yet) and `ahead` (a stale
40
+ // check) both mean "nothing was proved". Publishing an empty snapshot
41
+ // would clear a standing announcement on no evidence at all.
42
+ this.logger.warn('Wrapper check inconclusive — nothing published', {
43
+ tags: { nodeId },
44
+ meta: { state: contract.state, wrapperKind: wrapper.kind, seedVersion, contractVersion },
45
+ });
46
+ return;
47
+ }
48
+ const candidate = {
49
+ target: 'wrapper',
50
+ packageName: exports.WRAPPER_PACKAGE_NAME,
51
+ // The SHELL's versions, not the running code's. `wrapper.currentVersion`
52
+ // is what the shell says about ITSELF (an Electron app version), which
53
+ // is not on the same scale as a release; the seed is, and the seed is
54
+ // what `contractVersion` was compared against.
55
+ currentVersion: seedVersion,
56
+ latestVersion: contractVersion,
57
+ nodeId,
58
+ wrapperKind: wrapper.kind,
59
+ };
60
+ this.emitter.publishSnapshot('wrapper', [candidate], nodeId);
61
+ }
62
+ }
63
+ exports.WrapperUpdateChecker = WrapperUpdateChecker;