@camstack/server 1.2.71 → 1.2.72

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.
@@ -0,0 +1,165 @@
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.KNOWN_ELECTRON_APP_IDS = exports.ADDON_SCOPE = exports.ADDONS_SUBDIR = void 0;
37
+ exports.candidateDataRoots = candidateDataRoots;
38
+ exports.inventoryAddonRoots = inventoryAddonRoots;
39
+ exports.formatAddonRootReport = formatAddonRootReport;
40
+ /**
41
+ * Boot-time inventory of RIVAL ADDON ROOTS — the addon-shaped companion to
42
+ * `package-inventory.ts` (D45).
43
+ *
44
+ * WHAT THE OTHER ONE CANNOT SEE
45
+ * `package-inventory.ts` answers "how many copies of `@camstack/system` exist
46
+ * under the roots this node resolves through". Every root it scans is derived
47
+ * from the ACTIVE data dir, so a whole second data root — a different
48
+ * `<dataDir>` with its own `addons/@camstack/` tree, belonging to a retired
49
+ * app id or an abandoned layout — is structurally invisible to it. Yet that is
50
+ * the copy people actually read versions out of, because it is a plausible
51
+ * path with plausible package.json files in it.
52
+ *
53
+ * MEASURED, 2026-08-07
54
+ * The Mac agent node has FOUR addon roots on one disk:
55
+ *
56
+ * ~/Library/Application Support/@camstack/desktop/data/addons/@camstack
57
+ * — LIVE (addon-pipeline 1.2.43, deployed that afternoon)
58
+ * ~/camstack-agent-data/addons/@camstack
59
+ * — abandoned 2026-06-28, addon-pipeline 1.1.1
60
+ * ~/Library/Application Support/@camstack/electron-agent/agent-data/addons/@camstack
61
+ * — abandoned 2026-07-20, addon-pipeline 1.1.70
62
+ * ~/Library/Application Support/@camstack/electron/data/addons/@camstack
63
+ * — abandoned 2026-07-17, addon-pipeline 1.1.59
64
+ *
65
+ * Four truthful answers to "what version of addon-pipeline is on the Mac", one
66
+ * of which is about the running node. An hour was spent on the wrong one.
67
+ *
68
+ * WHAT THIS IS NOT
69
+ * It is not a filesystem walk and it does not delete anything. It stats a
70
+ * bounded, explicit candidate list and prints what it finds, once, at boot —
71
+ * the same policy as D45: make a second install location LOUD instead of
72
+ * letting it coexist silently. Removal stays an operator decision.
73
+ *
74
+ * Pure by construction: the caller injects every filesystem operation, so the
75
+ * whole thing is asserted against a described layout rather than a real disk.
76
+ */
77
+ const path = __importStar(require("node:path"));
78
+ /** The one directory under a data root that holds installed addons. */
79
+ exports.ADDONS_SUBDIR = 'addons';
80
+ /** The scope every first-party addon package sits under. */
81
+ exports.ADDON_SCOPE = '@camstack';
82
+ /**
83
+ * Data roots a camstack node has ever been configured to use on this machine.
84
+ *
85
+ * Enumerated, not searched. Each entry is a layout this project has actually
86
+ * shipped, so the list documents its own history:
87
+ * - `~/camstack-agent-data` — the pre-Electron agent's data root.
88
+ * - `<appSupport>/@camstack/<appId>/data` — every Electron app id the product
89
+ * has carried (`electron`, `electron-agent`, `desktop`). The rename left the
90
+ * old ids' trees behind, each a complete addon install.
91
+ * - `<appSupport>/@camstack/electron-agent/agent-data` — the one app id that
92
+ * used a different subdir name.
93
+ *
94
+ * A new app id must be ADDED here when it ships, and an id must never be
95
+ * removed when it is retired — a retired id is exactly what this looks for.
96
+ */
97
+ exports.KNOWN_ELECTRON_APP_IDS = ['electron', 'electron-agent', 'desktop'];
98
+ function candidateDataRoots(input) {
99
+ if (input.homeDir.length === 0)
100
+ return [];
101
+ const appSupport = input.platform === 'darwin'
102
+ ? path.join(input.homeDir, 'Library', 'Application Support')
103
+ : path.join(input.homeDir, '.config');
104
+ const roots = [
105
+ path.join(input.homeDir, 'camstack-agent-data'),
106
+ path.join(input.homeDir, 'camstack-data'),
107
+ ...exports.KNOWN_ELECTRON_APP_IDS.map((id) => path.join(appSupport, exports.ADDON_SCOPE, id, 'data')),
108
+ path.join(appSupport, exports.ADDON_SCOPE, 'electron-agent', 'agent-data'),
109
+ ];
110
+ const seen = new Set();
111
+ return roots.filter((r) => {
112
+ if (seen.has(r))
113
+ return false;
114
+ seen.add(r);
115
+ return true;
116
+ });
117
+ }
118
+ /** Up to this many `name@version` pairs are sampled per rival root. */
119
+ const SAMPLE_SIZE = 4;
120
+ function inventoryAddonRoots(input, fs, candidates = candidateDataRoots(input)) {
121
+ const activeScopeDir = path.join(input.activeAddonsDir, exports.ADDON_SCOPE);
122
+ const activeReal = fs.exists(activeScopeDir) ? fs.realPath(activeScopeDir) : activeScopeDir;
123
+ const rivals = [];
124
+ const seenReal = new Set([activeReal]);
125
+ for (const root of candidates) {
126
+ const scopeDir = path.join(root, exports.ADDONS_SUBDIR, exports.ADDON_SCOPE);
127
+ if (!fs.exists(scopeDir))
128
+ continue;
129
+ // A symlink or bind pointing back at the live tree is the SAME root, not a
130
+ // rival — reporting it would train operators to ignore this warning.
131
+ const real = fs.realPath(scopeDir);
132
+ if (seenReal.has(real))
133
+ continue;
134
+ seenReal.add(real);
135
+ const packages = fs.listPackages(scopeDir);
136
+ const sample = packages.slice(0, SAMPLE_SIZE).map((name) => {
137
+ const version = fs.readVersion(path.join(scopeDir, name));
138
+ return `${name}@${version ?? '<unreadable>'}`;
139
+ });
140
+ rivals.push({ scopeDir, packageCount: packages.length, sample });
141
+ }
142
+ return { activeScopeDir, rivals, candidatesScanned: candidates };
143
+ }
144
+ /**
145
+ * The loud report. Empty string when this machine has ONE addon root — a clean
146
+ * node says nothing, so the one that isn't stands out instead of scrolling past.
147
+ */
148
+ function formatAddonRootReport(inventory) {
149
+ if (inventory.rivals.length === 0)
150
+ return '';
151
+ const lines = [
152
+ 'A SECOND addon install location exists on this machine.',
153
+ `This node loads addons from: ${inventory.activeScopeDir}`,
154
+ 'Every version below is true of a tree nothing is running. Reading one of',
155
+ 'them answers a question about the wrong node.',
156
+ '',
157
+ ];
158
+ for (const rival of inventory.rivals) {
159
+ lines.push(` ${rival.scopeDir} — ${rival.packageCount} package(s)`);
160
+ for (const entry of rival.sample)
161
+ lines.push(` ${entry}`);
162
+ }
163
+ lines.push('', ' Remove it, or confirm it is inert — it will be reported on every boot.');
164
+ return lines.join('\n');
165
+ }
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.resolveDeployAction = resolveDeployAction;
37
37
  exports.createAgentService = createAgentService;
38
+ const node_crypto_1 = require("node:crypto");
38
39
  const fs = __importStar(require("node:fs"));
39
40
  const os = __importStar(require("node:os"));
40
41
  const path = __importStar(require("node:path"));
@@ -96,11 +97,13 @@ async function withTimeout(p, ms, fallback) {
96
97
  }
97
98
  /** Bound for the metrics snapshot inside status/health — see `withTimeout`. */
98
99
  const METRICS_SNAPSHOT_TIMEOUT_MS = 2_000;
99
- /**
100
- * Pure factory that builds the deploy routing logic from a seam. Exported so
101
- * the routing can be unit-tested without Moleculer or a real filesystem.
102
- */
103
100
  function resolveDeployAction(seam) {
101
+ const applyAndReport = async (addonId, buf) => {
102
+ const appliedSha256 = (0, node_crypto_1.createHash)('sha256').update(buf).digest('hex');
103
+ const { addonDir } = await seam.applyBundle(buf);
104
+ seam.onApplied(addonDir);
105
+ return { success: true, addonId, path: addonDir, appliedSha256 };
106
+ };
104
107
  return async (params) => {
105
108
  const { addonId, source, bundle } = params;
106
109
  if (source && (0, system_1.isAddonDeploySource)(source)) {
@@ -110,19 +113,13 @@ function resolveDeployAction(seam) {
110
113
  // loadedAddons eviction (onApplied) only applies to bundle-extracted installs.
111
114
  return { success: true, addonId };
112
115
  }
113
- const buf = await seam.fetchBundle(source);
114
- const { addonDir } = await seam.applyBundle(buf);
115
- seam.onApplied(addonDir);
116
- return { success: true, addonId, path: addonDir };
116
+ return applyAndReport(addonId, await seam.fetchBundle(source));
117
117
  }
118
118
  // Legacy inline-bundle path (back-compat, removed next release).
119
119
  if (bundle === undefined) {
120
120
  throw new Error('$agent.deploy: no source descriptor and no legacy bundle provided');
121
121
  }
122
- const buf = typeof bundle === 'string' ? Buffer.from(bundle, 'base64') : bundle;
123
- const { addonDir } = await seam.applyBundle(buf);
124
- seam.onApplied(addonDir);
125
- return { success: true, addonId, path: addonDir };
122
+ return applyAndReport(addonId, typeof bundle === 'string' ? Buffer.from(bundle, 'base64') : bundle);
126
123
  };
127
124
  }
128
125
  function readHubAddressFromConfig(configPath) {
@@ -39,13 +39,14 @@ exports.hubBundleBaseUrl = hubBundleBaseUrl;
39
39
  exports.buildHubHttpSource = buildHubHttpSource;
40
40
  exports.registerAddonUploadRoute = registerAddonUploadRoute;
41
41
  const fs = __importStar(require("node:fs"));
42
- const path = __importStar(require("node:path"));
43
42
  const os = __importStar(require("node:os"));
44
- const tarball_manifest_js_1 = require("./tarball-manifest.js");
45
- const upload_auth_js_1 = require("./upload-auth.js");
43
+ const path = __importStar(require("node:path"));
46
44
  const addon_package_service_js_1 = require("../core/addon/addon-package.service.js");
47
45
  const index_js_1 = require("../server-root/index.js");
46
+ const agent_addon_delivery_js_1 = require("./agent-addon-delivery.js");
48
47
  const deploy_stage_registry_js_1 = require("./deploy-stage-registry.js");
48
+ const tarball_manifest_js_1 = require("./tarball-manifest.js");
49
+ const upload_auth_js_1 = require("./upload-auth.js");
49
50
  const deployStageRegistry = new deploy_stage_registry_js_1.DeployStageRegistry();
50
51
  function getDeployStageRegistry() {
51
52
  return deployStageRegistry;
@@ -80,8 +81,19 @@ function registerDeployBundleRoute(fastify) {
80
81
  });
81
82
  }
82
83
  const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
83
- const AGENT_DEPLOY_TIMEOUT_MS = 60_000;
84
- const AGENT_DEPLOY_CONTROL_TIMEOUT_MS = 30_000;
84
+ /**
85
+ * Read one text field out of a multipart body, or `null`.
86
+ *
87
+ * Only call this AFTER the file part has been drained — see the note at the
88
+ * call site. The plugin types the value as `unknown`, hence the narrowing.
89
+ */
90
+ function readMultipartTextField(fields, name) {
91
+ const field = fields[name];
92
+ if (typeof field !== 'object' || field === null || !('value' in field))
93
+ return null;
94
+ const value = field.value;
95
+ return typeof value === 'string' ? value : null;
96
+ }
85
97
  /** Base URL agents pull staged deploy bundles from. */
86
98
  function hubBundleBaseUrl() {
87
99
  return process.env['CAMSTACK_HUB_PUBLIC_URL'] ?? 'https://127.0.0.1:4443';
@@ -96,7 +108,14 @@ function buildHubHttpSource(buffer, hubBaseUrl) {
96
108
  bytes: staged.bytes,
97
109
  };
98
110
  }
99
- async function registerAddonUploadRoute(fastify, addonBridge, authService, moleculer, addonRegistry, addonPackageService, logger) {
111
+ async function registerAddonUploadRoute(fastify, addonBridge, authService, moleculer, addonRegistry, addonPackageService, logger,
112
+ /**
113
+ * Marks the "hub is writing to this node" window around every agent
114
+ * delivery so the addon back-fill cannot mistake the roster gap a deploy
115
+ * opens for a node that lost its addons. Optional so route unit tests need
116
+ * not build one; `main.ts` always passes the AgentRegistryService's.
117
+ */
118
+ deliveryLedger) {
100
119
  await fastify.register(Promise.resolve().then(() => __importStar(require('@fastify/multipart'))), {
101
120
  limits: { fileSize: MAX_UPLOAD_BYTES },
102
121
  });
@@ -120,24 +139,25 @@ async function registerAddonUploadRoute(fastify, addonBridge, authService, molec
120
139
  if (!(0, tarball_manifest_js_1.isTarballFilename)(data.filename)) {
121
140
  return reply.status(400).send({ error: 'File must be a .tgz or .tar.gz archive' });
122
141
  }
123
- // `nodeId` and `addonId` come through as multipart text fields.
142
+ // Drain the file part FIRST. `data.fields` is the plugin's live `body`
143
+ // object, populated by busboy as parsing proceeds — so a field that sits
144
+ // AFTER the file part in the body simply is not there yet when
145
+ // `request.file()` resolves. Reading `nodeId` at that moment worked for a
146
+ // small tarball (whole body in one chunk, already parsed) and silently
147
+ // returned `undefined` for a large one.
148
+ //
149
+ // Cost, 2026-08-07: every `camstack deploy packages/addon-pipeline -n
150
+ // little-unraid` — a 4.9 MB tarball — read `nodeId = null` and took the HUB
151
+ // branch. The CLI printed `✓ little-unraid: @camstack/addon-pipeline@1.2.43`
152
+ // (the label it INTENDED, never the target the hub used) and the named agent
153
+ // was never addressed at all. It reproduced 4× and was diagnosed as a stale
154
+ // dist. Reading the fields after the drain is the fix here; the CLI also
155
+ // sends them BEFORE the file so this cannot depend on chunking again.
156
+ const buffer = await data.toBuffer();
124
157
  // `data.fields[X].value` is the parsed string; we narrow defensively
125
158
  // because the multipart plugin types it as `unknown`.
126
- const nodeIdField = data.fields['nodeId'];
127
- const addonIdField = data.fields['addonId'];
128
- const nodeId = typeof nodeIdField === 'object' &&
129
- nodeIdField !== null &&
130
- 'value' in nodeIdField &&
131
- typeof nodeIdField.value === 'string'
132
- ? nodeIdField.value
133
- : null;
134
- const addonIdHint = typeof addonIdField === 'object' &&
135
- addonIdField !== null &&
136
- 'value' in addonIdField &&
137
- typeof addonIdField.value === 'string'
138
- ? addonIdField.value
139
- : null;
140
- const buffer = await data.toBuffer();
159
+ const nodeId = readMultipartTextField(data.fields, 'nodeId');
160
+ const addonIdHint = readMultipartTextField(data.fields, 'addonId');
141
161
  // Gate: reject archives that don't expose a parseable package.json with
142
162
  // name + version. The hub installer did this implicitly via npm; the
143
163
  // agent path would otherwise fail mid-extraction with no clean rollback.
@@ -153,10 +173,10 @@ async function registerAddonUploadRoute(fastify, addonBridge, authService, molec
153
173
  // explicit `nodeId` value routes only to that agent via `$agent.deploy`.
154
174
  const baseUrl = hubBundleBaseUrl();
155
175
  if (!nodeId || nodeId === 'hub') {
156
- return installToHub(reply, addonBridge, addonRegistry, addonPackageService, moleculer, logger, data.filename, buffer, baseUrl);
176
+ return installToHub(reply, addonBridge, addonRegistry, addonPackageService, moleculer, logger, data.filename, buffer, baseUrl, deliveryLedger);
157
177
  }
158
178
  const agentAddonId = addonIdHint ?? manifest.name;
159
- return deployToAgent(reply, moleculer, nodeId, agentAddonId, buffer, baseUrl);
179
+ return deployToAgent(reply, moleculer, logger, nodeId, agentAddonId, buffer, baseUrl, deliveryLedger);
160
180
  });
161
181
  }
162
182
  /**
@@ -194,7 +214,7 @@ function packageHasAgentDeployable(addonsDir, packageName) {
194
214
  * without an agent restart. Agents that fail are reported per-node — one
195
215
  * unreachable agent must not block the others or the hub install.
196
216
  */
197
- async function propagateToAgents(moleculer, logger, packageName, buffer, hubBaseUrl) {
217
+ async function propagateToAgents(moleculer, logger, packageName, buffer, hubBaseUrl, deliveryLedger) {
198
218
  const broker = moleculer.broker;
199
219
  const nodes = broker.registry?.getNodeList?.({ onlyAvailable: true }) ?? [];
200
220
  // Moleculer reports both top-level nodes (`hub`, `dev-agent-0`, …) AND
@@ -215,33 +235,20 @@ async function propagateToAgents(moleculer, logger, packageName, buffer, hubBase
215
235
  return [];
216
236
  const results = [];
217
237
  for (const nodeId of agentNodeIds) {
218
- try {
219
- // The hub currently only ever sends the `hub-http` source (agent streams
220
- // the tgz directly from this hub). The `{kind:'npm'}` source is a reserved
221
- // follow-up (published-addon optimization) and is intentionally not produced
222
- // yet. Wiring it later must also make the agent's npm path evict the addon's
223
- // declaration ids before `$agent.reload`, otherwise an npm update pins the
224
- // stale version.
225
- const deployRaw = await broker.call('$agent.deploy', { addonId: packageName, source: buildHubHttpSource(buffer, hubBaseUrl) }, { nodeID: nodeId, timeout: AGENT_DEPLOY_CONTROL_TIMEOUT_MS });
226
- if (!isAgentDeployResponse(deployRaw)) {
227
- results.push({ nodeId, success: false, error: 'malformed deploy response' });
228
- continue;
229
- }
230
- const reloadRaw = await broker.call('$agent.reload', {}, { nodeID: nodeId, timeout: AGENT_DEPLOY_TIMEOUT_MS });
231
- const reloaded = reloadRaw !== null &&
232
- typeof reloadRaw === 'object' &&
233
- 'loaded' in reloadRaw
234
- ? reloadRaw.loaded
235
- : [];
236
- results.push({ nodeId, success: true, loaded: reloaded });
237
- }
238
- catch (err) {
239
- results.push({
240
- nodeId,
241
- success: false,
242
- error: err instanceof Error ? err.message : String(err),
243
- });
244
- }
238
+ // The hub currently only ever sends the `hub-http` source (agent streams
239
+ // the tgz directly from this hub). The `{kind:'npm'}` source is a reserved
240
+ // follow-up (published-addon optimization) and is intentionally not produced
241
+ // yet. Wiring it later must also make the agent's npm path evict the addon's
242
+ // declaration ids before `$agent.reload`, otherwise an npm update pins the
243
+ // stale version.
244
+ results.push(await (0, agent_addon_delivery_js_1.deliverAddonToAgent)({
245
+ broker,
246
+ logger,
247
+ nodeId,
248
+ packageName,
249
+ source: buildHubHttpSource(buffer, hubBaseUrl),
250
+ ...(deliveryLedger === undefined ? {} : { deliveryLedger }),
251
+ }));
245
252
  }
246
253
  return results;
247
254
  }
@@ -254,7 +261,7 @@ async function propagateToAgents(moleculer, logger, packageName, buffer, hubBase
254
261
  * Without this the CLI push was write-to-disk-only and required a server
255
262
  * restart to actually run the new code.
256
263
  */
257
- async function installToHub(reply, addonBridge, addonRegistry, addonPackageService, moleculer, logger, filename, buffer, hubBaseUrl) {
264
+ async function installToHub(reply, addonBridge, addonRegistry, addonPackageService, moleculer, logger, filename, buffer, hubBaseUrl, deliveryLedger) {
258
265
  const tmpDir = path.join(os.tmpdir(), `camstack-addon-upload-${Date.now()}`);
259
266
  fs.mkdirSync(tmpDir, { recursive: true });
260
267
  const tgzPath = path.join(tmpDir, filename);
@@ -379,7 +386,7 @@ async function installToHub(reply, addonBridge, addonRegistry, addonPackageServi
379
386
  });
380
387
  }
381
388
  if (propagatable) {
382
- void propagateToAgents(moleculer, logger, result.name, buffer, hubBaseUrl).then((agentResults) => {
389
+ void propagateToAgents(moleculer, logger, result.name, buffer, hubBaseUrl, deliveryLedger).then((agentResults) => {
383
390
  logger.info('propagation done', {
384
391
  meta: { packageName: result.name, agents: agentResults },
385
392
  });
@@ -400,40 +407,40 @@ async function installToHub(reply, addonBridge, addonRegistry, addonPackageServi
400
407
  fs.rmSync(tmpDir, { recursive: true, force: true });
401
408
  }
402
409
  }
403
- function isAgentDeployResponse(value) {
404
- if (value === null || typeof value !== 'object')
405
- return false;
406
- const v = value;
407
- if (typeof v.success !== 'boolean')
408
- return false;
409
- if (typeof v.addonId !== 'string')
410
- return false;
411
- if (v.path !== undefined && typeof v.path !== 'string')
412
- return false;
413
- return true;
414
- }
415
- async function deployToAgent(reply, moleculer, nodeId, addonId, buffer, hubBaseUrl) {
416
- try {
417
- const broker = moleculer.broker;
418
- // The hub currently only ever sends the `hub-http` source (agent streams
419
- // the tgz directly from this hub). The `{kind:'npm'}` source is a reserved
420
- // follow-up (published-addon optimization) and is intentionally not produced
421
- // yet. Wiring it later must also make the agent's npm path evict the addon's
422
- // declaration ids before `$agent.reload`, otherwise an npm update pins the
423
- // stale version.
424
- const raw = await broker.call('$agent.deploy', { addonId, source: buildHubHttpSource(buffer, hubBaseUrl) }, { nodeID: nodeId, timeout: AGENT_DEPLOY_CONTROL_TIMEOUT_MS });
425
- if (!isAgentDeployResponse(raw)) {
426
- return reply.status(502).send({ error: 'Agent deploy returned malformed response' });
427
- }
428
- return reply.send({
429
- success: true,
430
- target: nodeId,
431
- addonId: raw.addonId,
432
- path: raw.path,
433
- });
434
- }
435
- catch (err) {
436
- const msg = err instanceof Error ? err.message : String(err);
437
- return reply.status(502).send({ error: `Agent deploy failed: ${msg}` });
410
+ /**
411
+ * `camstack deploy <pkg> -n <nodeId>` install on ONE named agent.
412
+ *
413
+ * Goes through the same `deliverAddonToAgent` as the cluster propagation. It
414
+ * did not, once: this branch called `$agent.deploy` and returned success
415
+ * without ever asking the node to load what it had just written. The bundle was
416
+ * on disk and the node ran the old one — see `agent-addon-delivery.ts` for the
417
+ * evidence. The response now carries what was reloaded and what was verified,
418
+ * so the CLI can print facts instead of a bare tick.
419
+ */
420
+ async function deployToAgent(reply, moleculer, logger, nodeId, addonId, buffer, hubBaseUrl, deliveryLedger) {
421
+ // The hub currently only ever sends the `hub-http` source (agent streams the
422
+ // tgz directly from this hub). The `{kind:'npm'}` source is a reserved
423
+ // follow-up (published-addon optimization) and is intentionally not produced
424
+ // yet. Wiring it later must also make the agent's npm path evict the addon's
425
+ // declaration ids before `$agent.reload`, otherwise an npm update pins the
426
+ // stale version.
427
+ const result = await (0, agent_addon_delivery_js_1.deliverAddonToAgent)({
428
+ broker: moleculer.broker,
429
+ logger,
430
+ nodeId,
431
+ packageName: addonId,
432
+ source: buildHubHttpSource(buffer, hubBaseUrl),
433
+ ...(deliveryLedger === undefined ? {} : { deliveryLedger }),
434
+ });
435
+ if (!result.success) {
436
+ return reply.status(502).send({ error: `Agent deploy failed: ${result.error ?? 'unknown'}` });
438
437
  }
438
+ return reply.send({
439
+ success: true,
440
+ target: nodeId,
441
+ addonId: result.addonId,
442
+ path: result.path,
443
+ reloaded: result.loaded ?? [],
444
+ verifiedSha256: result.verifiedSha256,
445
+ });
439
446
  }
@@ -0,0 +1,135 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AGENT_RELOAD_TIMEOUT_MS = exports.AGENT_DEPLOY_CONTROL_TIMEOUT_MS = void 0;
4
+ exports.isAgentDeployResponse = isAgentDeployResponse;
5
+ exports.deliverAddonToAgent = deliverAddonToAgent;
6
+ /**
7
+ * Delivering an addon bundle to an agent — the single implementation.
8
+ *
9
+ * There used to be two. `propagateToAgents` (the hub-install broadcast) called
10
+ * `$agent.deploy` **and** `$agent.reload`; `deployToAgent` (the `camstack deploy
11
+ * -n <node>` path) called only `$agent.deploy`. Both reported success. The
12
+ * second one is a write-to-disk-only deploy: the tarball lands, the install
13
+ * manifest bumps `updatedAt`, the CLI prints `✓ <node>: <pkg>@<version>` — and
14
+ * every runner on that node keeps executing the PREVIOUS dist until something
15
+ * else happens to restart it.
16
+ *
17
+ * Reproduced on little-unraid 2026-08-07 with a marker file inside `dist/`: the
18
+ * marker was on disk 2 seconds after the `✓`, and not one of the 12 addon-runner
19
+ * pids had been recycled. Four operators-hours were spent that day looking for a
20
+ * stale tarball cache that does not exist — the bytes were always correct, they
21
+ * were just never loaded.
22
+ *
23
+ * So delivery has three steps, not one, and both callers share them:
24
+ * 1. `$agent.deploy` — pull + install the bundle.
25
+ * 2. hash check — the agent reports the sha256 of the bytes it actually
26
+ * applied; it must be the one we staged.
27
+ * 3. `$agent.reload` — re-instantiate the addons from the new dist, and
28
+ * report which ones.
29
+ */
30
+ /** `$agent.deploy` is a control call: install work is bounded well inside this. */
31
+ exports.AGENT_DEPLOY_CONTROL_TIMEOUT_MS = 30_000;
32
+ /**
33
+ * `$agent.reload` re-forks group runners and re-registers their capabilities —
34
+ * legitimately slow on a node with a Python inference pool.
35
+ */
36
+ exports.AGENT_RELOAD_TIMEOUT_MS = 60_000;
37
+ function isAgentDeployResponse(value) {
38
+ if (value === null || typeof value !== 'object')
39
+ return false;
40
+ const v = value;
41
+ if (typeof v.success !== 'boolean')
42
+ return false;
43
+ if (typeof v.addonId !== 'string')
44
+ return false;
45
+ if (v.path !== undefined && typeof v.path !== 'string')
46
+ return false;
47
+ if (v.appliedSha256 !== undefined && typeof v.appliedSha256 !== 'string')
48
+ return false;
49
+ return true;
50
+ }
51
+ function readLoaded(value) {
52
+ if (value === null || typeof value !== 'object')
53
+ return [];
54
+ const loaded = value.loaded;
55
+ if (!Array.isArray(loaded))
56
+ return [];
57
+ return loaded.filter((id) => typeof id === 'string');
58
+ }
59
+ /**
60
+ * Install `source` on one agent and leave it RUNNING the new code.
61
+ *
62
+ * Never throws: a per-node failure is returned so one unreachable agent cannot
63
+ * abort a cluster propagation or the hub's own install.
64
+ */
65
+ async function deliverAddonToAgent(args) {
66
+ const { broker, logger, nodeId, packageName, source } = args;
67
+ // Opened BEFORE the first byte and closed in the `finally` below — the window
68
+ // must cover `$agent.deploy` (which evicts the package's declaration ids) AND
69
+ // `$agent.reload` (whose re-registrations fire the reconcile), not just the
70
+ // happy path.
71
+ const closeWindow = args.deliveryLedger?.begin(nodeId, packageName);
72
+ try {
73
+ const deployRaw = await broker.call('$agent.deploy', { addonId: packageName, source }, { nodeID: nodeId, timeout: exports.AGENT_DEPLOY_CONTROL_TIMEOUT_MS });
74
+ if (!isAgentDeployResponse(deployRaw)) {
75
+ logger.warn('agent deploy: malformed deploy response', {
76
+ meta: { nodeId, packageName },
77
+ });
78
+ return { nodeId, success: false, error: 'malformed deploy response' };
79
+ }
80
+ // The hash gate. The agent already verifies bytes+sha against the staged
81
+ // descriptor before unpacking, so a mismatch here means the agent applied a
82
+ // DIFFERENT bundle than the one this deploy staged — the failure mode a
83
+ // `✓` must never be printed over. Refuse before telling it to load them.
84
+ const applied = deployRaw.appliedSha256;
85
+ if (applied !== undefined && applied !== source.sha256) {
86
+ logger.error('agent deploy: applied bundle hash does not match the shipped bundle', {
87
+ meta: { nodeId, packageName, expected: source.sha256, applied },
88
+ });
89
+ return {
90
+ nodeId,
91
+ success: false,
92
+ error: `applied bundle hash mismatch (shipped ${source.sha256}, agent applied ${applied})`,
93
+ };
94
+ }
95
+ if (applied === undefined) {
96
+ logger.warn('agent deploy: node did not report an applied bundle hash — UNVERIFIED', {
97
+ meta: { nodeId, packageName },
98
+ });
99
+ }
100
+ logger.info('agent deploy: bundle applied', {
101
+ meta: { nodeId, packageName, path: deployRaw.path, verifiedSha256: applied },
102
+ });
103
+ // Reload. Without this the deploy is write-to-disk-only and the node keeps
104
+ // running the previous dist — the whole point of this module.
105
+ const reloadRaw = await broker.call('$agent.reload', {}, { nodeID: nodeId, timeout: exports.AGENT_RELOAD_TIMEOUT_MS });
106
+ const loaded = readLoaded(reloadRaw);
107
+ if (loaded.length === 0) {
108
+ // Not fatal — a reload can legitimately find nothing to (re)instantiate
109
+ // when a concurrent deploy already did it. But it is the exact shape of
110
+ // the silent no-op, so it is never allowed to pass unlogged.
111
+ logger.warn('agent deploy: reload loaded NOTHING — node may still run the previous code', {
112
+ meta: { nodeId, packageName },
113
+ });
114
+ }
115
+ else {
116
+ logger.info('agent deploy: reloaded', { meta: { nodeId, packageName, loaded } });
117
+ }
118
+ return {
119
+ nodeId,
120
+ success: true,
121
+ addonId: deployRaw.addonId,
122
+ ...(deployRaw.path === undefined ? {} : { path: deployRaw.path }),
123
+ loaded,
124
+ ...(applied === undefined ? {} : { verifiedSha256: applied }),
125
+ };
126
+ }
127
+ catch (err) {
128
+ const message = err instanceof Error ? err.message : String(err);
129
+ logger.warn('agent deploy: FAILED', { meta: { nodeId, packageName, error: message } });
130
+ return { nodeId, success: false, error: message };
131
+ }
132
+ finally {
133
+ closeWindow?.();
134
+ }
135
+ }
@@ -16,8 +16,30 @@ exports.createClusterNodesRouter = createClusterNodesRouter;
16
16
  const zod_1 = require("zod");
17
17
  const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
18
18
  const ForgetNodeInputSchema = zod_1.z.object({ nodeId: zod_1.z.string().min(1) });
19
+ const RepairNodeAddonsInputSchema = zod_1.z.object({ nodeId: zod_1.z.string().min(1) });
19
20
  function createClusterNodesRouter(agentRegistry) {
20
21
  return (0, trpc_middleware_js_1.trpcRouter)({
22
+ /**
23
+ * The explicit half of the addon back-fill's trigger set.
24
+ *
25
+ * Everything automatic is gated: a node only converges up when it
26
+ * registers, when the hub boots, and when the hub is NOT mid-delivery to
27
+ * it — because the roster a node reports while the hub is writing to it is
28
+ * not evidence of anything. Those gates are deliberately conservative, so
29
+ * there has to be a way for an operator to say "this node really is
30
+ * missing addons, put them back" without waiting for a restart.
31
+ *
32
+ * It grants no extra powers: it runs the same reconcile, against the same
33
+ * roster the node itself declared, with the same circuit breaker. Nothing
34
+ * can be repaired by hand that the node could not have converged to.
35
+ */
36
+ repairNodeAddons: trpc_middleware_js_1.adminProcedure
37
+ .input(RepairNodeAddonsInputSchema)
38
+ .output(zod_1.z.object({ success: zod_1.z.boolean() }))
39
+ .mutation(async ({ input }) => {
40
+ await agentRegistry.repairNodeAddons(input.nodeId);
41
+ return { success: true };
42
+ }),
21
43
  // Purge one node's persisted offline history. Pairs with
22
44
  // `pipelineOrchestrator.removeAgentSettings` on the frontend to fully
23
45
  // forget an offline node (history row + per-node pipeline assignments).