@camstack/server 1.2.71 → 1.2.73
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.
- package/dist/addon-root-inventory.js +165 -0
- package/dist/agent/agent-service.js +9 -12
- package/dist/api/addon-upload.js +96 -89
- package/dist/api/agent-addon-delivery.js +135 -0
- package/dist/api/core/cluster-nodes.router.js +22 -0
- package/dist/api/core/logs.router.js +2 -1
- package/dist/api/trpc/generated-cap-mounts.js +2 -1
- package/dist/api/trpc/generated-cap-routers.js +856 -733
- package/dist/core/addon/addon-package.service.js +53 -5
- package/dist/core/agent/agent-addon-backfill.js +26 -3
- package/dist/core/agent/agent-delivery-ledger.js +112 -0
- package/dist/core/agent/agent-registry.service.js +107 -9
- package/dist/core/auth/share-token.service.js +32 -9
- package/dist/launcher.js +77 -6
- package/dist/main.js +4 -1
- package/dist/manual-boot.js +44 -25
- package/package.json +8 -8
|
@@ -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
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/api/addon-upload.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
//
|
|
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
|
|
127
|
-
const
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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).
|