@camstack/server 1.2.32 → 1.2.34

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.
@@ -17,7 +17,6 @@ const logs_router_js_1 = require("../core/logs.router.js");
17
17
  const notifications_router_js_1 = require("../core/notifications.router.js");
18
18
  const repl_router_js_1 = require("../core/repl.router.js");
19
19
  const server_management_provider_js_1 = require("../core/server-management.provider.js");
20
- const settings_backend_router_js_1 = require("../core/settings-backend.router.js");
21
20
  const stream_probe_router_js_1 = require("../core/stream-probe.router.js");
22
21
  const system_events_router_js_1 = require("../core/system-events.router.js");
23
22
  const cap_router_runtime_js_1 = require("./cap-router-runtime.js");
@@ -260,7 +259,6 @@ function buildCapabilityRouters(services) {
260
259
  // NOT the three-level settings gateway — that's the
261
260
  // `addonSettings` cap router (mounted dynamically above).
262
261
  addonSettingsRaw: (0, addon_settings_router_js_1.createAddonSettingsRouter)(services.configService),
263
- settingsBackend: (0, settings_backend_router_js_1.createSettingsBackendRouter)(() => services.addonRegistry.getSettingsBackend()),
264
262
  eventBusProxy: (0, event_bus_proxy_router_js_1.createEventBusProxyRouter)(services.eventBus),
265
263
  repl: (0, repl_router_js_1.createReplRouter)(services.replEngine),
266
264
  systemEvents: (0, system_events_router_js_1.createSystemEventsRouter)(services.eventBus),
@@ -42,6 +42,7 @@ const addon_row_manifest_1 = require("./addon-row-manifest");
42
42
  const runner_convergence_1 = require("./runner-convergence");
43
43
  const package_dir_utils_1 = require("./package-dir-utils");
44
44
  const types_1 = require("@camstack/types");
45
+ const prune_misplaced_addons_js_1 = require("./prune-misplaced-addons.js");
45
46
  const system_1 = require("@camstack/system");
46
47
  const system_2 = require("@camstack/system");
47
48
  const client_1 = require("@trpc/client");
@@ -481,6 +482,20 @@ class AddonRegistryService {
481
482
  this.healthMonitor.recordFailure(fail.packageName, fail.error, fail.addonId);
482
483
  }
483
484
  const loadedAddons = this.addonLoader.listAddons();
485
+ // Self-heal a leftover from before the installer honoured placement:
486
+ // `@camstack/addon-agent-ui` sat in /data/addons on the live hub dated
487
+ // 2026-07-22, skipped by the loop below ever since, and therefore shown as
488
+ // FAILED forever on a node where it cannot run. Fixing the installer was
489
+ // necessary and not sufficient — nothing removed what was already there,
490
+ // and a permanent red entry teaches people to ignore red entries.
491
+ (0, prune_misplaced_addons_js_1.prunePlacementLeftovers)((0, prune_misplaced_addons_js_1.decidePrune)(loadedAddons.map((a) => ({
492
+ packageName: a.packageName,
493
+ dir: path.join(addonsDir, a.packageName),
494
+ placement: (0, types_1.resolveAddonPlacement)(a.declaration),
495
+ })), 'hub'), path.join(addonsDir, '@camstack'), {
496
+ info: (msg, meta) => this.logger.info(msg, { meta }),
497
+ warn: (msg, meta) => this.logger.warn(msg, { meta }),
498
+ });
484
499
  for (const registered of loadedAddons) {
485
500
  // Skip agent-only addons — they never run on the hub, only on remote
486
501
  // agents that opt in via `execution.placement: 'agent-only'`.
@@ -0,0 +1,138 @@
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.decidePrune = decidePrune;
37
+ exports.isPrunableDir = isPrunableDir;
38
+ exports.prunePlacementLeftovers = prunePlacementLeftovers;
39
+ /**
40
+ * prune-misplaced-addons — remove an installed addon that can never run here.
41
+ *
42
+ * ## The state this exists to clear
43
+ *
44
+ * `@camstack/addon-agent-ui` declares `execution.placement: 'agent-only'`, and
45
+ * both hub-side load paths honour that: `addon-registry.service.ts` skips it at
46
+ * boot and after an install. `selectHubBootstrapPackages` also subtracts it, so
47
+ * a hub does not install it any more.
48
+ *
49
+ * None of that removes the copy an OLDER build already put on disk. On the live
50
+ * hub `/data/addons/@camstack/addon-agent-ui` is dated 2026-07-22 — eleven days
51
+ * before this was looked at. The loader skips it, so it never initialises, so
52
+ * the fleet UI shows it as **failed**, forever, on a node where it is not
53
+ * supposed to exist. An operator cannot tell that apart from an addon that is
54
+ * genuinely broken, which is the real cost: a permanent red entry teaches
55
+ * people to ignore red entries.
56
+ *
57
+ * Fixing the installer was necessary and not sufficient — the previous fix
58
+ * assumed no leftovers. This closes it.
59
+ *
60
+ * ## Why deleting is the right move
61
+ *
62
+ * An `agent-only` addon on a hub has no dormant use: it cannot be enabled, it
63
+ * cannot be reached, and keeping it costs a false failure. The directory is
64
+ * reinstallable from npm at any time, so removal is not destructive in the way
65
+ * deleting DATA would be. Anything outside `<dataDir>/addons/@camstack/` is
66
+ * refused outright.
67
+ */
68
+ const fs = __importStar(require("node:fs"));
69
+ const path = __importStar(require("node:path"));
70
+ /**
71
+ * Decide what to prune for a node in `role`. Pure — the decision is the part
72
+ * worth testing, and it must never depend on a filesystem to be checked.
73
+ *
74
+ * Only the unambiguous direction is pruned: an `agent-only` addon on a hub. The
75
+ * mirror case (`hub-only` on an agent) is deliberately NOT handled here — an
76
+ * agent seeds its addon set from the hub's closure and a `hub-only` entry there
77
+ * is expected to be inert, so pruning it would fight the seeding rather than
78
+ * fix anything.
79
+ */
80
+ function decidePrune(installed, role) {
81
+ if (role !== 'hub')
82
+ return [];
83
+ return installed
84
+ .filter((a) => a.placement === 'agent-only')
85
+ .map((a) => ({
86
+ packageName: a.packageName,
87
+ dir: a.dir,
88
+ reason: 'agent-only addon installed on the hub — it can never load here',
89
+ }));
90
+ }
91
+ /** Guard: only ever remove something under `<dataDir>/addons/@camstack/`. */
92
+ function isPrunableDir(dir, addonsRoot) {
93
+ const rel = path.relative(addonsRoot, dir);
94
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel))
95
+ return false;
96
+ // Exactly one level below the scope directory — never a nested path.
97
+ return rel.split(path.sep).filter(Boolean).length === 1;
98
+ }
99
+ /**
100
+ * Apply the decisions. Returns what was actually removed.
101
+ *
102
+ * A failure to remove one entry never aborts the rest and never throws into
103
+ * boot: the worst case is the pre-existing state — a stale entry showing as
104
+ * failed — which is exactly what we are trying to improve, not a reason to
105
+ * refuse to start.
106
+ */
107
+ function prunePlacementLeftovers(decisions, addonsRoot, logger, rm = (dir) => fs.rmSync(dir, { recursive: true, force: true })) {
108
+ const removed = [];
109
+ for (const d of decisions) {
110
+ if (!isPrunableDir(d.dir, addonsRoot)) {
111
+ logger.warn('refusing to prune a path outside the addon scope', {
112
+ packageName: d.packageName,
113
+ dir: d.dir,
114
+ addonsRoot,
115
+ });
116
+ continue;
117
+ }
118
+ try {
119
+ rm(d.dir);
120
+ removed.push(d.packageName);
121
+ // Say it out loud: a directory disappearing at boot must never be
122
+ // something the operator discovers by noticing it is gone.
123
+ logger.info('pruned a misplaced addon', {
124
+ packageName: d.packageName,
125
+ dir: d.dir,
126
+ reason: d.reason,
127
+ });
128
+ }
129
+ catch (err) {
130
+ logger.warn('could not prune a misplaced addon', {
131
+ packageName: d.packageName,
132
+ dir: d.dir,
133
+ error: err instanceof Error ? err.message : String(err),
134
+ });
135
+ }
136
+ }
137
+ return removed;
138
+ }
package/dist/main.js CHANGED
@@ -183,8 +183,8 @@ async function bootstrap() {
183
183
  // the starter: the starter runs from the image's /opt closure, which only
184
184
  // changes with a new image, while this entry is what `applyServerUpdate`
185
185
  // replaces — so the telemetry reaches a running hub the same day it is
186
- // written. See packages/node-root/src/heap-watch.ts for why it exists (four
187
- // silent OOMs in ~15h, one nine seconds after a viewer connected).
186
+ // written. See packages/system/src/kernel/heap-watch.ts for why it exists
187
+ // (four silent OOMs in ~15h, one nine seconds after a viewer connected).
188
188
  (0, system_2.startHeapWatch)();
189
189
  // Clean up orphaned processes from previous crashes before starting
190
190
  cleanupOrphanProcesses();
@@ -86,6 +86,7 @@ var require_dist = __commonJS({
86
86
  SERVER_ROOT_DIRNAME: /* @__PURE__ */ __name(() => SERVER_ROOT_DIRNAME, "SERVER_ROOT_DIRNAME"),
87
87
  SERVER_ROOT_STATE_FILE: /* @__PURE__ */ __name(() => SERVER_ROOT_STATE_FILE, "SERVER_ROOT_STATE_FILE"),
88
88
  applyPendingRootSwap: /* @__PURE__ */ __name(() => applyPendingRootSwap, "applyPendingRootSwap"),
89
+ assessImageContract: /* @__PURE__ */ __name(() => assessImageContract, "assessImageContract"),
89
90
  clearPendingRootSwap: /* @__PURE__ */ __name(() => clearPendingRootSwap, "clearPendingRootSwap"),
90
91
  clearRestartIntentMarker: /* @__PURE__ */ __name(() => clearRestartIntentMarker, "clearRestartIntentMarker"),
91
92
  compareSemver: /* @__PURE__ */ __name(() => compareSemver, "compareSemver"),
@@ -712,6 +713,82 @@ var require_dist = __commonJS({
712
713
  };
713
714
  }
714
715
  __name(planBoot, "planBoot");
716
+ function releaseSeries(version) {
717
+ const [major, minor] = version.split(".").map((seg) => {
718
+ const n = Number.parseInt(seg, 10);
719
+ return Number.isNaN(n) ? 0 : n;
720
+ });
721
+ return [
722
+ major ?? 0,
723
+ minor ?? 0
724
+ ];
725
+ }
726
+ __name(releaseSeries, "releaseSeries");
727
+ function sameSeries(a, b) {
728
+ const [aMajor, aMinor] = releaseSeries(a);
729
+ const [bMajor, bMinor] = releaseSeries(b);
730
+ return aMajor === bMajor && aMinor === bMinor;
731
+ }
732
+ __name(sameSeries, "sameSeries");
733
+ function behindMessage(seed, contract, series) {
734
+ const base = `This node is running an image whose baked seed is @camstack/server@${seed}; the deployment contract currently delivers ${contract}. applyServerUpdate keeps the code current but NEVER updates the image \u2014 the starter, the entrypoint and the boot fallback are still the old copies.`;
735
+ if (!series) {
736
+ return `${base} Recreate the container from the contract image to converge.`;
737
+ }
738
+ return `${base} The seed is a full release series behind \u2014 the shape of a pinned tag or an image repository that no longer receives builds, where a docker pull fixes nothing. Verify the container's image reference against the deployment templates.`;
739
+ }
740
+ __name(behindMessage, "behindMessage");
741
+ function assessImageContract(inputs) {
742
+ const { seedVersion, latestVersion, runningVersion } = inputs;
743
+ if (seedVersion === null) {
744
+ return {
745
+ state: "unknown",
746
+ seedVersion: null,
747
+ contractVersion: latestVersion,
748
+ contractSource: latestVersion !== null ? "registry" : null,
749
+ message: "No baked seed closure detected (dev workspace) \u2014 no image contract to compare."
750
+ };
751
+ }
752
+ const contractVersion = latestVersion ?? (runningVersion !== null && compareSemver(seedVersion, runningVersion) < 0 ? runningVersion : null);
753
+ if (contractVersion === null) {
754
+ return {
755
+ state: "unknown",
756
+ seedVersion,
757
+ contractVersion: null,
758
+ contractSource: null,
759
+ message: `This node is running an image whose baked seed is @camstack/server@${seedVersion}; no registry check has run yet, so the release the contract image delivers is unknown. Run a server-update check to verify the image against the contract.`
760
+ };
761
+ }
762
+ const contractSource = latestVersion !== null ? "registry" : "running";
763
+ const cmp = compareSemver(seedVersion, contractVersion);
764
+ if (cmp === 0) {
765
+ return {
766
+ state: "in-sync",
767
+ seedVersion,
768
+ contractVersion,
769
+ contractSource,
770
+ message: `Image seed @camstack/server@${seedVersion} matches the current release \u2014 this node is running the image the deployment contract names.`
771
+ };
772
+ }
773
+ if (cmp > 0) {
774
+ return {
775
+ state: "ahead",
776
+ seedVersion,
777
+ contractVersion,
778
+ contractSource,
779
+ message: `Image seed @camstack/server@${seedVersion} is NEWER than the best-known release ${contractVersion} \u2014 the registry check is stale or the registry was unreachable. Re-run a server-update check.`
780
+ };
781
+ }
782
+ const series = !sameSeries(seedVersion, contractVersion);
783
+ return {
784
+ state: series ? "behind-series" : "behind-patch",
785
+ seedVersion,
786
+ contractVersion,
787
+ contractSource,
788
+ message: behindMessage(seedVersion, contractVersion, series)
789
+ };
790
+ }
791
+ __name(assessImageContract, "assessImageContract");
715
792
  var fs4 = __toESM2(require("fs"));
716
793
  var path4 = __toESM2(require("path"));
717
794
  function detectWorkspaceRoot(fromDir) {
@@ -955,7 +1032,16 @@ var require_dist = __commonJS({
955
1032
  pendingVersion: pending?.version ?? null,
956
1033
  rolledBack: null,
957
1034
  stateFileCorrupt: false,
958
- lastCheckedAtMs: this.checkCache?.checkedAtMs ?? null
1035
+ lastCheckedAtMs: this.checkCache?.checkedAtMs ?? null,
1036
+ // Seed-vs-contract verdict: `runningVersion` can be perfectly current
1037
+ // (applyServerUpdate) while the IMAGE is weeks old from a dead repo —
1038
+ // 2026-08-02, hub on `camstack-server:intel-1.1.74` with every other
1039
+ // field green. See `image-contract.ts` for the full account.
1040
+ imageContract: assessImageContract({
1041
+ seedVersion: this.seedVersion(),
1042
+ latestVersion,
1043
+ runningVersion
1044
+ })
959
1045
  };
960
1046
  }
961
1047
  // ── Check ─────────────────────────────────────────────────────────────
@@ -989,6 +1075,20 @@ var require_dist = __commonJS({
989
1075
  checkedAtMs: this.now(),
990
1076
  error: null
991
1077
  };
1078
+ const imageContract = assessImageContract({
1079
+ seedVersion: this.seedVersion(),
1080
+ latestVersion,
1081
+ runningVersion
1082
+ });
1083
+ if (imageContract.state === "behind-patch" || imageContract.state === "behind-series") {
1084
+ this.logger.warn(`image seed diverges from the release contract: ${imageContract.message}`, {
1085
+ meta: {
1086
+ state: imageContract.state,
1087
+ seedVersion: imageContract.seedVersion,
1088
+ contractVersion: imageContract.contractVersion
1089
+ }
1090
+ });
1091
+ }
992
1092
  const updateAvailable = latestVersion !== null && runningVersion !== null && compareSemver(latestVersion, runningVersion) > 0;
993
1093
  return {
994
1094
  packageName: this.spec.packageName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.32",
3
+ "version": "1.2.34",
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": "*",
37
- "@camstack/addon-agent-ui": "*",
38
- "@camstack/addon-auth": "*",
39
- "@camstack/addon-decoder-nodeav": "*",
40
- "@camstack/addon-notifiers": "*",
41
- "@camstack/addon-pipeline": "*",
42
- "@camstack/addon-pipeline-orchestrator": "*",
43
- "@camstack/addon-post-analysis": "*",
44
- "@camstack/sdk": "*",
45
- "@camstack/shm-ring": "*",
46
- "@camstack/system": "*",
47
- "@camstack/types": "*",
48
- "@camstack/ui-library": "*",
36
+ "@camstack/addon-admin-ui": "1.2.22",
37
+ "@camstack/addon-agent-ui": "1.2.10",
38
+ "@camstack/addon-auth": "1.2.10",
39
+ "@camstack/addon-decoder-nodeav": "1.2.9",
40
+ "@camstack/addon-notifiers": "1.2.11",
41
+ "@camstack/addon-pipeline": "1.2.39",
42
+ "@camstack/addon-pipeline-orchestrator": "1.2.19",
43
+ "@camstack/addon-post-analysis": "1.2.23",
44
+ "@camstack/sdk": "1.2.9",
45
+ "@camstack/shm-ring": "1.1.9",
46
+ "@camstack/system": "1.2.30",
47
+ "@camstack/types": "1.2.22",
48
+ "@camstack/ui-library": "1.2.18",
49
49
  "@fastify/compress": "^9.0.0",
50
50
  "@fastify/cookie": "^11.0.2",
51
51
  "@fastify/cors": "^11.2.0",
@@ -1,99 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.createAddonsCustomProcedures = createAddonsCustomProcedures;
4
- /**
5
- * `api.addons.custom` — generic dispatcher for addon-defined custom actions.
6
- *
7
- * Task 7.2 of the device-proxy redesign. Addons declare a catalog of
8
- * custom actions at boot via `AddonInitResult.customActions` + a single
9
- * `handleCustomAction(action, input)` handler. The catalog is registered
10
- * with a per-process `CustomActionRegistry` (Task 7.1). This endpoint is
11
- * the single tRPC entry point that resolves an `(addonId, action)` pair,
12
- * validates input + output against the action's Zod schemas, enforces the
13
- * action's declared auth level, and dispatches to the addon handler.
14
- *
15
- * The factory returns a record of procedures (not a router) so the caller
16
- * can spread it into the existing `addons` namespace:
17
- *
18
- * trpcRouter({
19
- * ...existingAddonsProcedures,
20
- * ...createAddonsCustomProcedures({ getCustomActionRegistry: ... }),
21
- * })
22
- *
23
- * This avoids `mergeRouters` (which requires sharing the `t` instance
24
- * across modules) while still mounting the procedure at `api.addons.custom`.
25
- */
26
- const zod_1 = require("zod");
27
- const server_1 = require("@trpc/server");
28
- const trpc_middleware_js_1 = require("./trpc/trpc.middleware.js");
29
- /**
30
- * Build the procedure record for the `custom` endpoint.
31
- *
32
- * The OUTER procedure is `protectedProcedure` — every caller must be
33
- * authenticated. The INNER per-action auth declared in `spec.auth` is
34
- * enforced manually by `ensureAuth` because the auth level is not known
35
- * until after the registry lookup.
36
- */
37
- function createAddonsCustomProcedures(deps) {
38
- return {
39
- custom: trpc_middleware_js_1.protectedProcedure
40
- .input(zod_1.z.object({
41
- addonId: zod_1.z.string().min(1),
42
- action: zod_1.z.string().min(1),
43
- input: zod_1.z.unknown(),
44
- }))
45
- .output(zod_1.z.unknown())
46
- .mutation(async ({ input, ctx }) => {
47
- const registry = deps.getCustomActionRegistry();
48
- const entry = registry.resolve(input.addonId, input.action);
49
- if (!entry) {
50
- throw new server_1.TRPCError({
51
- code: 'NOT_FOUND',
52
- message: `addon '${input.addonId}' has no custom action '${input.action}'`,
53
- });
54
- }
55
- // Per-action authorization. The outer procedure already requires
56
- // authentication; here we additionally enforce the declared role
57
- // when it's stricter than 'protected'.
58
- ensureAuth(ctx, entry.spec.auth);
59
- // Validate input against the action's declared Zod schema.
60
- const parsedInput = entry.spec.input.parse(input.input);
61
- // Dispatch through the addon handler, forwarding the authenticated
62
- // caller when the action declares `caller: 'required'`. The caller is
63
- // derived server-side from the request principal (never trusted from
64
- // input); `ctx.user` is guaranteed present because `ensureAuth` above
65
- // rejects unauthenticated callers for any non-public action, and the
66
- // outer `protectedProcedure` rejects them for public ones.
67
- const caller = entry.spec.caller === 'required' && ctx.user
68
- ? { userId: ctx.user.id, isAdmin: ctx.user.isAdmin }
69
- : undefined;
70
- const result = await entry.handler(parsedInput, caller);
71
- // Validate the addon's output. Crash-early on misbehaving addons.
72
- return entry.spec.output.parse(result);
73
- }),
74
- };
75
- }
76
- /**
77
- * Enforce the action's declared auth level.
78
- *
79
- * Mirrors the role checks performed by `protectedProcedure` and
80
- * `adminProcedure` in trpc.middleware.ts:
81
- * - public: no auth
82
- * - protected: any authenticated user
83
- * - admin: isAdmin only (scoped tokens bounce)
84
- */
85
- function ensureAuth(ctx, level) {
86
- if (level === 'public')
87
- return;
88
- if (!ctx.user) {
89
- throw new server_1.TRPCError({ code: 'UNAUTHORIZED' });
90
- }
91
- if (level === 'protected')
92
- return;
93
- if (level === 'admin') {
94
- if (!ctx.user.isAdmin) {
95
- throw new server_1.TRPCError({ code: 'FORBIDDEN', message: 'custom action requires admin' });
96
- }
97
- return;
98
- }
99
- }