@camstack/server 1.2.99 → 1.2.101

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.
@@ -488,6 +488,12 @@ async function startAgent(configPath) {
488
488
  args: input.args,
489
489
  ...(input.deviceId !== undefined ? { deviceId: input.deviceId } : {}),
490
490
  ...(input.nodeId !== undefined ? { nodeId: input.nodeId } : {}),
491
+ // The native-only hint must survive the forward: the agent has no
492
+ // native authority of its own (no CapRouteResolver, no cluster
493
+ // CapabilityRegistry), so the hub answers "native or nothing" on
494
+ // its behalf. Dropping it here would let the hub resolve the cap
495
+ // NAME instead — i.e. the wrapper the caller is trying to skip.
496
+ ...(input.native === true ? { native: true } : {}),
491
497
  }, { timeout: 60_000 }),
492
498
  logger: { warn: (msg) => consoleLogger.warn(`[uds-fallback] ${msg}`) },
493
499
  });
@@ -4,6 +4,30 @@ exports.createAddonSettingsRouter = createAddonSettingsRouter;
4
4
  /**
5
5
  * Addon settings router — raw DB proxy for the common settings API.
6
6
  *
7
+ * ## ⚠️ This is NOT the store `ctx.settings.readAddonStore()` reads
8
+ *
9
+ * Two different physical stores wear the name "addon-settings", and mistaking
10
+ * one for the other costs a whole debugging session — it did on 2026-08-13,
11
+ * where a per-node inference override was written here, read back here
12
+ * (looking perfectly correct), and was invisible to the addon forever:
13
+ *
14
+ * | Surface | Physical shape | Read by |
15
+ * | --- | --- | --- |
16
+ * | THIS router (`ConfigService.getAddonConfig` → `getAllAddon`) | the `addon-settings` table, **one row per key**, `id = "<addonId>.<key>"`, `data = {addonId, key, value}` | `buildAddonConfig` → `ctx.addonConfig`, and `virtual-doorbell` |
17
+ * | `settings-store` cap (`ctx.settings.readAddonStore()`) | **ONE blob row**, namespace `<addonId>`, collection `addon-settings`, key `root` | `BaseAddon.resolveConfig` / every addon's global settings |
18
+ *
19
+ * Nothing bridges them. To write an addon's global setting — including a
20
+ * `perNode: true` key — go through the `addonSettings` CAP
21
+ * (`updateGlobalSettings`, which applies the schema's per-node scoping), or
22
+ * read it back with
23
+ * `settingsStore.get {namespace:<addonId>, collection:'addon-settings', key:'root'}`.
24
+ * `addonSettingsRaw.getGlobal` answering `{}` for an addon that plainly has
25
+ * settings is the tell.
26
+ *
27
+ * `setAllAddon` is also a **bulk replace**: `updateGlobal` read-modify-writes
28
+ * the whole per-key set, so a write here deletes every other row of that addon
29
+ * in this table.
30
+ *
7
31
  * Exposes four protected procedures consumed by:
8
32
  * 1. Forked addons (via the tRPC WSS client in `WorkerBootstrapService`)
9
33
  * to read/write their 3-level settings chain from the worker process.
@@ -63,11 +63,17 @@ const GetRecentInputSchema = ScopeFieldsSchema.extend({
63
63
  limit: zod_1.z.number().int().min(1).max(500).optional(),
64
64
  });
65
65
  /**
66
- * `subscribe` keeps category as a single string. The bus's
67
- * `extractCategoryPattern` keys handlers by a single pattern; passing
68
- * an array would silently route only the first category. UIs that
69
- * need multi-category live filtering must subscribe by deviceId
70
- * (single-category-per-call or wildcard) and narrow client-side.
66
+ * `subscribe` keeps category as a single string a WIRE choice now, not a bus
67
+ * limitation.
68
+ *
69
+ * It used to be a limitation, and a silent one: the bus keyed handlers by
70
+ * `extractCategoryPattern`, so an array filter routed only its FIRST category
71
+ * and dropped the rest with no error. `extractCategoryPatterns` +
72
+ * `registerSubscriber` (2026-08-12) register a subscriber under every category
73
+ * it names, so an array here would now work. The single string stays because
74
+ * one live SSE stream per category is what every consumer of this endpoint
75
+ * actually opens; a UI that wants several can open several or subscribe by
76
+ * wildcard and narrow client-side.
71
77
  */
72
78
  const SubscribeInputSchema = ScopeFieldsSchema.extend({
73
79
  category: zod_1.z.string().optional(),
@@ -7,10 +7,31 @@
7
7
  * (`index.html`) always revalidate so a redeploy actually reaches clients.
8
8
  */
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.spaShellCacheControl = spaShellCacheControl;
10
11
  exports.spaAssetCacheControl = spaAssetCacheControl;
11
12
  exports.addonBundleCacheControl = addonBundleCacheControl;
12
13
  exports.contentTypeForPath = contentTypeForPath;
13
14
  exports.isRetiredPublicPath = isRetiredPublicPath;
15
+ /**
16
+ * Cache-Control for the SPA SHELL (`index.html`) — the HTML entry only, never a
17
+ * hashed asset.
18
+ *
19
+ * The shell is the one file that must never survive a deploy: it carries the
20
+ * `<script src="assets/index-<hash>.js">` pointers, so a stale copy loads a
21
+ * build whose hashed assets no longer exist — the operator sees the previous UI
22
+ * or a blank page and has to force-refresh. It is a few KB, and every in-app
23
+ * navigation is client-side, so re-fetching it costs nothing measurable.
24
+ *
25
+ * `no-store`, not `no-cache`: the shell is streamed with no ETag and no
26
+ * Last-Modified, so a revalidation could never answer 304 anyway — and
27
+ * `no-store` additionally keeps the response out of the heuristic and
28
+ * back/forward caches that made this visible. The addon-route fallback used to
29
+ * send the shell with NO header at all, which is where the stale copies came
30
+ * from.
31
+ */
32
+ function spaShellCacheControl() {
33
+ return 'no-store';
34
+ }
14
35
  /**
15
36
  * Cache-Control value for a static SPA asset addressed by its dist-relative
16
37
  * path. Policy (shared by admin-ui + viewer-ui):
@@ -18,7 +39,10 @@ exports.isRetiredPublicPath = isRetiredPublicPath;
18
39
  * → `no-cache, must-revalidate` (PWA update propagation).
19
40
  * - Content-hashed build assets under `assets/` (Vite) or `_expo/` (Expo
20
41
  * web export) → `public, max-age=31536000, immutable`.
21
- * - Everything else (index.html, favicon, non-hashed files) → `no-cache`.
42
+ * - The shell requested BY NAME (`/index.html`) reaches this function rather
43
+ * than the shell branch of the handler — it gets {@link spaShellCacheControl}
44
+ * so the same bytes never carry two different policies.
45
+ * - Everything else (favicon, non-hashed files) → `no-cache`.
22
46
  */
23
47
  function spaAssetCacheControl(rel) {
24
48
  const base = rel.split('/').pop() ?? rel;
@@ -32,6 +56,8 @@ function spaAssetCacheControl(rel) {
32
56
  if (inAssetDir && /-[A-Za-z0-9_-]{8,}\./.test(base)) {
33
57
  return 'public, max-age=31536000, immutable';
34
58
  }
59
+ if (base === 'index.html')
60
+ return spaShellCacheControl();
35
61
  return 'no-cache';
36
62
  }
37
63
  /**
@@ -7499,6 +7499,51 @@ function createCapRouter_recording(getProvider, createRemoteProxy) {
7499
7499
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
7500
7500
  return p.cancelStorageMigrationMove(methodInput);
7501
7501
  }),
7502
+ relocateFootage: trpc_middleware_js_1.adminProcedure
7503
+ .input(types_92.recordingCapability.methods.relocateFootage.input.loose())
7504
+ .output(types_92.recordingCapability.methods.relocateFootage.output)
7505
+ .mutation(async ({ input, ctx }) => {
7506
+ const { nodeId, ...methodInput } = input;
7507
+ const p = resolveProvider('recording', nodeId, () => getProvider(ctx), createRemoteProxy);
7508
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
7509
+ return p.relocateFootage(methodInput);
7510
+ }),
7511
+ listRelocateJobs: trpc_middleware_js_1.adminProcedure
7512
+ .input(types_92.recordingCapability.methods.listRelocateJobs.input.loose())
7513
+ .output(types_92.recordingCapability.methods.listRelocateJobs.output)
7514
+ .query(async ({ input, ctx }) => {
7515
+ const { nodeId, ...methodInput } = input;
7516
+ const p = resolveProvider('recording', nodeId, () => getProvider(ctx), createRemoteProxy);
7517
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
7518
+ return p.listRelocateJobs(methodInput);
7519
+ }),
7520
+ cancelRelocateJob: trpc_middleware_js_1.adminProcedure
7521
+ .input(types_92.recordingCapability.methods.cancelRelocateJob.input.loose())
7522
+ .output(types_92.recordingCapability.methods.cancelRelocateJob.output)
7523
+ .mutation(async ({ input, ctx }) => {
7524
+ const { nodeId, ...methodInput } = input;
7525
+ const p = resolveProvider('recording', nodeId, () => getProvider(ctx), createRemoteProxy);
7526
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
7527
+ return p.cancelRelocateJob(methodInput);
7528
+ }),
7529
+ planStorageRebalance: trpc_middleware_js_1.adminProcedure
7530
+ .input(types_92.recordingCapability.methods.planStorageRebalance.input.loose())
7531
+ .output(types_92.recordingCapability.methods.planStorageRebalance.output)
7532
+ .query(async ({ input, ctx }) => {
7533
+ const { nodeId, ...methodInput } = input;
7534
+ const p = resolveProvider('recording', nodeId, () => getProvider(ctx), createRemoteProxy);
7535
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
7536
+ return p.planStorageRebalance(methodInput);
7537
+ }),
7538
+ startStorageRebalance: trpc_middleware_js_1.adminProcedure
7539
+ .input(types_92.recordingCapability.methods.startStorageRebalance.input.loose())
7540
+ .output(types_92.recordingCapability.methods.startStorageRebalance.output)
7541
+ .mutation(async ({ input, ctx }) => {
7542
+ const { nodeId, ...methodInput } = input;
7543
+ const p = resolveProvider('recording', nodeId, () => getProvider(ctx), createRemoteProxy);
7544
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
7545
+ return p.startStorageRebalance(methodInput);
7546
+ }),
7502
7547
  });
7503
7548
  }
7504
7549
  function createCapRouter_recordingExport(getProvider, createRemoteProxy) {
@@ -666,14 +666,15 @@ class AddonRegistryService {
666
666
  // one were declared — the mechanism stays available as an explicit
667
667
  // opt-in for future co-location needs.
668
668
  //
669
- // `@camstack/system` builtins and `placement: 'agent-only'` addons are
670
- // excluded from the plan (filtered inside `buildAddonGroupPlan`).
669
+ // `placement: 'agent-only'` addons and `@camstack/system` builtins are
670
+ // excluded from the plan (filtered inside `buildAddonGroupPlan`) — except
671
+ // a builtin declaring `execution.isolate`, which is spawned AFTER the
672
+ // in-process passes below (see `spawnRunnerPlan`'s two calls).
671
673
  // A failed runner spawn is logged and the addon surfaces as
672
674
  // `addon.error` — there is NO in-process-on-the-hub fallback (it
673
675
  // would violate one-addon-one-process). Retry policy for a failed
674
676
  // spawn is governed by the kernel circuit-breaker.
675
- {
676
- const plan = this.buildAddonGroupPlan(allIds);
677
+ const spawnRunnerPlan = async (plan) => {
677
678
  for (const [runnerId, runnerAddons] of plan) {
678
679
  try {
679
680
  await this.initializeAddonGroup(runnerId, runnerAddons);
@@ -694,7 +695,24 @@ class AddonRegistryService {
694
695
  });
695
696
  }
696
697
  }
697
- }
698
+ };
699
+ // An ISOLATED builtin is a builtin: everything it stores lives behind
700
+ // `settings-store`, which is one of the in-process builtins booted below.
701
+ // Spawned here it would race them, and the failure would not look like a
702
+ // race — the snapshot wrapper mints its link-signing secret at init, so a
703
+ // store that answers "not ready" once yields either no signed-link plane
704
+ // for the life of the process or a NEW secret that invalidates every link
705
+ // a client is holding. Ordinary addons tolerate the same window because
706
+ // `readAddonStoreWithRetry` rides it out; an isolated builtin does not
707
+ // need to be lucky, because it can simply be spawned second.
708
+ const isolatedBuiltinIds = allIds.filter((id) => {
709
+ const entry = this.addonEntries.get(id);
710
+ return (entry?.packageName === '@camstack/system' &&
711
+ entry.declaration !== undefined &&
712
+ (0, types_1.isIsolatedBuiltin)(entry.declaration));
713
+ });
714
+ const isolatedBuiltins = new Set(isolatedBuiltinIds);
715
+ await spawnRunnerPlan(this.buildAddonGroupPlan(allIds.filter((id) => !isolatedBuiltins.has(id))));
698
716
  // In-process boot for `@camstack/system` builtins. Core builtins stay
699
717
  // resident on the hub process — they provide the storage / settings
700
718
  // / logging infrastructure every forked runner depends on, reachable
@@ -707,8 +725,12 @@ class AddonRegistryService {
707
725
  // A core builtin is identified by `packageName === "@camstack/system"`,
708
726
  // NOT by the absence of an `execution` declaration — a builtin may
709
727
  // still declare `execution` (e.g. `platform-probe`); the package
710
- // boundary is the selection criterion.
711
- const isCoreBuiltin = (id) => this.addonEntries.get(id)?.packageName === '@camstack/system';
728
+ // boundary is the selection criterion. The ONE exception is
729
+ // `execution.isolate`, which says "fork me": such a builtin must never
730
+ // boot in-process here, not even when its runner spawn failed. A spawn
731
+ // failure surfaces as `addon.error`; quietly running the addon on the hub
732
+ // instead would defeat the isolation on exactly the day it is needed.
733
+ const isCoreBuiltin = (id) => this.addonEntries.get(id)?.packageName === '@camstack/system' && !isolatedBuiltins.has(id);
712
734
  // Pass 1 — infrastructure builtins. A failed REQUIRED infra cap
713
735
  // aborts boot: nothing downstream can run without storage/settings.
714
736
  for (const infra of system_1.INFRA_CAPABILITIES) {
@@ -797,6 +819,15 @@ class AddonRegistryService {
797
819
  }
798
820
  }
799
821
  }
822
+ // Isolated builtins, now that storage / settings / logging are resident.
823
+ // Same authority (`buildAddonGroupPlan`), same failure semantics — only the
824
+ // ORDER differs, and the order is the point.
825
+ if (isolatedBuiltinIds.length > 0) {
826
+ this.logger.info('Spawning isolated system builtins', {
827
+ meta: { addonIds: isolatedBuiltinIds },
828
+ });
829
+ await spawnRunnerPlan(this.buildAddonGroupPlan(isolatedBuiltinIds));
830
+ }
800
831
  const initializedIds = [...this.addonEntries.entries()]
801
832
  .filter(([, e]) => e.initialized)
802
833
  .map(([id]) => id);
@@ -1740,14 +1771,14 @@ class AddonRegistryService {
1740
1771
  * in-process by this predicate, so its route-mount took the co-located
1741
1772
  * path and received the async UDS cap proxy whose `getRoutes()` returns
1742
1773
  * a Promise (→ `getRoutes(...).map is not a function`). Only
1743
- * `@camstack/system` builtins boot in-process on the hub. The type
1744
- * predicate narrows both `addonDir` and `declaration` for callers.
1774
+ * `@camstack/system` builtins boot in-process on the hub except one that
1775
+ * declares `execution.isolate`, which forks like anything else. Both
1776
+ * questions are answered by `runsInOwnRunner`, so the two can no longer
1777
+ * disagree. The type predicate narrows both `addonDir` and `declaration`
1778
+ * for callers.
1745
1779
  */
1746
1780
  isForkedAddonEntry(entry) {
1747
- return !!(entry.declaration &&
1748
- entry.addonDir &&
1749
- entry.packageName !== '@camstack/system' &&
1750
- (0, types_1.resolveAddonPlacement)(entry.declaration) !== 'agent-only');
1781
+ return !!(entry.declaration && entry.addonDir && this.runsInOwnRunner(entry));
1751
1782
  }
1752
1783
  /** Per-entry helper used by `listAddons()` to mark a row removable / not. */
1753
1784
  isRequiredEntry(entry) {
@@ -2798,12 +2829,18 @@ class AddonRegistryService {
2798
2829
  * passes a live JS reference across a process boundary any more.
2799
2830
  *
2800
2831
  * Addons with `placement: 'agent-only'` are dropped (they don't run on
2801
- * the hub). `@camstack/system` builtins are dropped too — they stay
2802
- * in-process on the hub (every forked runner connects to the hub
2803
- * broker via static-URL TCP transit, so cap providers hosted on the
2804
- * hub broker are reachable from any runner with one hop; moving them
2805
- * into a sibling subprocess would isolate them on a separate Moleculer
2806
- * node that child brokers cannot reach).
2832
+ * the hub). `@camstack/system` builtins are dropped too — UNLESS the
2833
+ * manifest entry declares `execution.isolate: true`.
2834
+ *
2835
+ * The reason builtins stay in-process is that the infrastructure ones
2836
+ * (storage, settings, logging, device-manager) must live in the process
2837
+ * every runner depends on. It is NOT, as the comment here used to claim,
2838
+ * that a subprocess would be unreachable: `docs/architecture/transport.md`
2839
+ * is explicit that there is no per-addon Moleculer broker and that a child
2840
+ * reaches the hub AND ITS SIBLINGS over the UDS channel —
2841
+ * `LocalChildRegistry` implements exactly that. The old claim was wrong and
2842
+ * nearly stopped the snapshot split; `docs/decisions/adr-0113.md` records
2843
+ * what replaced it.
2807
2844
  *
2808
2845
  * Read-only. Used by the bootstrap to plan spawns up-front and by
2809
2846
  * diagnostics that want to render the current topology.
@@ -2814,10 +2851,7 @@ class AddonRegistryService {
2814
2851
  const entry = this.addonEntries.get(id);
2815
2852
  if (!entry?.declaration || !entry.addonDir)
2816
2853
  continue;
2817
- if (entry.packageName === '@camstack/system')
2818
- continue;
2819
- const placement = (0, types_1.resolveAddonPlacement)(entry.declaration);
2820
- if (placement === 'agent-only')
2854
+ if (!this.runsInOwnRunner(entry))
2821
2855
  continue;
2822
2856
  const runnerId = (0, types_1.resolveRunnerId)(entry.declaration, id);
2823
2857
  const bucket = plan.get(runnerId) ?? [];
@@ -2826,6 +2860,26 @@ class AddonRegistryService {
2826
2860
  }
2827
2861
  return plan;
2828
2862
  }
2863
+ /**
2864
+ * THE fork predicate: does this entry boot in its own runner subprocess?
2865
+ *
2866
+ * Every consumer of "forked vs in-process" asks THIS — the runner plan, the
2867
+ * in-process boot passes, route mounts, data-plane mounts, restart and
2868
+ * uninstall. They are required to agree, and when they did not
2869
+ * (`isForkedAddonEntry` demanding an `execution` block the plan did not)
2870
+ * `auth-oidc` was forked at boot yet classified in-process, and its routes
2871
+ * were registered against an async UDS proxy (`getRoutes(...).map is not a
2872
+ * function`).
2873
+ */
2874
+ runsInOwnRunner(entry) {
2875
+ if (!entry.declaration || !entry.addonDir)
2876
+ return false;
2877
+ if ((0, types_1.resolveAddonPlacement)(entry.declaration) === 'agent-only')
2878
+ return false;
2879
+ if (entry.packageName !== '@camstack/system')
2880
+ return true;
2881
+ return (0, types_1.isIsolatedBuiltin)(entry.declaration);
2882
+ }
2829
2883
  /**
2830
2884
  * Spawn ONE runner subprocess that hosts every addon in `addons` and
2831
2885
  * register their custom-actions catalogs against the shared
@@ -261,6 +261,17 @@ class MoleculerService {
261
261
  // handler retries the hub-local route then throws a precise error rather
262
262
  // than the unroutable broker fallback (`switch.switch.getStatus`).
263
263
  isDeviceNativeCap: (capName) => this.capabilityService.getRegistry()?.getDefinition(capName)?.deviceNative === true,
264
+ // `native: true` route — a forked WRAPPER asking for the per-device
265
+ // native behind its own cap name (the snapshot wrapper, once it moved
266
+ // out of hub-main). `getNativeProvider` is the single authority that
267
+ // can tell the two apart: in-process natives, hub-local vendor children
268
+ // (via `setNativeFallback` → `resolveHubLocalUdsRoute`, which skips any
269
+ // in-hub provider shadowing the name) and remote agents. Every other
270
+ // route here resolves by cap name and would hand the wrapper back to
271
+ // itself.
272
+ resolveNative: (capName, deviceId) => this.capabilityService
273
+ .getRegistry()
274
+ ?.getNativeProvider(capName, deviceId) ?? null,
264
275
  // `nodeIdMode: 'data'` signal: for these caps (`addon-settings`,
265
276
  // `addons`, `nodes`, `pipeline-orchestrator`) an inline `args.nodeId`
266
277
  // is provider DATA — the hub singleton dispatches internally — never a
@@ -0,0 +1,209 @@
1
+ "use strict";
2
+ /**
3
+ * What a node WOULD install at first boot once the image stops carrying an
4
+ * addon seed tree — [D45](../../../docs/decisions/adr-0045.md), task 32.
5
+ *
6
+ * Operator directive, 2026-08-12: the docker seed stays for `@camstack/server`
7
+ * and the system packages ("una copia di server e dei pacchetti di sistema"),
8
+ * and *"tutti gli altri addons devono rimanere copia unica, sempre, installati
9
+ * durante l'avvio iniziale"*. Dropping `/opt/camstack-seed-addons` moves the
10
+ * version decision from image-build time — where the Dockerfile installs the
11
+ * exact version the hub closure resolved — to boot time. Something then has to
12
+ * answer two questions the seed answered implicitly:
13
+ *
14
+ * 1. **Which version?** The closure's own `package.json` is the pin, and it is
15
+ * not always there: `@camstack/server` 1.2.90…1.2.94 were published with
16
+ * `"@camstack/addon-pipeline": "*"` (pin-root-deps did not run on those
17
+ * releases), 1.2.96+ carry exact versions. Unpinned resolves to `latest`,
18
+ * and that is REPORTED rather than hidden — a node that installed
19
+ * latest-of-everything cannot answer "what shipped here".
20
+ * 2. **What about the copy already on disk?** It wins, always
21
+ * ([D90](../../../docs/decisions/adr-0090.md)). The live hub runs
22
+ * `addon-pipeline` 1.2.69 against an image seed of 1.2.64; replacing a
23
+ * deployed copy with the closure's number is exactly the rollback the
24
+ * unconditional `cp -a` caused. So an installed copy is ADOPTED — and a gap
25
+ * against the pin is reported, never repaired behind the operator's back.
26
+ *
27
+ * Everything here is pure: the caller supplies the roster, the pins and what is
28
+ * on disk. It installs nothing and it decides nothing about booting — the
29
+ * launcher runs it in OBSERVE mode, which prints the plan and changes no
30
+ * behaviour.
31
+ */
32
+ Object.defineProperty(exports, "__esModule", { value: true });
33
+ exports.resolveWantedVersion = resolveWantedVersion;
34
+ exports.compareVersions = compareVersions;
35
+ exports.bootstrapPinsFrom = bootstrapPinsFrom;
36
+ exports.planFirstBootAddons = planFirstBootAddons;
37
+ exports.formatFirstBootPlan = formatFirstBootPlan;
38
+ /** A plain `major.minor.patch`, optionally with a prerelease/build suffix. */
39
+ const EXACT_VERSION = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z-.]+)?$/;
40
+ /**
41
+ * The version a fetch should ask for, given whatever the closure declared.
42
+ *
43
+ * Only an EXACT version is a pin. A range (`^1.2.0`) is deliberately not
44
+ * honoured: `installFromNpm`'s registry path resolves exact versions and
45
+ * dist-tags only (ranges fall back to `npm pack`), so pretending to support one
46
+ * here would produce a spec the fetch cannot use. Everything else — `*`, a
47
+ * range, an absent dep — becomes `latest`, flagged as unpinned so the caller
48
+ * can say it out loud.
49
+ */
50
+ function resolveWantedVersion(raw) {
51
+ if (raw !== undefined && EXACT_VERSION.test(raw))
52
+ return { spec: raw, pinned: true };
53
+ return { spec: 'latest', pinned: false };
54
+ }
55
+ /**
56
+ * Compare two plain versions. `1` when `a` is newer, `-1` when older, `0` when
57
+ * equal, `null` when either side is not a plain `x.y.z`.
58
+ *
59
+ * A local comparator rather than `@camstack/node-root`'s: that package is
60
+ * `private: true` and is ABSENT from the server closure, and this module is
61
+ * reached from `launcher.ts`, which is `tsc` output rather than a bundle — an
62
+ * import of it would be an unresolvable `require` at boot (the trap recorded in
63
+ * `docs/architecture/update-and-release.md`). Prerelease ordering is not
64
+ * modelled: nothing in the bootstrap roster ships one, and guessing at
65
+ * prerelease precedence would be a second, subtly different semver.
66
+ */
67
+ function compareVersions(a, b) {
68
+ if (!EXACT_VERSION.test(a) || !EXACT_VERSION.test(b))
69
+ return null;
70
+ const parse = (v) => v
71
+ .split(/[-+]/)[0]
72
+ .split('.')
73
+ .map((n) => Number.parseInt(n, 10));
74
+ const left = parse(a);
75
+ const right = parse(b);
76
+ for (let i = 0; i < 3; i++) {
77
+ const l = left[i] ?? 0;
78
+ const r = right[i] ?? 0;
79
+ if (l !== r)
80
+ return l > r ? 1 : -1;
81
+ }
82
+ return 0;
83
+ }
84
+ /** The `@camstack/*` half of a closure manifest's dependencies — the pins. */
85
+ function bootstrapPinsFrom(manifest) {
86
+ const out = {};
87
+ for (const [name, spec] of Object.entries(manifest.dependencies ?? {})) {
88
+ if (name.startsWith('@camstack/'))
89
+ out[name] = spec;
90
+ }
91
+ return out;
92
+ }
93
+ /** The plan. Pure; installs nothing. */
94
+ function planFirstBootAddons(input) {
95
+ const provided = new Set(input.closureProvided);
96
+ const decisions = input.required.map((pkg) => decide(pkg, input.closurePins[pkg], input.installed, provided));
97
+ const wouldInstall = decisions.filter((d) => d.action === 'install-npm').map((d) => d.pkg);
98
+ return {
99
+ decisions,
100
+ wouldInstall,
101
+ unpinned: decisions
102
+ .filter((d) => d.action === 'install-npm' && !d.want.pinned)
103
+ .map((d) => d.pkg),
104
+ divergent: decisions.filter((d) => d.divergent).map((d) => d.pkg),
105
+ needsRegistry: wouldInstall.length > 0,
106
+ };
107
+ }
108
+ function decide(pkg, pin, installed, closureProvided) {
109
+ const want = resolveWantedVersion(pin);
110
+ const hasEntry = Object.hasOwn(installed, pkg);
111
+ const version = installed[pkg] ?? null;
112
+ // Host-provided and reachable: a copy under the addon root WINS over the
113
+ // closure and nothing refreshes it — the shape that left both agents 17
114
+ // versions behind. Same rule as `shouldSkipClosureProvidedSeed`.
115
+ if (closureProvided.has(pkg)) {
116
+ return {
117
+ pkg,
118
+ action: 'skip-closure-provided',
119
+ want,
120
+ installedVersion: version,
121
+ divergent: false,
122
+ reason: 'provided by the server closure — a copy here would shadow it (D15)',
123
+ };
124
+ }
125
+ if (version === null) {
126
+ return {
127
+ pkg,
128
+ action: 'install-npm',
129
+ want,
130
+ installedVersion: null,
131
+ divergent: false,
132
+ reason: hasEntry
133
+ ? `installed copy has an unreadable package.json — fetching ${want.spec}`
134
+ : want.pinned
135
+ ? `absent — fetch ${want.spec}, the version the running closure pins`
136
+ : 'absent, and the closure carries no pin — fetching latest',
137
+ };
138
+ }
139
+ return {
140
+ pkg,
141
+ action: 'adopt-installed',
142
+ want,
143
+ installedVersion: version,
144
+ ...adoptionVerdict(version, want),
145
+ };
146
+ }
147
+ /**
148
+ * Whether an installed copy agrees with the pin — and, when it does not, in
149
+ * which direction. The copy is adopted either way: `<dataDir>/addons` is the
150
+ * runtime authority and an image number must never outrank it (D90). What
151
+ * changes is whether the boot says something about it.
152
+ */
153
+ function adoptionVerdict(version, want) {
154
+ if (!want.pinned) {
155
+ return { divergent: false, reason: `installed ${version}; the closure carries no pin to check` };
156
+ }
157
+ const order = compareVersions(version, want.spec);
158
+ if (order === null) {
159
+ return { divergent: true, reason: `installed ${version} is not comparable to pin ${want.spec}` };
160
+ }
161
+ if (order === 0)
162
+ return { divergent: false, reason: `installed ${version} matches the pin` };
163
+ const sameMajor = version.split('.')[0] === want.spec.split('.')[0];
164
+ if (!sameMajor) {
165
+ return {
166
+ divergent: true,
167
+ reason: `installed ${version} is a different major from the pinned ${want.spec} — a framework contract, not a version gap`,
168
+ };
169
+ }
170
+ if (order > 0) {
171
+ return {
172
+ divergent: false,
173
+ reason: `installed ${version} is ahead of the pinned ${want.spec} — a deploy outranks the image (D90)`,
174
+ };
175
+ }
176
+ return {
177
+ divergent: true,
178
+ reason: `installed ${version} is BEHIND the pinned ${want.spec} — kept, because replacing it is an un-deploy`,
179
+ };
180
+ }
181
+ /**
182
+ * The report.
183
+ *
184
+ * Always states the roster it checked and how much work it found, even when
185
+ * that is none: a boot that prints nothing is indistinguishable from a boot
186
+ * where the check did not run, and this repo has paid for that twice. In
187
+ * `observe` the lines say so explicitly — an observe line that reads like an
188
+ * install is worse than no line at all.
189
+ */
190
+ function formatFirstBootPlan(plan, mode) {
191
+ const header = `first-boot addon plan (${mode}) — ${plan.decisions.length} package(s), ` +
192
+ `${plan.wouldInstall.length} to install, ${plan.divergent.length} divergent`;
193
+ const lines = [
194
+ mode === 'observe' ? `${header}; nothing was installed from this plan` : header,
195
+ ];
196
+ for (const d of plan.decisions) {
197
+ const target = d.action === 'install-npm' ? `@${d.want.spec}` : '';
198
+ lines.push(` ${d.pkg}${target} — ${d.action}: ${d.reason}`);
199
+ }
200
+ if (plan.unpinned.length > 0) {
201
+ lines.push(` unpinned (would resolve the registry's latest): ${plan.unpinned.join(', ')} — ` +
202
+ 'the running closure declares no exact version for these, so what a node ends ' +
203
+ 'up with depends on when it booted');
204
+ }
205
+ if (!plan.needsRegistry) {
206
+ lines.push(' no registry access needed — every required addon is already on disk');
207
+ }
208
+ return lines;
209
+ }
package/dist/launcher.js CHANGED
@@ -54,12 +54,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
54
54
  const fs = __importStar(require("node:fs"));
55
55
  const os = __importStar(require("node:os"));
56
56
  const path = __importStar(require("node:path"));
57
- const tar = __importStar(require("tar"));
58
57
  const yaml = __importStar(require("js-yaml"));
59
- const framework_nodepath_js_1 = require("./framework-nodepath.js");
58
+ const tar = __importStar(require("tar"));
60
59
  const addon_root_inventory_js_1 = require("./addon-root-inventory.js");
61
- const package_inventory_js_1 = require("./package-inventory.js");
62
60
  const bootstrap_packages_js_1 = require("./bootstrap-packages.js");
61
+ const first_boot_addon_plan_js_1 = require("./first-boot-addon-plan.js");
62
+ const framework_nodepath_js_1 = require("./framework-nodepath.js");
63
+ const package_inventory_js_1 = require("./package-inventory.js");
63
64
  /** Path of the manifest file embedded inside every archive. */
64
65
  const ARCHIVE_MANIFEST_NAME = '.camstack-backup-manifest.json';
65
66
  /** Resolve the data directory from env or default */
@@ -206,15 +207,44 @@ function readBootstrapInstallSource(dataDir, bootstrapSchema) {
206
207
  * `launch()` on import, so nothing declared here can be imported by a spec).
207
208
  */
208
209
  function deriveBootstrapFromSelf() {
210
+ const pkg = readOwnManifest();
211
+ return pkg === null ? [] : (0, bootstrap_packages_js_1.selectHubBootstrapPackages)(pkg);
212
+ }
213
+ /**
214
+ * THIS server closure's own `package.json` — the roster's source and, when
215
+ * `pin-root-deps` ran for the release, the exact version of every bootstrap
216
+ * addon. Read by absolute path for the same reason `deriveBootstrapFromSelf`
217
+ * is: the slim image strips the `@camstack/*` symlinks, so `require.resolve`
218
+ * would answer nothing here.
219
+ */
220
+ function readOwnManifest() {
209
221
  try {
210
222
  const pkgPath = path.resolve(__dirname, '..', 'package.json');
211
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
212
- return (0, bootstrap_packages_js_1.selectHubBootstrapPackages)(pkg);
223
+ return JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
213
224
  }
214
225
  catch (err) {
215
- console.warn(`[launcher] could not derive bootstrap list from package.json: ${err instanceof Error ? err.message : String(err)}`);
216
- return [];
226
+ console.warn(`[launcher] could not read the server manifest: ${err instanceof Error ? err.message : String(err)}`);
227
+ return null;
228
+ }
229
+ }
230
+ /** The version each required package currently has under the addon root. */
231
+ function readInstalledAddonVersions(addonRoot, packages) {
232
+ const out = {};
233
+ for (const name of packages) {
234
+ const pkgJson = path.join(addonRoot, name, 'package.json');
235
+ if (!fs.existsSync(pkgJson))
236
+ continue;
237
+ try {
238
+ const raw = JSON.parse(fs.readFileSync(pkgJson, 'utf-8'));
239
+ const version = typeof raw === 'object' && raw !== null ? raw.version : undefined;
240
+ out[name] = typeof version === 'string' ? version : null;
241
+ }
242
+ catch {
243
+ // Present but unreadable is NOT absent — the plan says so in its own words.
244
+ out[name] = null;
245
+ }
217
246
  }
247
+ return out;
218
248
  }
219
249
  /**
220
250
  * Read `bootstrap.requiredAddons` from `<dataDir>/config.yaml` if present.
@@ -364,6 +394,44 @@ async function launch() {
364
394
  const roleDefaultBootstrap = role === 'agent' ? AddonInstaller.AGENT_PACKAGES : deriveBootstrapFromSelf();
365
395
  const bootstrapRequired = readBootstrapRequiredAddons(dataDir, bootstrapSchema) ?? roleDefaultBootstrap;
366
396
  console.log(`[launcher] bootstrap (${role}): ${bootstrapRequired.length} required package(s)`);
397
+ // WHAT WOULD A SEED-FREE IMAGE DO HERE? (D45 task 32 — OBSERVE ONLY)
398
+ //
399
+ // The image still bakes an addon seed tree, so `ensureRequiredPackages` below
400
+ // usually finds every package already on disk. Once the seed leaves the image
401
+ // ("tutti gli altri addons devono rimanere copia unica, sempre, installati
402
+ // durante l'avvio iniziale", 2026-08-12) this same roster has to come from the
403
+ // registry, at a version somebody chose. This block prints that plan and
404
+ // NOTHING ELSE: no install, no version argument, no branch. It exists so the
405
+ // decision can be read off real boots — how many packages a node would fetch,
406
+ // which ones the closure carries no pin for, and where a deployed copy already
407
+ // disagrees with it — before any of it is switched on.
408
+ //
409
+ // Diagnostics, so it can never end a boot: same policy as the inventories.
410
+ try {
411
+ const ownManifest = readOwnManifest();
412
+ const plan = (0, first_boot_addon_plan_js_1.planFirstBootAddons)({
413
+ required: bootstrapRequired,
414
+ closurePins: ownManifest === null ? {} : (0, first_boot_addon_plan_js_1.bootstrapPinsFrom)(ownManifest),
415
+ installed: readInstalledAddonVersions(addonsDir, bootstrapRequired),
416
+ // Mirrors `shouldSkipClosureProvidedSeed`: only `@camstack/system`, and
417
+ // only while it actually resolves.
418
+ closureProvided: (() => {
419
+ try {
420
+ require.resolve('@camstack/system/package.json');
421
+ return ['@camstack/system'];
422
+ }
423
+ catch {
424
+ return [];
425
+ }
426
+ })(),
427
+ });
428
+ for (const line of (0, first_boot_addon_plan_js_1.formatFirstBootPlan)(plan, 'observe')) {
429
+ console.log(`[launcher] ${line}`);
430
+ }
431
+ }
432
+ catch (err) {
433
+ console.warn(`[launcher] first-boot addon plan failed: ${err instanceof Error ? err.message : String(err)}`);
434
+ }
367
435
  await installer.ensureRequiredPackages(bootstrapRequired);
368
436
  // Reconcile the install manifest against what is on disk — the directory is
369
437
  // the truth, because it is what the loader runs. Registers image-seeded
@@ -472,12 +540,21 @@ async function launch() {
472
540
  // 1.2.30), so every version the node reported was true of a copy nobody was
473
541
  // running.
474
542
  //
475
- // Reported, not fatal. Every node in the fleet currently HAS extra copies —
476
- // the image seeds under /opt and the legacy /data/framework — and D45 records
477
- // their removal as a deliberate, scheduled migration with a container
478
- // recreate as the escape hatch, not something a routine boot should enact by
479
- // refusing to start. `CAMSTACK_INVENTORY_STRICT=1` makes it fatal, which is
480
- // how a layout that HAS been cleaned keeps itself clean.
543
+ // Only the copies nobody deposits on purpose are reported. The /opt seeds
544
+ // are baked into the image and shadowed by the /data/server-root/current
545
+ // deposit on every applyServerUpdate calling those a violation printed the
546
+ // banner at every boot on every node, which is how it stopped being read.
547
+ // They get one compact line instead; an unexpected location (the legacy
548
+ // /data/framework, a bootstrap install under /data/addons) and two copies
549
+ // that could both run still scream.
550
+ //
551
+ // NEVER fatal — operator directive, 2026-08-12: "non dovremmo mai prevenire
552
+ // il boot". There is no flag that turns this into a refusal. A node that
553
+ // cannot start is a camera system that is not recording, and the inventory is
554
+ // the thing that was supposed to EXPLAIN a bad layout, not enact a verdict on
555
+ // it. Removing a copy stays an operator decision.
556
+ //
557
+ // [inventory-diagnostics-begin] enforced by scripts/check-inventory-never-fatal.ts
481
558
  try {
482
559
  const inventory = (0, package_inventory_js_1.inventoryHostProvided)((0, package_inventory_js_1.hostProvidedSearchRoots)({
483
560
  nodePath: process.env['NODE_PATH'],
@@ -508,13 +585,12 @@ async function launch() {
508
585
  }
509
586
  },
510
587
  });
588
+ for (const note of (0, package_inventory_js_1.formatShadowingNotes)(inventory)) {
589
+ console.log(`[launcher] ${note}`);
590
+ }
511
591
  const report = (0, package_inventory_js_1.formatInventoryReport)(inventory);
512
592
  if (report !== '') {
513
593
  console.error(`[launcher] ${report}`);
514
- if (process.env['CAMSTACK_INVENTORY_STRICT'] === '1') {
515
- console.error('[launcher] CAMSTACK_INVENTORY_STRICT=1 — refusing to boot on this layout');
516
- process.exit(1);
517
- }
518
594
  }
519
595
  }
520
596
  catch (err) {
@@ -532,10 +608,9 @@ async function launch() {
532
608
  // not run since June. Measured on the Mac agent: four addon roots, four
533
609
  // truthful `addon-pipeline` versions, one of them the running node's.
534
610
  //
535
- // Reported, never fatal, never deleted here — same policy as D45. Making the
536
- // second location LOUD is the whole job; removing it stays an operator
537
- // decision. `CAMSTACK_INVENTORY_STRICT=1` makes it fatal, which is how a
538
- // machine that HAS been cleaned keeps itself clean.
611
+ // Reported, never fatal, never deleted here — same policy as D45 above, and
612
+ // no flag changes it. Making the second location LOUD is the whole job;
613
+ // removing it stays an operator decision.
539
614
  try {
540
615
  const addonRoots = (0, addon_root_inventory_js_1.inventoryAddonRoots)({
541
616
  dataDir: DATA_DIR,
@@ -579,15 +654,12 @@ async function launch() {
579
654
  const addonRootReport = (0, addon_root_inventory_js_1.formatAddonRootReport)(addonRoots);
580
655
  if (addonRootReport !== '') {
581
656
  console.error(`[launcher] ${addonRootReport}`);
582
- if (process.env['CAMSTACK_INVENTORY_STRICT'] === '1') {
583
- console.error('[launcher] CAMSTACK_INVENTORY_STRICT=1 — refusing to boot on this layout');
584
- process.exit(1);
585
- }
586
657
  }
587
658
  }
588
659
  catch (err) {
589
660
  console.warn(`[launcher] addon-root inventory failed: ${err instanceof Error ? err.message : String(err)}`);
590
661
  }
662
+ // [inventory-diagnostics-end]
591
663
  // Now safe to load the role's runtime entry (both have static imports from
592
664
  // @camstack/system, resolved only after the framework dir + NODE_PATH setup
593
665
  // above). The agent boots from THIS same @camstack/server closure — there is
package/dist/main.js CHANGED
@@ -888,7 +888,15 @@ async function bootstrap() {
888
888
  return reply.status(404).send({ error: 'Not found' });
889
889
  }
890
890
  if (method === 'GET' && spaIndexHtml) {
891
- return reply.type('text/html').send(fs.createReadStream(spaIndexHtml));
891
+ // An addon deep-link that is not an addon ROUTE is an admin-UI page —
892
+ // serve the admin shell, under the SAME no-store policy as `/`. This
893
+ // send used to carry no cache header at all, so browsers kept the
894
+ // index.html of a previous build (and its dead hashed asset names)
895
+ // across deploys until a force refresh.
896
+ return reply
897
+ .header('cache-control', (0, spa_static_1.spaShellCacheControl)())
898
+ .type('text/html')
899
+ .send(fs.createReadStream(spaIndexHtml));
892
900
  }
893
901
  return reply.status(404).send({ error: 'Not found' });
894
902
  }
@@ -1115,7 +1123,7 @@ async function bootstrap() {
1115
1123
  }
1116
1124
  return reply.callNotFound();
1117
1125
  }
1118
- reply.header('cache-control', 'no-cache, must-revalidate');
1126
+ reply.header('cache-control', (0, spa_static_1.spaShellCacheControl)());
1119
1127
  return reply.type('text/html').send(fs.createReadStream(indexPath));
1120
1128
  });
1121
1129
  fastify.get('/*', async (request, reply) => {
@@ -1143,7 +1151,7 @@ async function bootstrap() {
1143
1151
  }
1144
1152
  return reply.callNotFound();
1145
1153
  }
1146
- reply.header('cache-control', 'no-cache, must-revalidate');
1154
+ reply.header('cache-control', (0, spa_static_1.spaShellCacheControl)());
1147
1155
  return reply.type('text/html').send(fs.createReadStream(indexPath));
1148
1156
  });
1149
1157
  const resolveAdminUi = async () => {
@@ -38,6 +38,7 @@ exports.hostProvidedSearchRoots = hostProvidedSearchRoots;
38
38
  exports.seedNodeModulesRoots = seedNodeModulesRoots;
39
39
  exports.inventoryHostProvided = inventoryHostProvided;
40
40
  exports.formatInventoryReport = formatInventoryReport;
41
+ exports.formatShadowingNotes = formatShadowingNotes;
41
42
  /**
42
43
  * Boot-time inventory of host-provided packages — enforcement step 2 of
43
44
  * [D45](../../../docs/decisions/adr-0045.md).
@@ -54,6 +55,16 @@ exports.formatInventoryReport = formatInventoryReport;
54
55
  * This answers the other half — "what else is lying around that could have
55
56
  * run" — which is the question a resolved path alone cannot.
56
57
  *
58
+ * WHERE a copy sits decides whether it is noise or a violation. Two of the four
59
+ * copies above are EXPECTED: the `/opt` seed trees are baked into the image and
60
+ * deliberately shadowed by the `/data/server-root/current` deposit on every
61
+ * `applyServerUpdate`. Counting them as a violation printed the banner at every
62
+ * boot on every node, which taught the operator to scroll past the one boot
63
+ * where it mattered — the exact failure D45 exists to prevent. So the detector
64
+ * classifies locations and screams only for a copy nobody deposits on purpose,
65
+ * or for two copies that could BOTH be the one that runs. The expected
66
+ * shadowing gets one compact line instead.
67
+ *
57
68
  * Everything here is pure: the caller injects the three filesystem operations,
58
69
  * so the whole inventory is testable against a described layout rather than a
59
70
  * real disk.
@@ -78,24 +89,39 @@ exports.HOST_PROVIDED_PACKAGES = [
78
89
  * to a handful of stat calls at boot. `/data/addons` is a root in its own right
79
90
  * because the bootstrap-install layout puts the package DIRECTLY there
80
91
  * (`/data/addons/@camstack/system`), not under a `node_modules`.
92
+ *
93
+ * A `NODE_PATH` entry is classified by WHICH directory it names, not by being
94
+ * on `NODE_PATH`: the launcher puts the deposit and the seeds there itself, so
95
+ * "it is on NODE_PATH" would mark the whole fleet unexpected.
81
96
  */
82
97
  function hostProvidedSearchRoots(input) {
83
98
  const fromNodePath = (input.nodePath ?? '')
84
99
  .split(input.pathSeparator)
85
100
  .map((p) => p.trim())
86
101
  .filter((p) => p.length > 0);
102
+ const seedPaths = new Set(input.seedRoots);
103
+ const expectationFor = (dir) => {
104
+ if (dir === input.serverNodeModules)
105
+ return 'active';
106
+ if (seedPaths.has(dir))
107
+ return 'seed';
108
+ return 'unexpected';
109
+ };
87
110
  const roots = [
88
- input.serverNodeModules,
89
- ...fromNodePath,
90
- path.join(input.dataDir, 'framework', 'node_modules'),
91
- path.join(input.dataDir, 'addons'),
92
- ...input.seedRoots,
111
+ { path: input.serverNodeModules, expectation: 'active' },
112
+ ...fromNodePath.map((p) => ({ path: p, expectation: expectationFor(p) })),
113
+ { path: path.join(input.dataDir, 'framework', 'node_modules'), expectation: 'unexpected' },
114
+ { path: path.join(input.dataDir, 'addons'), expectation: 'unexpected' },
115
+ ...input.seedRoots.map((p) => ({ path: p, expectation: expectationFor(p) })),
93
116
  ];
117
+ // First occurrence wins, and the deposit is listed first — so a directory
118
+ // that is both the closure and a seed keeps the classification that decides
119
+ // whether it runs.
94
120
  const seen = new Set();
95
121
  return roots.filter((r) => {
96
- if (seen.has(r))
122
+ if (seen.has(r.path))
97
123
  return false;
98
- seen.add(r);
124
+ seen.add(r.path);
99
125
  return true;
100
126
  });
101
127
  }
@@ -136,45 +162,106 @@ function inventoryHostProvided(roots, fs, packages = exports.HOST_PROVIDED_PACKA
136
162
  // them two would cry wolf on a node that is actually compliant.
137
163
  const seenReal = new Set();
138
164
  for (const root of roots) {
139
- const dir = path.join(root, pkg);
165
+ const dir = path.join(root.path, pkg);
140
166
  if (!fs.exists(dir))
141
167
  continue;
142
168
  const real = fs.realPath(dir);
143
169
  if (seenReal.has(real))
144
170
  continue;
145
171
  seenReal.add(real);
146
- copies.push({ pkg, path: dir, version: fs.readVersion(dir) });
172
+ copies.push({ pkg, path: dir, version: fs.readVersion(dir), location: root.expectation });
147
173
  }
148
174
  }
149
- const countByPkg = new Map();
175
+ const byPkg = new Map();
150
176
  for (const c of copies)
151
- countByPkg.set(c.pkg, (countByPkg.get(c.pkg) ?? 0) + 1);
152
- const duplicated = [...countByPkg.entries()]
153
- .filter(([, n]) => n > 1)
154
- .map(([pkg]) => pkg)
155
- .sort();
156
- return { copies, duplicated, rootsScanned: roots };
177
+ byPkg.set(c.pkg, [...(byPkg.get(c.pkg) ?? []), c]);
178
+ const inventories = [...byPkg.entries()]
179
+ .map(([pkg, found]) => classifyPackage(pkg, found))
180
+ .sort((a, b) => a.pkg.localeCompare(b.pkg));
181
+ return {
182
+ copies,
183
+ packages: inventories,
184
+ violations: inventories.filter((p) => p.violation).map((p) => p.pkg),
185
+ rootsScanned: roots.map((r) => r.path),
186
+ };
187
+ }
188
+ /**
189
+ * Sort one package's copies into what each one means.
190
+ *
191
+ * A deposit copy shadows every seed — that is what `applyServerUpdate` does on
192
+ * every release, so those seeds are not candidates for anything. With no
193
+ * deposit copy the seeds ARE the candidates, and two of them is the resolution
194
+ * race D45 was written about.
195
+ */
196
+ function classifyPackage(pkg, found) {
197
+ const active = found.filter((c) => c.location === 'active');
198
+ const seeds = found.filter((c) => c.location === 'seed');
199
+ const unexpected = found.filter((c) => c.location === 'unexpected');
200
+ const activeCandidates = active.length > 0 ? active : seeds;
201
+ const shadowedSeeds = active.length > 0 ? seeds : [];
202
+ return {
203
+ pkg,
204
+ copies: found,
205
+ activeCandidates,
206
+ shadowedSeeds,
207
+ unexpected,
208
+ violation: unexpected.length > 0 || activeCandidates.length > 1,
209
+ };
157
210
  }
158
211
  /**
159
- * The loud report. Empty string when the node is clean a compliant boot says
160
- * nothing, so the one that isn't stands out instead of scrolling past.
212
+ * The loud report. Empty string unless a copy is somewhere nobody deposits on
213
+ * purpose, or two copies could both be the one that runs an expected seed
214
+ * under the deposit is the fleet's normal shape and reports nothing here, or
215
+ * the banner would print on every boot of every node and be read on none.
161
216
  */
162
217
  function formatInventoryReport(result) {
163
- if (result.duplicated.length === 0)
218
+ const violating = result.packages.filter((p) => p.violation);
219
+ if (violating.length === 0)
164
220
  return '';
165
221
  const lines = [
166
- 'D45 violation — a host-provided package exists more than once on this node.',
167
- 'Which copy runs is a module-resolution outcome, and every version this node',
168
- 'reports is a claim about one of them rather than about the node.',
222
+ 'D45 violation — a host-provided package sits somewhere it must not, or two',
223
+ 'copies could both be the one that runs. Which copy runs is then a',
224
+ 'module-resolution outcome, and every version this node reports is a claim',
225
+ 'about one of them rather than about the node.',
169
226
  '',
170
227
  ];
171
- for (const pkg of result.duplicated) {
172
- const found = result.copies.filter((c) => c.pkg === pkg);
173
- lines.push(` ${pkg} ${found.length} copies:`);
174
- for (const c of found) {
175
- lines.push(` ${c.version ?? '<unreadable package.json>'} ${c.path}`);
228
+ for (const inv of violating) {
229
+ lines.push(` ${inv.pkg} ${inv.copies.length} copies:`);
230
+ for (const c of inv.copies) {
231
+ lines.push(` ${c.version ?? '<unreadable package.json>'} [${describeLocation(c, inv)}] ${c.path}`);
176
232
  }
177
233
  }
178
234
  lines.push('', ` roots scanned: ${result.rootsScanned.join(', ')}`);
179
235
  return lines.join('\n');
180
236
  }
237
+ /** Why this one copy is (or is not) part of the problem, in one word or two. */
238
+ function describeLocation(copy, inv) {
239
+ if (copy.location === 'unexpected')
240
+ return 'UNEXPECTED — remove it';
241
+ if (inv.activeCandidates.includes(copy)) {
242
+ return inv.activeCandidates.length > 1 ? 'CONFLICTS — could run' : 'active';
243
+ }
244
+ return 'image seed, shadowed';
245
+ }
246
+ /**
247
+ * The quiet half: the expected seed shadowing, one compact line per package.
248
+ *
249
+ * Deliberately not a banner and deliberately not phrased as a fault — the image
250
+ * bakes these trees and the deposit shadows them on every release. A package
251
+ * already named in {@link formatInventoryReport} is skipped: the banner lists
252
+ * all of its copies, and a reassuring line beside it would read as verification.
253
+ */
254
+ function formatShadowingNotes(result) {
255
+ return result.packages
256
+ .filter((p) => !p.violation && p.shadowedSeeds.length > 0)
257
+ .map((p) => {
258
+ const seedVersions = [...new Set(p.shadowedSeeds.map(describeVersion))].join(', ');
259
+ const active = p.activeCandidates[0];
260
+ const activeVersion = active === undefined ? 'unknown' : describeVersion(active);
261
+ const activePath = active === undefined ? '' : ` (${active.path})`;
262
+ return `${p.pkg} — seed ${seedVersions} shadowed by active ${activeVersion}${activePath}`;
263
+ });
264
+ }
265
+ function describeVersion(copy) {
266
+ return copy.version ?? '<unreadable package.json>';
267
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.99",
3
+ "version": "1.2.101",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist",
@@ -33,19 +33,19 @@
33
33
  ]
34
34
  },
35
35
  "dependencies": {
36
- "@camstack/addon-admin-ui": "1.2.49",
37
- "@camstack/addon-agent-ui": "1.2.13",
38
- "@camstack/addon-auth": "1.2.14",
39
- "@camstack/addon-decoder-nodeav": "1.2.12",
40
- "@camstack/addon-notifiers": "1.2.17",
41
- "@camstack/addon-pipeline": "1.2.68",
42
- "@camstack/addon-pipeline-orchestrator": "1.2.49",
43
- "@camstack/addon-post-analysis": "1.2.65",
44
- "@camstack/sdk": "1.2.14",
45
- "@camstack/shm-ring": "1.1.12",
46
- "@camstack/system": "1.2.83",
47
- "@camstack/types": "1.2.62",
48
- "@camstack/ui-library": "1.2.42",
36
+ "@camstack/addon-admin-ui": "1.2.51",
37
+ "@camstack/addon-agent-ui": "1.2.14",
38
+ "@camstack/addon-auth": "1.2.15",
39
+ "@camstack/addon-decoder-nodeav": "1.2.13",
40
+ "@camstack/addon-notifiers": "1.2.18",
41
+ "@camstack/addon-pipeline": "1.2.70",
42
+ "@camstack/addon-pipeline-orchestrator": "1.2.50",
43
+ "@camstack/addon-post-analysis": "1.2.67",
44
+ "@camstack/sdk": "1.2.15",
45
+ "@camstack/shm-ring": "1.1.13",
46
+ "@camstack/system": "1.2.85",
47
+ "@camstack/types": "1.2.64",
48
+ "@camstack/ui-library": "1.2.43",
49
49
  "@fastify/compress": "^9.0.0",
50
50
  "@fastify/cookie": "^11.0.2",
51
51
  "@fastify/cors": "^11.2.0",