@camstack/server 1.2.681 → 1.2.682

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/CHANGELOG.md CHANGED
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.2.682] - 2026-10-28
11
+
12
+ ### Changed
13
+
14
+ - A freshly paired or re-registered agent node now receives every any-node addon installed on the hub, at the hub version
15
+ - A fresh agent is named after its short host name and architecture, and the hub offers agents its internal address instead of the public endpoint
16
+
10
17
  ## [1.2.681] - 2026-10-28
11
18
 
12
19
  ### Changed
@@ -33,7 +33,9 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.defaultAgentName = defaultAgentName;
36
37
  exports.loadAgentConfig = loadAgentConfig;
38
+ exports.seededAgentName = seededAgentName;
37
39
  const fs = __importStar(require("node:fs"));
38
40
  const path = __importStar(require("node:path"));
39
41
  const crypto = __importStar(require("node:crypto"));
@@ -95,15 +97,31 @@ function parseMeshTls(value) {
95
97
  return undefined;
96
98
  return { certPem, keyPem, caCertPem };
97
99
  }
100
+ /**
101
+ * The name an agent without a chosen one carries: the host name up to its first dot (so
102
+ * `.local` / `.localdomain` and a domain suffix drop out), then the architecture.
103
+ */
104
+ function defaultAgentName(hostname, arch) {
105
+ const short = hostname.trim().split('.')[0] ?? '';
106
+ return `${short.length > 0 ? short : 'agent'}-${arch}`;
107
+ }
108
+ /**
109
+ * The name before anyone renamed the agent: `CAMSTACK_AGENT_NAME` when set, else the default.
110
+ * Never the node id - that is an identity, not a label.
111
+ */
112
+ function seededAgentName() {
113
+ const fromEnv = process.env.CAMSTACK_AGENT_NAME;
114
+ return fromEnv !== undefined && fromEnv.trim().length > 0
115
+ ? fromEnv.trim()
116
+ : defaultAgentName(os.hostname(), os.arch());
117
+ }
98
118
  function loadAgentConfig(configPath, dataDirOverride) {
99
119
  const filePath = configPath ?? 'agent.json';
100
120
  const dataDir = dataDirOverride ?? process.env.CAMSTACK_DATA ?? './camstack-data';
101
121
  const resolvedDataDir = path.resolve(dataDir);
102
122
  // Name: config file takes priority over env (allows UI rename to stick).
103
123
  // Env is the initial seed, config file is the persisted override.
104
- const envName = process.env.CAMSTACK_NODE_ID ??
105
- process.env.CAMSTACK_AGENT_NAME ??
106
- `${os.hostname()}-${os.arch()}`;
124
+ const envName = seededAgentName();
107
125
  // Environment variable for the hub connection (optional — agent starts
108
126
  // without). Pairing material lives only in agent.json.
109
127
  const envHubAddress = process.env.CAMSTACK_HUB_ADDRESS || undefined;
@@ -64,6 +64,7 @@ const agent_http_auth_js_1 = require("./agent-http-auth.js");
64
64
  const agent_pairing_routes_js_1 = require("./agent-pairing-routes.js");
65
65
  const agent_pairing_store_js_1 = require("./agent-pairing-store.js");
66
66
  const node_1 = require("@camstack/types/node");
67
+ const agent_config_js_1 = require("./agent-config.js");
67
68
  const agent_update_routes_js_1 = require("./agent-update-routes.js");
68
69
  const derive_hub_url_js_1 = require("./derive-hub-url.js");
69
70
  Object.defineProperty(exports, "pickIpv4", { enumerable: true, get: function () { return derive_hub_url_js_1.pickIpv4; } });
@@ -259,10 +260,10 @@ function deriveHubConnectionState(nodes, hubAddressConfigured, paired) {
259
260
  * Read the current effective config from the persisted file.
260
261
  * This is the single source of truth -- always reflects what the UI wrote.
261
262
  */
262
- function getEffectiveConfig(configPath, nodeId) {
263
+ function getEffectiveConfig(configPath) {
263
264
  const raw = readConfigFile(configPath);
264
265
  return {
265
- name: typeof raw.name === 'string' ? raw.name : nodeId,
266
+ name: typeof raw.name === 'string' && raw.name.length > 0 ? raw.name : (0, agent_config_js_1.seededAgentName)(),
266
267
  hubAddress: typeof raw.hubAddress === 'string' ? raw.hubAddress : null,
267
268
  paired: (0, agent_pairing_store_js_1.isAgentPaired)(configPath),
268
269
  hasMeshTls: raw.meshTls !== null && typeof raw.meshTls === 'object',
@@ -387,7 +388,7 @@ async function createAgentHttpServer(getBroker, config) {
387
388
  // -- Agent status (enriched with connection state) ------------------
388
389
  app.get('/api/agent/status', async () => {
389
390
  const broker = getBroker();
390
- const eff = getEffectiveConfig(config.configPath, config.nodeId);
391
+ const eff = getEffectiveConfig(config.configPath);
391
392
  const nodes = getRegistryNodes(broker);
392
393
  const hubConnected = nodes.some((n) => n.id === 'hub');
393
394
  const hubConnectionState = config.getHubConnectionState
@@ -458,7 +459,7 @@ async function createAgentHttpServer(getBroker, config) {
458
459
  });
459
460
  // -- Unlock info (public; Continue-with-hub needs hub origin while locked) --
460
461
  app.get('/api/agent/unlock-info', async () => {
461
- const eff = getEffectiveConfig(config.configPath, config.nodeId);
462
+ const eff = getEffectiveConfig(config.configPath);
462
463
  const nodes = getRegistryNodes(getBroker());
463
464
  const resolvedHubAddress = resolveHubEndpoint(nodes);
464
465
  const hubHttpsOrigin = (eff.hubAddress !== null ? (0, derive_hub_url_js_1.deriveHubUrlFromHubAddress)(eff.hubAddress) : undefined) ??
@@ -539,7 +540,7 @@ async function createAgentHttpServer(getBroker, config) {
539
540
  // -- Config read (always from file -- single source of truth) -------
540
541
  app.get('/api/agent/config', async () => {
541
542
  const persisted = readConfigFile(config.configPath);
542
- const eff = getEffectiveConfig(config.configPath, config.nodeId);
543
+ const eff = getEffectiveConfig(config.configPath);
543
544
  return {
544
545
  nodeId: config.nodeId,
545
546
  name: eff.name,
@@ -608,7 +609,7 @@ async function createAgentHttpServer(getBroker, config) {
608
609
  // status poll.
609
610
  const hubsFinder = config.hubsFinder ??
610
611
  (0, hubs_found_js_1.createHubsFinder)({
611
- getConfiguredHubAddress: () => getEffectiveConfig(config.configPath, config.nodeId).hubAddress,
612
+ getConfiguredHubAddress: () => getEffectiveConfig(config.configPath).hubAddress,
612
613
  log: (message) => console.warn(`[Agent] ${message}`),
613
614
  });
614
615
  app.get('/api/agent/hubs-found', async () => hubsFinder.snapshot());
@@ -79,12 +79,16 @@ function failureOf(result) {
79
79
  function createAgentHubAddonControls(deps) {
80
80
  const states = new Map();
81
81
  const getHubVersions = async () => {
82
+ if (deps.isPaired !== undefined && !deps.isPaired())
83
+ return { ok: false, reason: 'unpaired' };
82
84
  try {
83
85
  const list = await deps.callHub(exports.HUB_ADDON_LIST_ACTION, undefined, HUB_LIST_TIMEOUT_MS);
84
86
  return { ok: true, versions: hubVersionsFromList(list) };
85
87
  }
86
88
  catch (err) {
87
- return { ok: false, error: errorMessage(err) };
89
+ // The transport's own words (a service name) are for the log, not the operator.
90
+ deps.onProblem?.(`the hub did not answer addons.list: ${errorMessage(err)}`);
91
+ return { ok: false, reason: 'unanswered' };
88
92
  }
89
93
  };
90
94
  const run = async (packageName, version) => {
@@ -118,7 +122,9 @@ function createAgentHubAddonControls(deps) {
118
122
  if (!hub.ok) {
119
123
  return {
120
124
  started: false,
121
- message: `The hub could not be asked which version this node should run: ${hub.error}`,
125
+ message: hub.reason === 'unpaired'
126
+ ? 'This agent is not paired with a hub, so there is no version to bring it to.'
127
+ : 'The hub did not answer, so the version this node should run is unknown.',
122
128
  };
123
129
  }
124
130
  const hubVersion = hub.versions.get(packageName);
@@ -74,7 +74,7 @@ async function withHubVersions(report, hub) {
74
74
  const progress = hub?.progress();
75
75
  return {
76
76
  ...report,
77
- hubVersionsError: versions === null || versions.ok ? null : versions.error,
77
+ hubVersions: versions === null || versions.ok ? 'known' : versions.reason,
78
78
  entries: report.entries.map((entry) => {
79
79
  const hubVersion = versions?.ok === true ? (versions.versions.get(entry.packageName) ?? null) : null;
80
80
  return {
@@ -879,6 +879,7 @@ async function startAgent(configPath) {
879
879
  addons: {
880
880
  hub: (0, agent_hub_addon_update_js_1.createAgentHubAddonControls)({
881
881
  nodeId: config.nodeId,
882
+ isPaired: () => (0, agent_pairing_store_js_1.isAgentPaired)(config.configPath),
882
883
  // The mesh channel the agent already holds; the hub names this caller
883
884
  // `node:<id>` and answers per its remote privilege grant.
884
885
  callHub: (action, params, timeoutMs) => broker.call(action, params, { nodeID: 'hub', timeout: timeoutMs }),
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveAgentHubAddresses = resolveAgentHubAddresses;
3
4
  exports.createAgentPairingRouter = createAgentPairingRouter;
4
5
  const node_crypto_1 = require("node:crypto");
5
6
  /**
@@ -68,6 +69,21 @@ const UnpairResultSchema = zod_1.z.object({
68
69
  disconnected: zod_1.z.number().int(),
69
70
  next: zod_1.z.string(),
70
71
  });
72
+ /**
73
+ * What an agent is offered: the hub's INTERNAL role for the API URL and the mesh
74
+ * address alike. The public endpoint is for people outside the LAN; an agent on the
75
+ * LAN is shown, and keeps, the address it actually dials.
76
+ */
77
+ function resolveAgentHubAddresses(book) {
78
+ const internal = book.roles.internal.resolved;
79
+ if (internal === null) {
80
+ throw new Error('The hub has no LAN address to give an agent yet - set one on System -> Network');
81
+ }
82
+ return {
83
+ hubUrl: internal,
84
+ hubMeshAddress: `${new URL(internal).hostname}:${system_1.DEFAULT_HUB_TLS_TCP_PORT}`,
85
+ };
86
+ }
71
87
  function createAgentPairingRouter(deps) {
72
88
  const transport = deps.transport ?? (0, agent_pairing_http_js_1.createHttpAgentPairingTransport)();
73
89
  const meshCa = deps.meshCa ??
@@ -89,20 +105,9 @@ function createAgentPairingRouter(deps) {
89
105
  }
90
106
  return provider;
91
107
  };
92
- /** The address an agent dials (its `internal` role) and the URL the hub serves its API on. */
108
+ /** The address an agent dials and is shown: the hub's `internal` role. */
93
109
  const hubAddresses = deps.hubAddresses ??
94
- (async () => {
95
- const book = await localNetwork().getAddresses({ scheme: 'https' });
96
- const internal = book.roles.internal.resolved;
97
- const hubUrl = book.roles.publicEndpoint.resolved ?? internal;
98
- if (internal === null || hubUrl === null) {
99
- throw new Error('The hub has no LAN address to give an agent yet - set one on System -> Network');
100
- }
101
- return {
102
- hubUrl,
103
- hubMeshAddress: `${new URL(internal).hostname}:${system_1.DEFAULT_HUB_TLS_TCP_PORT}`,
104
- };
105
- });
110
+ (async () => resolveAgentHubAddresses(await localNetwork().getAddresses({ scheme: 'https' })));
106
111
  const pairing = new system_1.HubPairing({
107
112
  transport,
108
113
  ca: meshCa,
@@ -53,22 +53,10 @@
53
53
  Object.defineProperty(exports, "__esModule", { value: true });
54
54
  exports.BackfillAttemptTracker = exports.MAX_BACKFILL_ATTEMPTS = void 0;
55
55
  exports.triggerMayDeliver = triggerMayDeliver;
56
+ exports.freshNodeSkipReason = freshNodeSkipReason;
56
57
  exports.computeBackfillPlan = computeBackfillPlan;
57
58
  exports.confirmBackfillPlan = confirmBackfillPlan;
58
59
  exports.executeBackfill = executeBackfill;
59
- /**
60
- * Why a reconcile is running. Named on every back-fill decision, and an
61
- * allowlist: a future caller has to CHOOSE a trigger, and a trigger that must
62
- * not deliver bytes is refused here rather than in a comment.
63
- *
64
- * `node-registration` covers both a first registration and a rejoin — the
65
- * distinction is not the discriminator it looks like. The case the back-fill
66
- * exists for (a node that lost `<dataDir>/addons` and came back with 4 of 14
67
- * packages) presents as a rejoin, so excluding rejoins would delete the only
68
- * legitimate purpose. The transient-roster-gap case is excluded by the delivery
69
- * ledger and the confirmation read instead, which discriminate on FACTS rather
70
- * than on which callback fired.
71
- */
72
60
  const system_1 = require("@camstack/system");
73
61
  /** Triggers permitted to install packages onto a node. */
74
62
  const DELIVERING_TRIGGERS = new Set([
@@ -80,11 +68,26 @@ const DELIVERING_TRIGGERS = new Set([
80
68
  function triggerMayDeliver(trigger) {
81
69
  return DELIVERING_TRIGGERS.has(trigger);
82
70
  }
71
+ /**
72
+ * Why a hub package may not go to a fresh node, or `null` when it may.
73
+ * A package with a hub-only addon is refused whole: the stale sweep would
74
+ * undeploy that addon and with it the shared bundle directory.
75
+ */
76
+ function freshNodeSkipReason(placements) {
77
+ if (placements.includes('hub-only'))
78
+ return 'hub-only placement';
79
+ if (!placements.includes('any-node'))
80
+ return 'placement does not allow any node';
81
+ return null;
82
+ }
83
83
  /**
84
84
  * Decide what a node is missing relative to its own last-declared roster.
85
85
  * Pure — no I/O, no clock, no randomness.
86
86
  */
87
87
  function computeBackfillPlan(input) {
88
+ if (input.freshNode !== undefined && input.recorded.length === 0) {
89
+ return computeFreshNodePlan(input, input.freshNode);
90
+ }
88
91
  const liveByName = new Map(input.live.map((p) => [p.name, p.version]));
89
92
  const targets = [];
90
93
  const retired = [];
@@ -113,7 +116,39 @@ function computeBackfillPlan(input) {
113
116
  }
114
117
  targets.push({ name: entry.name, version: hubVersion, versionSource: 'hub-installed' });
115
118
  }
116
- return { targets, retired, notOnHub, liveCount: liveByName.size };
119
+ return { targets, retired, notOnHub, liveCount: liveByName.size, skipped: [] };
120
+ }
121
+ function computeFreshNodePlan(input, fresh) {
122
+ const liveByName = new Map(input.live.map((p) => [p.name, p.version]));
123
+ const targets = [];
124
+ const retired = [];
125
+ const skipped = [];
126
+ for (const [name, placements] of fresh.hubPackagePlacements) {
127
+ if (input.bootstrap.has(name))
128
+ continue;
129
+ if (input.undeployed.has(name))
130
+ continue;
131
+ const reason = freshNodeSkipReason(placements);
132
+ if (reason !== null) {
133
+ skipped.push({ name, reason });
134
+ continue;
135
+ }
136
+ const hubVersion = input.hubVersions.get(name);
137
+ if (hubVersion === undefined) {
138
+ skipped.push({ name, reason: 'hub version unknown' });
139
+ continue;
140
+ }
141
+ const present = liveByName.has(name);
142
+ const decision = (0, system_1.decideAddonVersionMove)(present ? (liveByName.get(name) ?? null) : null, hubVersion, system_1.ADDON_VERSION_REASON_HUB);
143
+ if (!decision.move)
144
+ continue;
145
+ if (input.retired.has(name)) {
146
+ retired.push(name);
147
+ continue;
148
+ }
149
+ targets.push({ name, version: hubVersion, versionSource: 'hub-installed' });
150
+ }
151
+ return { targets, retired, notOnHub: [], liveCount: liveByName.size, skipped };
117
152
  }
118
153
  function confirmBackfillPlan(plan, secondRead) {
119
154
  const seen = new Map(secondRead.map((p) => [p.name, p.version]));
@@ -675,6 +675,7 @@ class AgentRegistryService {
675
675
  const hubPlacements = new Map();
676
676
  const hubPackages = new Set();
677
677
  const hubVersions = new Map();
678
+ const hubPackagePlacements = new Map();
678
679
  for (const row of this.addonRegistry.listAddons()) {
679
680
  const declId = row.manifest.id;
680
681
  if (typeof declId !== 'string')
@@ -684,6 +685,9 @@ class AgentRegistryService {
684
685
  const packageName = row.manifest.packageName;
685
686
  if (typeof packageName === 'string' && packageName.length > 0) {
686
687
  hubPackages.add(packageName);
688
+ const siblings = hubPackagePlacements.get(packageName) ?? [];
689
+ siblings.push((0, types_1.resolveAddonPlacement)(decl));
690
+ hubPackagePlacements.set(packageName, siblings);
687
691
  const packageVersion = row.manifest.packageVersion;
688
692
  if (typeof packageVersion === 'string' && packageVersion.length > 0) {
689
693
  hubVersions.set(packageName, packageVersion);
@@ -754,7 +758,7 @@ class AgentRegistryService {
754
758
  console.error(`[agent-registry] Reconcile ${agentId}: failed to undeploy "${addon.id}":`, err instanceof Error ? err.message : String(err));
755
759
  }
756
760
  }
757
- await this.backfillAgentAddons(agentId, agentAddons, undeployedPackages, hubVersions, trigger);
761
+ await this.backfillAgentAddons(agentId, agentAddons, undeployedPackages, hubVersions, hubPackagePlacements, trigger);
758
762
  }
759
763
  catch (err) {
760
764
  console.error(`[agent-registry] Reconcile failed for agent ${agentId}:`, err instanceof Error ? err.message : String(err));
@@ -775,10 +779,13 @@ class AgentRegistryService {
775
779
  * Every leg is idempotent: run twice with no change in between and step 3
776
780
  * finds nothing to do.
777
781
  */
778
- async backfillAgentAddons(agentId, agentAddons, undeployedPackages, hubVersions, trigger) {
782
+ async backfillAgentAddons(agentId, agentAddons, undeployedPackages, hubVersions, hubPackagePlacements, trigger) {
779
783
  const store = this.historyStore;
780
784
  if (!store)
781
785
  return;
786
+ // Read BEFORE recording: a fresh node is one that never declared a roster
787
+ // (D962), and the record below would make it look like it had one.
788
+ const priorRoster = await store.getPackages(agentId);
782
789
  const live = rosterFromAddons(agentAddons);
783
790
  await store.recordPackages(agentId, live);
784
791
  if (undeployedPackages.size > 0) {
@@ -795,7 +802,20 @@ class AgentRegistryService {
795
802
  undeployed: undeployedPackages,
796
803
  retired: this.backfillTracker.retiredFor(agentId),
797
804
  hubVersions,
805
+ freshNode: priorRoster.length === 0 ? { hubPackagePlacements } : undefined,
798
806
  });
807
+ if (priorRoster.length === 0) {
808
+ this.logger?.info('back-fill: fresh node (no prior declared roster), plan from the hub any-node set', {
809
+ tags: { nodeId: agentId },
810
+ meta: { planned: planned.targets.map((t) => `${t.name}@${t.version}`) },
811
+ });
812
+ for (const skip of planned.skipped) {
813
+ this.logger?.info('back-fill: fresh node not given a hub package', {
814
+ tags: { nodeId: agentId },
815
+ meta: { package: skip.name, reason: skip.reason },
816
+ });
817
+ }
818
+ }
799
819
  // Never a delivery, and said so: an addon the hub no longer installs stays
800
820
  // off the node even though the node once declared it (the stale sweep above
801
821
  // removes a copy that is still there).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.681",
3
+ "version": "1.2.682",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -50,7 +50,7 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@camstack/addon-admin-ui": "1.2.510",
53
- "@camstack/addon-agent-ui": "1.2.288",
53
+ "@camstack/addon-agent-ui": "1.2.289",
54
54
  "@camstack/addon-auth": "1.2.170",
55
55
  "@camstack/addon-benchmark": "1.2.288",
56
56
  "@camstack/addon-decoder-nodeav": "1.2.144",
@@ -60,7 +60,7 @@
60
60
  "@camstack/addon-post-analysis": "1.2.650",
61
61
  "@camstack/develop": "0.1.38",
62
62
  "@camstack/sdk": "1.2.154",
63
- "@camstack/system": "1.2.512",
63
+ "@camstack/system": "1.2.513",
64
64
  "@camstack/types": "1.2.439",
65
65
  "@camstack/ui-library": "1.2.346",
66
66
  "@fastify/compress": "^9.0.0",