@camstack/server 1.2.101 → 1.2.102

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.
@@ -37,6 +37,7 @@ exports.startAgent = startAgent;
37
37
  const fs = __importStar(require("node:fs"));
38
38
  const path = __importStar(require("node:path"));
39
39
  const agent_http_js_1 = require("./agent-http.js");
40
+ const single_copy_cleanup_runner_js_1 = require("../single-copy-cleanup-runner.js");
40
41
  const derive_hub_url_js_1 = require("./derive-hub-url.js");
41
42
  const system_1 = require("@camstack/system");
42
43
  const types_1 = require("@camstack/types");
@@ -381,6 +382,18 @@ async function startAgent(configPath) {
381
382
  catch (err) {
382
383
  consoleLogger.warn(`agent root boot confirmation failed: ${err instanceof Error ? err.message : String(err)}`);
383
384
  }
385
+ // ONE COPY PER NODE — the same sweep the hub runs, at this role's own
386
+ // healthy-boot moment (D45, task 32). Operator, 2026-08-13: "l'agente deve
387
+ // essere identico all'hub". An agent is where the redundant copies actually
388
+ // are: `little-unraid` still holds `<dataDir>/addons/@camstack/system@1.2.78`
389
+ // — a copy nothing loads (both roles pass `['@camstack/system']` as the
390
+ // loader's skip list) and that WINS over the closure for anything resolving
391
+ // through it.
392
+ void (0, single_copy_cleanup_runner_js_1.runSingleCopyCleanup)({
393
+ dataDir: config.dataDir,
394
+ addonRoot: config.addonsDir,
395
+ log: (line) => consoleLogger.info(line),
396
+ });
384
397
  };
385
398
  // Logger factory — creates scoped loggers from the shared LogManager.
386
399
  // All entries flow through registered ILogDestination providers (hub-forwarder).
@@ -1,114 +1,50 @@
1
1
  "use strict";
2
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.
3
+ * What a node installs at first boot once the image stops carrying a
4
+ * NON-system addon seed tree — [D45](../../../docs/decisions/adr-0045.md),
5
+ * task 32.
5
6
  *
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:
7
+ * Operator decisions, 2026-08-12/13:
13
8
  *
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.
9
+ * - the docker seed stays for `@camstack/server` and the SYSTEM packages, as
10
+ * the first-boot fallback source; every other addon is single-copy, always,
11
+ * installed during the initial boot;
12
+ * - **no pinning at all** *"il sistema è designato per essere
13
+ * addons-agnostic, latest va sempre bene al primo boot"*. There is no version
14
+ * contract between the closure and an addon, so first boot asks for `latest`
15
+ * and the copy that lands is the one the node keeps.
26
16
  *
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.
17
+ * So this module answers exactly one question per package *fetch it, adopt
18
+ * what is already here, or leave it to the closure*and reports the version
19
+ * the running closure happens to carry beside the installed one, as INFO. That
20
+ * comparison is not a pin and never drives an action: the installed copy wins,
21
+ * always ([D90](../../../docs/decisions/adr-0090.md)). It exists because the
22
+ * same package having two truthful versions on one node is what task 32 is
23
+ * about — the hub runs `addon-pipeline` 1.2.69 while its own closure carries
24
+ * 1.2.64, and nothing said so out loud until now.
25
+ *
26
+ * Pure: the caller supplies the roster, what is on disk and what the closure
27
+ * carries. It installs nothing and can never end a boot.
31
28
  */
32
29
  Object.defineProperty(exports, "__esModule", { value: true });
33
- exports.resolveWantedVersion = resolveWantedVersion;
34
- exports.compareVersions = compareVersions;
35
- exports.bootstrapPinsFrom = bootstrapPinsFrom;
36
30
  exports.planFirstBootAddons = planFirstBootAddons;
37
31
  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
32
  /** The plan. Pure; installs nothing. */
94
33
  function planFirstBootAddons(input) {
95
34
  const provided = new Set(input.closureProvided);
96
- const decisions = input.required.map((pkg) => decide(pkg, input.closurePins[pkg], input.installed, provided));
35
+ const decisions = input.required.map((pkg) => decide(pkg, input.installed, input.closureVersions, provided));
97
36
  const wouldInstall = decisions.filter((d) => d.action === 'install-npm').map((d) => d.pkg);
98
37
  return {
99
38
  decisions,
100
39
  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),
40
+ twoVersions: decisions.filter((d) => d.twoVersions).map((d) => d.pkg),
105
41
  needsRegistry: wouldInstall.length > 0,
106
42
  };
107
43
  }
108
- function decide(pkg, pin, installed, closureProvided) {
109
- const want = resolveWantedVersion(pin);
44
+ function decide(pkg, installed, closureVersions, closureProvided) {
110
45
  const hasEntry = Object.hasOwn(installed, pkg);
111
46
  const version = installed[pkg] ?? null;
47
+ const closureVersion = closureVersions[pkg] ?? null;
112
48
  // Host-provided and reachable: a copy under the addon root WINS over the
113
49
  // closure and nothing refreshes it — the shape that left both agents 17
114
50
  // versions behind. Same rule as `shouldSkipClosureProvidedSeed`.
@@ -116,9 +52,9 @@ function decide(pkg, pin, installed, closureProvided) {
116
52
  return {
117
53
  pkg,
118
54
  action: 'skip-closure-provided',
119
- want,
120
55
  installedVersion: version,
121
- divergent: false,
56
+ closureVersion,
57
+ twoVersions: false,
122
58
  reason: 'provided by the server closure — a copy here would shadow it (D15)',
123
59
  };
124
60
  }
@@ -126,56 +62,24 @@ function decide(pkg, pin, installed, closureProvided) {
126
62
  return {
127
63
  pkg,
128
64
  action: 'install-npm',
129
- want,
130
65
  installedVersion: null,
131
- divergent: false,
66
+ closureVersion,
67
+ twoVersions: false,
132
68
  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',
69
+ ? 'installed copy has an unreadable package.json — fetching latest'
70
+ : 'absent — fetching latest, which is the whole version contract (addons-agnostic)',
137
71
  };
138
72
  }
73
+ const twoVersions = closureVersion !== null && closureVersion !== version;
139
74
  return {
140
75
  pkg,
141
76
  action: 'adopt-installed',
142
- want,
143
77
  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`,
78
+ closureVersion,
79
+ twoVersions,
80
+ reason: twoVersions
81
+ ? `installed ${version}; this closure carries ${closureVersion} — the installed copy is what runs (D90)`
82
+ : `installed ${version}kept`,
179
83
  };
180
84
  }
181
85
  /**
@@ -189,18 +93,17 @@ function adoptionVerdict(version, want) {
189
93
  */
190
94
  function formatFirstBootPlan(plan, mode) {
191
95
  const header = `first-boot addon plan (${mode}) — ${plan.decisions.length} package(s), ` +
192
- `${plan.wouldInstall.length} to install, ${plan.divergent.length} divergent`;
96
+ `${plan.wouldInstall.length} to install`;
193
97
  const lines = [
194
98
  mode === 'observe' ? `${header}; nothing was installed from this plan` : header,
195
99
  ];
196
100
  for (const d of plan.decisions) {
197
- const target = d.action === 'install-npm' ? `@${d.want.spec}` : '';
101
+ const target = d.action === 'install-npm' ? '@latest' : '';
198
102
  lines.push(` ${d.pkg}${target} — ${d.action}: ${d.reason}`);
199
103
  }
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');
104
+ if (plan.twoVersions.length > 0) {
105
+ lines.push(` two versions on this node (installed vs this closure's own copy): ${plan.twoVersions.join(', ')} — ` +
106
+ 'INFO: the installed copy is the one that runs');
204
107
  }
205
108
  if (!plan.needsRegistry) {
206
109
  lines.push(' no registry access needed — every required addon is already on disk');
package/dist/launcher.js CHANGED
@@ -394,25 +394,25 @@ async function launch() {
394
394
  const roleDefaultBootstrap = role === 'agent' ? AddonInstaller.AGENT_PACKAGES : deriveBootstrapFromSelf();
395
395
  const bootstrapRequired = readBootstrapRequiredAddons(dataDir, bootstrapSchema) ?? roleDefaultBootstrap;
396
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)
397
+ // THE FIRST-BOOT ADDON PLAN reported every boot (D45 task 32).
398
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.
399
+ // The image no longer bakes a NON-system addon tree, so this roster comes
400
+ // from the registry on a fresh node: "tutti gli altri addons devono rimanere
401
+ // copia unica, sempre, installati durante l'avvio iniziale" (2026-08-12), at
402
+ // `latest`, because "il sistema e' designato per essere addons-agnostic"
403
+ // (2026-08-13). The plan below is what `ensureRequiredPackages` is about to
404
+ // do, said out loud BEFORE it happens: how many packages this node must fetch
405
+ // (i.e. whether this boot needs the registry at all), and where an installed
406
+ // copy differs from the one inside this very closure one node, two truthful
407
+ // versions, which is the condition task 32 exists to end.
408
408
  //
409
409
  // Diagnostics, so it can never end a boot: same policy as the inventories.
410
410
  try {
411
- const ownManifest = readOwnManifest();
411
+ const closureNodeModules = path.resolve(__dirname, '..', 'node_modules');
412
412
  const plan = (0, first_boot_addon_plan_js_1.planFirstBootAddons)({
413
413
  required: bootstrapRequired,
414
- closurePins: ownManifest === null ? {} : (0, first_boot_addon_plan_js_1.bootstrapPinsFrom)(ownManifest),
415
414
  installed: readInstalledAddonVersions(addonsDir, bootstrapRequired),
415
+ closureVersions: readInstalledAddonVersions(closureNodeModules, bootstrapRequired),
416
416
  // Mirrors `shouldSkipClosureProvidedSeed`: only `@camstack/system`, and
417
417
  // only while it actually resolves.
418
418
  closureProvided: (() => {
package/dist/main.js CHANGED
@@ -46,6 +46,7 @@ const ws_2 = require("ws");
46
46
  const fs = __importStar(require("node:fs"));
47
47
  const path = __importStar(require("node:path"));
48
48
  const node_child_process_1 = require("node:child_process");
49
+ const single_copy_cleanup_runner_js_1 = require("./single-copy-cleanup-runner.js");
49
50
  const logging_service_1 = require("./core/logging/logging.service");
50
51
  const event_bus_service_1 = require("./core/events/event-bus.service");
51
52
  const config_service_1 = require("./core/config/config.service");
@@ -1290,6 +1291,23 @@ async function bootstrap() {
1290
1291
  meta: { error: err instanceof Error ? err.message : String(err) },
1291
1292
  });
1292
1293
  }
1294
+ // ONE COPY PER NODE — the sweep, at the only moment it is safe (D45, task 32).
1295
+ //
1296
+ // The boot above is confirmed healthy and the hub is serving, so the legacy
1297
+ // `<dataDir>/framework` tree and any host-provided package under the addon
1298
+ // root are redundant: nothing loads them, and each is a resolution race that
1299
+ // has already cost this project three wrong diagnoses in one evening.
1300
+ //
1301
+ // It runs HERE and not in the launcher because "healthy" is the gate. Before
1302
+ // the hub is up, the copies this removes are the recovery path — the image
1303
+ // seeds them for exactly the boot that would otherwise fail. The agent runs
1304
+ // the SAME function at its own confirmation point; neither role owns a
1305
+ // private idea of what may be deleted.
1306
+ void (0, single_copy_cleanup_runner_js_1.runSingleCopyCleanup)({
1307
+ dataDir: dataPath,
1308
+ addonRoot: process.env['CAMSTACK_ADDONS_DIR'] ?? path.join(dataPath, 'addons'),
1309
+ log: (line) => logger.info(line),
1310
+ });
1293
1311
  // One-time backfill: stamp integrationId on devices created before the
1294
1312
  // device-manager forwarder started stamping it (legacy camera providers),
1295
1313
  // so deleting their integration cascades them. Idempotent — only touches
@@ -0,0 +1,128 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.runSingleCopyCleanup = runSingleCopyCleanup;
37
+ /**
38
+ * The one adapter that runs the post-boot single-copy sweep on BOTH roles.
39
+ *
40
+ * Operator, 2026-08-13: *"l'agente deve essere identico all'hub"*. Two code
41
+ * paths that must agree get one implementation — the hub calls this after its
42
+ * boot-health confirmation, the agent after its first acked `registerNode`, and
43
+ * neither owns a private idea of what may be deleted.
44
+ *
45
+ * Everything decidable lives in `single-copy-cleanup.ts` and is pure. This file
46
+ * only reads the real world: the active-root env, where `@camstack/system`
47
+ * actually resolved, the effective `NODE_PATH`, and the disk.
48
+ *
49
+ * It never throws. A sweep that fails leaves a node with an extra directory; a
50
+ * sweep that ends a process is an outage, and the copies it removes exist
51
+ * precisely because a node once could not boot.
52
+ */
53
+ const fs = __importStar(require("node:fs"));
54
+ const path = __importStar(require("node:path"));
55
+ const single_copy_cleanup_js_1 = require("./single-copy-cleanup.js");
56
+ const EMPTY_RESULT = { removed: [], failed: [], refused: true };
57
+ /**
58
+ * Discover, plan, report, sweep. Called only from a point where the role has
59
+ * already confirmed its boot healthy — that confirmation IS the gate, and it is
60
+ * re-stated as `bootHealthy: true` here so the pure planner can be exercised
61
+ * from both sides in a test.
62
+ */
63
+ async function runSingleCopyCleanup(opts) {
64
+ try {
65
+ if (!(0, single_copy_cleanup_js_1.isCleanupEnabled)(process.env)) {
66
+ opts.log('single-copy cleanup disabled by CAMSTACK_SINGLE_COPY_CLEANUP — nothing removed');
67
+ return EMPTY_RESULT;
68
+ }
69
+ const candidates = (0, single_copy_cleanup_js_1.discoverRedundantCopies)({
70
+ dataDir: opts.dataDir,
71
+ addonRoot: opts.addonRoot,
72
+ closureProvides: (pkg) => {
73
+ try {
74
+ require.resolve(`${pkg}/package.json`);
75
+ return true;
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ },
81
+ }, {
82
+ exists: (p) => fs.existsSync(p),
83
+ readVersion: (dir) => readVersion(dir),
84
+ });
85
+ const plan = (0, single_copy_cleanup_js_1.planSingleCopyCleanup)({
86
+ activeRoot: process.env['CAMSTACK_SERVER_ACTIVE_ROOT'] ?? null,
87
+ closureResolvedFrom: resolveSystemPath(),
88
+ bootHealthy: true,
89
+ nodePathEntries: (process.env['NODE_PATH'] ?? '')
90
+ .split(process.platform === 'win32' ? ';' : ':')
91
+ .map((entry) => entry.trim())
92
+ .filter((entry) => entry.length > 0),
93
+ candidates,
94
+ });
95
+ for (const line of (0, single_copy_cleanup_js_1.formatCleanupPlan)(plan))
96
+ opts.log(line);
97
+ return await (0, single_copy_cleanup_js_1.executeSingleCopyCleanup)(plan, {
98
+ exists: (p) => fs.existsSync(p),
99
+ rename: (from, to) => fs.renameSync(from, to),
100
+ remove: async (p) => {
101
+ await fs.promises.rm(p, { recursive: true, force: true });
102
+ },
103
+ }, opts.log);
104
+ }
105
+ catch (err) {
106
+ opts.log(`single-copy cleanup failed: ${err instanceof Error ? err.message : String(err)} — nothing was removed`);
107
+ return EMPTY_RESULT;
108
+ }
109
+ }
110
+ /** Where this process actually resolved `@camstack/system`, or `null`. */
111
+ function resolveSystemPath() {
112
+ try {
113
+ return require.resolve('@camstack/system/package.json');
114
+ }
115
+ catch {
116
+ return null;
117
+ }
118
+ }
119
+ function readVersion(packageDir) {
120
+ try {
121
+ const raw = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf-8'));
122
+ const version = typeof raw === 'object' && raw !== null ? raw.version : undefined;
123
+ return typeof version === 'string' ? version : null;
124
+ }
125
+ catch {
126
+ return null;
127
+ }
128
+ }
@@ -0,0 +1,241 @@
1
+ "use strict";
2
+ /**
3
+ * Post-boot convergence to ONE copy per node — [D45](../../../docs/decisions/adr-0045.md),
4
+ * task 32.
5
+ *
6
+ * Operator decisions, 2026-08-13:
7
+ *
8
+ * - the SYSTEM seed stays in the image, as the first-boot and fallback source
9
+ * (*"teniamo gli addons di sistema nell'immagine per il primo boot, da usare
10
+ * in caso di fallback"*);
11
+ * - once a node **has booted correctly**, the redundant DATA-side copies are
12
+ * removed — the legacy `<dataDir>/framework` tree and any host-provided
13
+ * package sitting under the addon root;
14
+ * - the end state is one version of everything per node, and an agent is
15
+ * identical to a hub.
16
+ *
17
+ * The removal is the easy half. The dangerous half is WHEN, because every copy
18
+ * this deletes is one that something could still be resolving:
19
+ *
20
+ * | Situation | Why cleaning would brick the node |
21
+ * | --- | --- |
22
+ * | BAKED mode (no `<dataDir>/server-root/current`) | the node is running FROM the fallback tree |
23
+ * | `@camstack/system` resolved outside the active closure | the copy being deleted may be the one in memory |
24
+ * | the tree is still on `NODE_PATH` | a live resolution path, whatever the mode says |
25
+ * | boot not confirmed healthy | the copies are the recovery path, needed exactly now |
26
+ *
27
+ * So the plan is gated four ways, the executor refuses a blocked plan a second
28
+ * time, and a refusal is LOUD — a sweep that quietly did nothing is
29
+ * indistinguishable from a node that was already clean.
30
+ *
31
+ * Removal is rename-then-delete, the pattern `evictInstallDir` already uses on
32
+ * this FUSE mount: the rename is what ends the shadowing and it succeeds with
33
+ * inodes still open; the delete is best-effort. A copy that could not even be
34
+ * renamed is REPORTED, never swallowed.
35
+ *
36
+ * Pure planning + an injected filesystem, so the whole thing is testable
37
+ * against a described layout rather than a real disk.
38
+ */
39
+ Object.defineProperty(exports, "__esModule", { value: true });
40
+ exports.CLOSURE_PROVIDED_PACKAGES = void 0;
41
+ exports.planSingleCopyCleanup = planSingleCopyCleanup;
42
+ exports.executeSingleCopyCleanup = executeSingleCopyCleanup;
43
+ exports.formatCleanupPlan = formatCleanupPlan;
44
+ exports.discoverRedundantCopies = discoverRedundantCopies;
45
+ exports.isCleanupEnabled = isCleanupEnabled;
46
+ /** Image trees. The fallback source lives here and is never data-side. */
47
+ const IMAGE_PREFIX = '/opt/';
48
+ /**
49
+ * Decide what may be removed. Pure.
50
+ *
51
+ * The node-wide gates come first and are absolute: a blocked plan removes
52
+ * nothing at all, whatever the individual candidates look like.
53
+ */
54
+ function planSingleCopyCleanup(input) {
55
+ const blockedReason = nodeWideBlock(input);
56
+ if (blockedReason !== null) {
57
+ return {
58
+ remove: [],
59
+ keep: input.candidates.map((candidate) => ({ candidate, reason: blockedReason })),
60
+ blocked: true,
61
+ blockedReason,
62
+ };
63
+ }
64
+ const remove = [];
65
+ const keep = [];
66
+ for (const candidate of input.candidates) {
67
+ const refusal = perCandidateRefusal(candidate, input);
68
+ if (refusal === null)
69
+ remove.push(candidate);
70
+ else
71
+ keep.push({ candidate, reason: refusal });
72
+ }
73
+ return { remove, keep, blocked: false, blockedReason: null };
74
+ }
75
+ /** The reasons NOTHING may be removed this boot. */
76
+ function nodeWideBlock(input) {
77
+ if (!input.bootHealthy) {
78
+ return 'this boot is not confirmed healthy — the redundant copies are the recovery path and stay';
79
+ }
80
+ if (input.activeRoot === null) {
81
+ return 'baked mode: there is no active closure, so the node is running FROM the fallback tree';
82
+ }
83
+ if (input.closureResolvedFrom === null) {
84
+ return 'the process resolved no @camstack/system at all — nothing here is safe to remove';
85
+ }
86
+ if (!isInside(input.closureResolvedFrom, input.activeRoot)) {
87
+ return (`@camstack/system resolved from ${input.closureResolvedFrom}, which is OUTSIDE the active ` +
88
+ 'closure — the copy being removed could be the one in memory');
89
+ }
90
+ return null;
91
+ }
92
+ /** Why this ONE copy stays, or `null` when it may go. */
93
+ function perCandidateRefusal(candidate, input) {
94
+ if (input.activeRoot !== null && isInside(candidate.path, input.activeRoot)) {
95
+ return 'inside the active closure — this is the copy that runs, not a redundant one';
96
+ }
97
+ if (candidate.path.startsWith(IMAGE_PREFIX)) {
98
+ return 'an image tree — the operator keeps it as the first-boot and fallback source';
99
+ }
100
+ const onNodePath = input.nodePathEntries.some((entry) => entry === candidate.path || isInside(entry, candidate.path));
101
+ if (onNodePath) {
102
+ return 'still on NODE_PATH — a live resolution path, whatever the boot mode says';
103
+ }
104
+ return null;
105
+ }
106
+ /** Is `child` the same path as `parent`, or under it? Separator-aware. */
107
+ function isInside(child, parent) {
108
+ const normalise = (p) => (p.endsWith('/') ? p.slice(0, -1) : p);
109
+ const c = normalise(child);
110
+ const p = normalise(parent);
111
+ return c === p || c.startsWith(`${p}/`);
112
+ }
113
+ /**
114
+ * Carry out a plan. Never throws: a cleanup that fails is a node with an extra
115
+ * directory, and a cleanup that ends a process is an outage.
116
+ *
117
+ * The blocked check is repeated here on purpose. `planSingleCopyCleanup` is the
118
+ * only intended producer, but a hand-assembled plan must not be able to walk
119
+ * past the one gate that keeps this from running at the wrong moment.
120
+ */
121
+ async function executeSingleCopyCleanup(plan, fs, log) {
122
+ if (plan.blocked) {
123
+ log(`single-copy cleanup REFUSED — ${plan.blockedReason ?? 'blocked'}`);
124
+ return { removed: [], failed: [], refused: true };
125
+ }
126
+ const removed = [];
127
+ const failed = [];
128
+ for (const candidate of plan.remove) {
129
+ if (!fs.exists(candidate.path))
130
+ continue;
131
+ const label = `${candidate.pkg ?? candidate.kind} ${candidate.version ?? '<unknown version>'}`;
132
+ const aside = `${candidate.path}.removing-${new Date().toISOString().replace(/[:.]/g, '-')}`;
133
+ try {
134
+ await fs.rename(candidate.path, aside);
135
+ }
136
+ catch (err) {
137
+ failed.push(candidate.path);
138
+ log(`single-copy cleanup — could NOT remove ${candidate.path} (${label}): ${errMsg(err)}`);
139
+ continue;
140
+ }
141
+ // Past this line the copy can no longer be resolved by anything, which is
142
+ // the whole point; the bytes are a separate, best-effort concern.
143
+ removed.push(candidate.path);
144
+ log(`single-copy cleanup — removed ${candidate.path} (${label})`);
145
+ try {
146
+ await fs.remove(aside);
147
+ }
148
+ catch (err) {
149
+ log(`single-copy cleanup — ${aside} still on disk (held open?): ${errMsg(err)}`);
150
+ }
151
+ }
152
+ return { removed, failed, refused: false };
153
+ }
154
+ function errMsg(err) {
155
+ return err instanceof Error ? err.message : String(err);
156
+ }
157
+ /**
158
+ * The report. Always says how many copies it looked at, so a boot that removed
159
+ * nothing is distinguishable from a boot where the sweep never ran — and a
160
+ * BLOCKED sweep names every copy it left behind, because those are exactly the
161
+ * ones an operator would otherwise believe were gone.
162
+ */
163
+ function formatCleanupPlan(plan) {
164
+ const lines = [];
165
+ if (plan.blocked) {
166
+ lines.push(`single-copy cleanup blocked — ${plan.blockedReason ?? 'unknown reason'}; ` +
167
+ `${plan.keep.length} copy(ies) kept`);
168
+ }
169
+ else {
170
+ lines.push(`single-copy cleanup — ${plan.remove.length} copy(ies) to remove`);
171
+ }
172
+ for (const c of plan.remove) {
173
+ lines.push(` remove ${c.path} — ${c.pkg ?? c.kind} ${c.version ?? '<unknown version>'}`);
174
+ }
175
+ for (const k of plan.keep) {
176
+ lines.push(` keep ${k.candidate.path} — ${k.candidate.pkg ?? k.candidate.kind} ` +
177
+ `${k.candidate.version ?? '<unknown version>'}: ${k.reason}`);
178
+ }
179
+ return lines;
180
+ }
181
+ /**
182
+ * The host-provided packages the closure carries. A copy of any of these under
183
+ * the addon root WINS over the closure and is never refreshed — the shape that
184
+ * left both agents 17 versions behind ([D107](../../../docs/decisions/adr-0107.md)),
185
+ * and the reason `<dataDir>/addons/@camstack/system` is on the agent's disk at
186
+ * 1.2.78 while it runs a newer closure.
187
+ */
188
+ exports.CLOSURE_PROVIDED_PACKAGES = [
189
+ '@camstack/system',
190
+ '@camstack/types',
191
+ '@camstack/sdk',
192
+ '@camstack/shm-ring',
193
+ '@camstack/ui-library',
194
+ ];
195
+ /**
196
+ * Every redundant DATA-side copy on this node, in removal order: the legacy
197
+ * framework tree first, then the host-provided packages under the addon root.
198
+ *
199
+ * A copy whose `package.json` will not parse is still reported, with a null
200
+ * version — dropping it would under-report exactly the kind that has been
201
+ * sitting somewhere since an interrupted install.
202
+ */
203
+ function discoverRedundantCopies(input, fs) {
204
+ const found = [];
205
+ const legacyFramework = `${trimSlash(input.dataDir)}/framework`;
206
+ if (fs.exists(legacyFramework)) {
207
+ found.push({
208
+ kind: 'legacy-framework-tree',
209
+ path: legacyFramework,
210
+ pkg: null,
211
+ version: fs.readVersion(`${legacyFramework}/node_modules/@camstack/system`),
212
+ });
213
+ }
214
+ for (const pkg of exports.CLOSURE_PROVIDED_PACKAGES) {
215
+ if (!input.closureProvides(pkg))
216
+ continue;
217
+ const dir = `${trimSlash(input.addonRoot)}/${pkg}`;
218
+ if (!fs.exists(dir))
219
+ continue;
220
+ found.push({
221
+ kind: 'addon-root-closure-copy',
222
+ path: dir,
223
+ pkg,
224
+ version: fs.readVersion(dir),
225
+ });
226
+ }
227
+ return found;
228
+ }
229
+ function trimSlash(p) {
230
+ return p.endsWith('/') ? p.slice(0, -1) : p;
231
+ }
232
+ /**
233
+ * The kill switch. Default ON — the operator asked for convergence to one copy
234
+ * — but a destructive sweep that cannot be turned off from the outside is one
235
+ * an operator has to edit code to stop. `CAMSTACK_SINGLE_COPY_CLEANUP=off`
236
+ * (or `0` / `false`) leaves every copy where it is, and the boot says so.
237
+ */
238
+ function isCleanupEnabled(env) {
239
+ const raw = env['CAMSTACK_SINGLE_COPY_CLEANUP']?.trim().toLowerCase();
240
+ return raw !== 'off' && raw !== '0' && raw !== 'false';
241
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.101",
3
+ "version": "1.2.102",
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.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",
36
+ "@camstack/addon-admin-ui": "1.2.52",
37
+ "@camstack/addon-agent-ui": "1.2.15",
38
+ "@camstack/addon-auth": "1.2.16",
39
+ "@camstack/addon-decoder-nodeav": "1.2.14",
40
+ "@camstack/addon-notifiers": "1.2.19",
41
+ "@camstack/addon-pipeline": "1.2.71",
42
+ "@camstack/addon-pipeline-orchestrator": "1.2.51",
43
+ "@camstack/addon-post-analysis": "1.2.68",
44
+ "@camstack/sdk": "1.2.16",
45
+ "@camstack/shm-ring": "1.1.14",
46
+ "@camstack/system": "1.2.86",
47
+ "@camstack/types": "1.2.65",
48
+ "@camstack/ui-library": "1.2.44",
49
49
  "@fastify/compress": "^9.0.0",
50
50
  "@fastify/cookie": "^11.0.2",
51
51
  "@fastify/cors": "^11.2.0",