@camstack/server 1.2.99 → 1.2.101
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/agent/main.js +6 -0
- package/dist/api/core/addon-settings.router.js +24 -0
- package/dist/api/core/system-events.router.js +11 -5
- package/dist/api/static/spa-static.js +27 -1
- package/dist/api/trpc/generated-cap-routers.js +45 -0
- package/dist/core/addon/addon-registry.service.js +77 -23
- package/dist/core/moleculer/moleculer.service.js +11 -0
- package/dist/first-boot-addon-plan.js +209 -0
- package/dist/launcher.js +97 -25
- package/dist/main.js +11 -3
- package/dist/package-inventory.js +114 -27
- package/package.json +14 -14
package/dist/agent/main.js
CHANGED
|
@@ -488,6 +488,12 @@ async function startAgent(configPath) {
|
|
|
488
488
|
args: input.args,
|
|
489
489
|
...(input.deviceId !== undefined ? { deviceId: input.deviceId } : {}),
|
|
490
490
|
...(input.nodeId !== undefined ? { nodeId: input.nodeId } : {}),
|
|
491
|
+
// The native-only hint must survive the forward: the agent has no
|
|
492
|
+
// native authority of its own (no CapRouteResolver, no cluster
|
|
493
|
+
// CapabilityRegistry), so the hub answers "native or nothing" on
|
|
494
|
+
// its behalf. Dropping it here would let the hub resolve the cap
|
|
495
|
+
// NAME instead — i.e. the wrapper the caller is trying to skip.
|
|
496
|
+
...(input.native === true ? { native: true } : {}),
|
|
491
497
|
}, { timeout: 60_000 }),
|
|
492
498
|
logger: { warn: (msg) => consoleLogger.warn(`[uds-fallback] ${msg}`) },
|
|
493
499
|
});
|
|
@@ -4,6 +4,30 @@ exports.createAddonSettingsRouter = createAddonSettingsRouter;
|
|
|
4
4
|
/**
|
|
5
5
|
* Addon settings router — raw DB proxy for the common settings API.
|
|
6
6
|
*
|
|
7
|
+
* ## ⚠️ This is NOT the store `ctx.settings.readAddonStore()` reads
|
|
8
|
+
*
|
|
9
|
+
* Two different physical stores wear the name "addon-settings", and mistaking
|
|
10
|
+
* one for the other costs a whole debugging session — it did on 2026-08-13,
|
|
11
|
+
* where a per-node inference override was written here, read back here
|
|
12
|
+
* (looking perfectly correct), and was invisible to the addon forever:
|
|
13
|
+
*
|
|
14
|
+
* | Surface | Physical shape | Read by |
|
|
15
|
+
* | --- | --- | --- |
|
|
16
|
+
* | THIS router (`ConfigService.getAddonConfig` → `getAllAddon`) | the `addon-settings` table, **one row per key**, `id = "<addonId>.<key>"`, `data = {addonId, key, value}` | `buildAddonConfig` → `ctx.addonConfig`, and `virtual-doorbell` |
|
|
17
|
+
* | `settings-store` cap (`ctx.settings.readAddonStore()`) | **ONE blob row**, namespace `<addonId>`, collection `addon-settings`, key `root` | `BaseAddon.resolveConfig` / every addon's global settings |
|
|
18
|
+
*
|
|
19
|
+
* Nothing bridges them. To write an addon's global setting — including a
|
|
20
|
+
* `perNode: true` key — go through the `addonSettings` CAP
|
|
21
|
+
* (`updateGlobalSettings`, which applies the schema's per-node scoping), or
|
|
22
|
+
* read it back with
|
|
23
|
+
* `settingsStore.get {namespace:<addonId>, collection:'addon-settings', key:'root'}`.
|
|
24
|
+
* `addonSettingsRaw.getGlobal` answering `{}` for an addon that plainly has
|
|
25
|
+
* settings is the tell.
|
|
26
|
+
*
|
|
27
|
+
* `setAllAddon` is also a **bulk replace**: `updateGlobal` read-modify-writes
|
|
28
|
+
* the whole per-key set, so a write here deletes every other row of that addon
|
|
29
|
+
* in this table.
|
|
30
|
+
*
|
|
7
31
|
* Exposes four protected procedures consumed by:
|
|
8
32
|
* 1. Forked addons (via the tRPC WSS client in `WorkerBootstrapService`)
|
|
9
33
|
* to read/write their 3-level settings chain from the worker process.
|
|
@@ -63,11 +63,17 @@ const GetRecentInputSchema = ScopeFieldsSchema.extend({
|
|
|
63
63
|
limit: zod_1.z.number().int().min(1).max(500).optional(),
|
|
64
64
|
});
|
|
65
65
|
/**
|
|
66
|
-
* `subscribe` keeps category as a single string
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
66
|
+
* `subscribe` keeps category as a single string — a WIRE choice now, not a bus
|
|
67
|
+
* limitation.
|
|
68
|
+
*
|
|
69
|
+
* It used to be a limitation, and a silent one: the bus keyed handlers by
|
|
70
|
+
* `extractCategoryPattern`, so an array filter routed only its FIRST category
|
|
71
|
+
* and dropped the rest with no error. `extractCategoryPatterns` +
|
|
72
|
+
* `registerSubscriber` (2026-08-12) register a subscriber under every category
|
|
73
|
+
* it names, so an array here would now work. The single string stays because
|
|
74
|
+
* one live SSE stream per category is what every consumer of this endpoint
|
|
75
|
+
* actually opens; a UI that wants several can open several or subscribe by
|
|
76
|
+
* wildcard and narrow client-side.
|
|
71
77
|
*/
|
|
72
78
|
const SubscribeInputSchema = ScopeFieldsSchema.extend({
|
|
73
79
|
category: zod_1.z.string().optional(),
|
|
@@ -7,10 +7,31 @@
|
|
|
7
7
|
* (`index.html`) always revalidate so a redeploy actually reaches clients.
|
|
8
8
|
*/
|
|
9
9
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.spaShellCacheControl = spaShellCacheControl;
|
|
10
11
|
exports.spaAssetCacheControl = spaAssetCacheControl;
|
|
11
12
|
exports.addonBundleCacheControl = addonBundleCacheControl;
|
|
12
13
|
exports.contentTypeForPath = contentTypeForPath;
|
|
13
14
|
exports.isRetiredPublicPath = isRetiredPublicPath;
|
|
15
|
+
/**
|
|
16
|
+
* Cache-Control for the SPA SHELL (`index.html`) — the HTML entry only, never a
|
|
17
|
+
* hashed asset.
|
|
18
|
+
*
|
|
19
|
+
* The shell is the one file that must never survive a deploy: it carries the
|
|
20
|
+
* `<script src="assets/index-<hash>.js">` pointers, so a stale copy loads a
|
|
21
|
+
* build whose hashed assets no longer exist — the operator sees the previous UI
|
|
22
|
+
* or a blank page and has to force-refresh. It is a few KB, and every in-app
|
|
23
|
+
* navigation is client-side, so re-fetching it costs nothing measurable.
|
|
24
|
+
*
|
|
25
|
+
* `no-store`, not `no-cache`: the shell is streamed with no ETag and no
|
|
26
|
+
* Last-Modified, so a revalidation could never answer 304 anyway — and
|
|
27
|
+
* `no-store` additionally keeps the response out of the heuristic and
|
|
28
|
+
* back/forward caches that made this visible. The addon-route fallback used to
|
|
29
|
+
* send the shell with NO header at all, which is where the stale copies came
|
|
30
|
+
* from.
|
|
31
|
+
*/
|
|
32
|
+
function spaShellCacheControl() {
|
|
33
|
+
return 'no-store';
|
|
34
|
+
}
|
|
14
35
|
/**
|
|
15
36
|
* Cache-Control value for a static SPA asset addressed by its dist-relative
|
|
16
37
|
* path. Policy (shared by admin-ui + viewer-ui):
|
|
@@ -18,7 +39,10 @@ exports.isRetiredPublicPath = isRetiredPublicPath;
|
|
|
18
39
|
* → `no-cache, must-revalidate` (PWA update propagation).
|
|
19
40
|
* - Content-hashed build assets under `assets/` (Vite) or `_expo/` (Expo
|
|
20
41
|
* web export) → `public, max-age=31536000, immutable`.
|
|
21
|
-
* -
|
|
42
|
+
* - The shell requested BY NAME (`/index.html`) reaches this function rather
|
|
43
|
+
* than the shell branch of the handler — it gets {@link spaShellCacheControl}
|
|
44
|
+
* so the same bytes never carry two different policies.
|
|
45
|
+
* - Everything else (favicon, non-hashed files) → `no-cache`.
|
|
22
46
|
*/
|
|
23
47
|
function spaAssetCacheControl(rel) {
|
|
24
48
|
const base = rel.split('/').pop() ?? rel;
|
|
@@ -32,6 +56,8 @@ function spaAssetCacheControl(rel) {
|
|
|
32
56
|
if (inAssetDir && /-[A-Za-z0-9_-]{8,}\./.test(base)) {
|
|
33
57
|
return 'public, max-age=31536000, immutable';
|
|
34
58
|
}
|
|
59
|
+
if (base === 'index.html')
|
|
60
|
+
return spaShellCacheControl();
|
|
35
61
|
return 'no-cache';
|
|
36
62
|
}
|
|
37
63
|
/**
|
|
@@ -7499,6 +7499,51 @@ function createCapRouter_recording(getProvider, createRemoteProxy) {
|
|
|
7499
7499
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
7500
7500
|
return p.cancelStorageMigrationMove(methodInput);
|
|
7501
7501
|
}),
|
|
7502
|
+
relocateFootage: trpc_middleware_js_1.adminProcedure
|
|
7503
|
+
.input(types_92.recordingCapability.methods.relocateFootage.input.loose())
|
|
7504
|
+
.output(types_92.recordingCapability.methods.relocateFootage.output)
|
|
7505
|
+
.mutation(async ({ input, ctx }) => {
|
|
7506
|
+
const { nodeId, ...methodInput } = input;
|
|
7507
|
+
const p = resolveProvider('recording', nodeId, () => getProvider(ctx), createRemoteProxy);
|
|
7508
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
7509
|
+
return p.relocateFootage(methodInput);
|
|
7510
|
+
}),
|
|
7511
|
+
listRelocateJobs: trpc_middleware_js_1.adminProcedure
|
|
7512
|
+
.input(types_92.recordingCapability.methods.listRelocateJobs.input.loose())
|
|
7513
|
+
.output(types_92.recordingCapability.methods.listRelocateJobs.output)
|
|
7514
|
+
.query(async ({ input, ctx }) => {
|
|
7515
|
+
const { nodeId, ...methodInput } = input;
|
|
7516
|
+
const p = resolveProvider('recording', nodeId, () => getProvider(ctx), createRemoteProxy);
|
|
7517
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
7518
|
+
return p.listRelocateJobs(methodInput);
|
|
7519
|
+
}),
|
|
7520
|
+
cancelRelocateJob: trpc_middleware_js_1.adminProcedure
|
|
7521
|
+
.input(types_92.recordingCapability.methods.cancelRelocateJob.input.loose())
|
|
7522
|
+
.output(types_92.recordingCapability.methods.cancelRelocateJob.output)
|
|
7523
|
+
.mutation(async ({ input, ctx }) => {
|
|
7524
|
+
const { nodeId, ...methodInput } = input;
|
|
7525
|
+
const p = resolveProvider('recording', nodeId, () => getProvider(ctx), createRemoteProxy);
|
|
7526
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
7527
|
+
return p.cancelRelocateJob(methodInput);
|
|
7528
|
+
}),
|
|
7529
|
+
planStorageRebalance: trpc_middleware_js_1.adminProcedure
|
|
7530
|
+
.input(types_92.recordingCapability.methods.planStorageRebalance.input.loose())
|
|
7531
|
+
.output(types_92.recordingCapability.methods.planStorageRebalance.output)
|
|
7532
|
+
.query(async ({ input, ctx }) => {
|
|
7533
|
+
const { nodeId, ...methodInput } = input;
|
|
7534
|
+
const p = resolveProvider('recording', nodeId, () => getProvider(ctx), createRemoteProxy);
|
|
7535
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
7536
|
+
return p.planStorageRebalance(methodInput);
|
|
7537
|
+
}),
|
|
7538
|
+
startStorageRebalance: trpc_middleware_js_1.adminProcedure
|
|
7539
|
+
.input(types_92.recordingCapability.methods.startStorageRebalance.input.loose())
|
|
7540
|
+
.output(types_92.recordingCapability.methods.startStorageRebalance.output)
|
|
7541
|
+
.mutation(async ({ input, ctx }) => {
|
|
7542
|
+
const { nodeId, ...methodInput } = input;
|
|
7543
|
+
const p = resolveProvider('recording', nodeId, () => getProvider(ctx), createRemoteProxy);
|
|
7544
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
7545
|
+
return p.startStorageRebalance(methodInput);
|
|
7546
|
+
}),
|
|
7502
7547
|
});
|
|
7503
7548
|
}
|
|
7504
7549
|
function createCapRouter_recordingExport(getProvider, createRemoteProxy) {
|
|
@@ -666,14 +666,15 @@ class AddonRegistryService {
|
|
|
666
666
|
// one were declared — the mechanism stays available as an explicit
|
|
667
667
|
// opt-in for future co-location needs.
|
|
668
668
|
//
|
|
669
|
-
//
|
|
670
|
-
// excluded from the plan (filtered inside `buildAddonGroupPlan`)
|
|
669
|
+
// `placement: 'agent-only'` addons and `@camstack/system` builtins are
|
|
670
|
+
// excluded from the plan (filtered inside `buildAddonGroupPlan`) — except
|
|
671
|
+
// a builtin declaring `execution.isolate`, which is spawned AFTER the
|
|
672
|
+
// in-process passes below (see `spawnRunnerPlan`'s two calls).
|
|
671
673
|
// A failed runner spawn is logged and the addon surfaces as
|
|
672
674
|
// `addon.error` — there is NO in-process-on-the-hub fallback (it
|
|
673
675
|
// would violate one-addon-one-process). Retry policy for a failed
|
|
674
676
|
// spawn is governed by the kernel circuit-breaker.
|
|
675
|
-
{
|
|
676
|
-
const plan = this.buildAddonGroupPlan(allIds);
|
|
677
|
+
const spawnRunnerPlan = async (plan) => {
|
|
677
678
|
for (const [runnerId, runnerAddons] of plan) {
|
|
678
679
|
try {
|
|
679
680
|
await this.initializeAddonGroup(runnerId, runnerAddons);
|
|
@@ -694,7 +695,24 @@ class AddonRegistryService {
|
|
|
694
695
|
});
|
|
695
696
|
}
|
|
696
697
|
}
|
|
697
|
-
}
|
|
698
|
+
};
|
|
699
|
+
// An ISOLATED builtin is a builtin: everything it stores lives behind
|
|
700
|
+
// `settings-store`, which is one of the in-process builtins booted below.
|
|
701
|
+
// Spawned here it would race them, and the failure would not look like a
|
|
702
|
+
// race — the snapshot wrapper mints its link-signing secret at init, so a
|
|
703
|
+
// store that answers "not ready" once yields either no signed-link plane
|
|
704
|
+
// for the life of the process or a NEW secret that invalidates every link
|
|
705
|
+
// a client is holding. Ordinary addons tolerate the same window because
|
|
706
|
+
// `readAddonStoreWithRetry` rides it out; an isolated builtin does not
|
|
707
|
+
// need to be lucky, because it can simply be spawned second.
|
|
708
|
+
const isolatedBuiltinIds = allIds.filter((id) => {
|
|
709
|
+
const entry = this.addonEntries.get(id);
|
|
710
|
+
return (entry?.packageName === '@camstack/system' &&
|
|
711
|
+
entry.declaration !== undefined &&
|
|
712
|
+
(0, types_1.isIsolatedBuiltin)(entry.declaration));
|
|
713
|
+
});
|
|
714
|
+
const isolatedBuiltins = new Set(isolatedBuiltinIds);
|
|
715
|
+
await spawnRunnerPlan(this.buildAddonGroupPlan(allIds.filter((id) => !isolatedBuiltins.has(id))));
|
|
698
716
|
// In-process boot for `@camstack/system` builtins. Core builtins stay
|
|
699
717
|
// resident on the hub process — they provide the storage / settings
|
|
700
718
|
// / logging infrastructure every forked runner depends on, reachable
|
|
@@ -707,8 +725,12 @@ class AddonRegistryService {
|
|
|
707
725
|
// A core builtin is identified by `packageName === "@camstack/system"`,
|
|
708
726
|
// NOT by the absence of an `execution` declaration — a builtin may
|
|
709
727
|
// still declare `execution` (e.g. `platform-probe`); the package
|
|
710
|
-
// boundary is the selection criterion.
|
|
711
|
-
|
|
728
|
+
// boundary is the selection criterion. The ONE exception is
|
|
729
|
+
// `execution.isolate`, which says "fork me": such a builtin must never
|
|
730
|
+
// boot in-process here, not even when its runner spawn failed. A spawn
|
|
731
|
+
// failure surfaces as `addon.error`; quietly running the addon on the hub
|
|
732
|
+
// instead would defeat the isolation on exactly the day it is needed.
|
|
733
|
+
const isCoreBuiltin = (id) => this.addonEntries.get(id)?.packageName === '@camstack/system' && !isolatedBuiltins.has(id);
|
|
712
734
|
// Pass 1 — infrastructure builtins. A failed REQUIRED infra cap
|
|
713
735
|
// aborts boot: nothing downstream can run without storage/settings.
|
|
714
736
|
for (const infra of system_1.INFRA_CAPABILITIES) {
|
|
@@ -797,6 +819,15 @@ class AddonRegistryService {
|
|
|
797
819
|
}
|
|
798
820
|
}
|
|
799
821
|
}
|
|
822
|
+
// Isolated builtins, now that storage / settings / logging are resident.
|
|
823
|
+
// Same authority (`buildAddonGroupPlan`), same failure semantics — only the
|
|
824
|
+
// ORDER differs, and the order is the point.
|
|
825
|
+
if (isolatedBuiltinIds.length > 0) {
|
|
826
|
+
this.logger.info('Spawning isolated system builtins', {
|
|
827
|
+
meta: { addonIds: isolatedBuiltinIds },
|
|
828
|
+
});
|
|
829
|
+
await spawnRunnerPlan(this.buildAddonGroupPlan(isolatedBuiltinIds));
|
|
830
|
+
}
|
|
800
831
|
const initializedIds = [...this.addonEntries.entries()]
|
|
801
832
|
.filter(([, e]) => e.initialized)
|
|
802
833
|
.map(([id]) => id);
|
|
@@ -1740,14 +1771,14 @@ class AddonRegistryService {
|
|
|
1740
1771
|
* in-process by this predicate, so its route-mount took the co-located
|
|
1741
1772
|
* path and received the async UDS cap proxy whose `getRoutes()` returns
|
|
1742
1773
|
* a Promise (→ `getRoutes(...).map is not a function`). Only
|
|
1743
|
-
* `@camstack/system` builtins boot in-process on the hub
|
|
1744
|
-
*
|
|
1774
|
+
* `@camstack/system` builtins boot in-process on the hub — except one that
|
|
1775
|
+
* declares `execution.isolate`, which forks like anything else. Both
|
|
1776
|
+
* questions are answered by `runsInOwnRunner`, so the two can no longer
|
|
1777
|
+
* disagree. The type predicate narrows both `addonDir` and `declaration`
|
|
1778
|
+
* for callers.
|
|
1745
1779
|
*/
|
|
1746
1780
|
isForkedAddonEntry(entry) {
|
|
1747
|
-
return !!(entry.declaration &&
|
|
1748
|
-
entry.addonDir &&
|
|
1749
|
-
entry.packageName !== '@camstack/system' &&
|
|
1750
|
-
(0, types_1.resolveAddonPlacement)(entry.declaration) !== 'agent-only');
|
|
1781
|
+
return !!(entry.declaration && entry.addonDir && this.runsInOwnRunner(entry));
|
|
1751
1782
|
}
|
|
1752
1783
|
/** Per-entry helper used by `listAddons()` to mark a row removable / not. */
|
|
1753
1784
|
isRequiredEntry(entry) {
|
|
@@ -2798,12 +2829,18 @@ class AddonRegistryService {
|
|
|
2798
2829
|
* passes a live JS reference across a process boundary any more.
|
|
2799
2830
|
*
|
|
2800
2831
|
* Addons with `placement: 'agent-only'` are dropped (they don't run on
|
|
2801
|
-
* the hub). `@camstack/system` builtins are dropped too —
|
|
2802
|
-
*
|
|
2803
|
-
*
|
|
2804
|
-
*
|
|
2805
|
-
*
|
|
2806
|
-
*
|
|
2832
|
+
* the hub). `@camstack/system` builtins are dropped too — UNLESS the
|
|
2833
|
+
* manifest entry declares `execution.isolate: true`.
|
|
2834
|
+
*
|
|
2835
|
+
* The reason builtins stay in-process is that the infrastructure ones
|
|
2836
|
+
* (storage, settings, logging, device-manager) must live in the process
|
|
2837
|
+
* every runner depends on. It is NOT, as the comment here used to claim,
|
|
2838
|
+
* that a subprocess would be unreachable: `docs/architecture/transport.md`
|
|
2839
|
+
* is explicit that there is no per-addon Moleculer broker and that a child
|
|
2840
|
+
* reaches the hub AND ITS SIBLINGS over the UDS channel —
|
|
2841
|
+
* `LocalChildRegistry` implements exactly that. The old claim was wrong and
|
|
2842
|
+
* nearly stopped the snapshot split; `docs/decisions/adr-0113.md` records
|
|
2843
|
+
* what replaced it.
|
|
2807
2844
|
*
|
|
2808
2845
|
* Read-only. Used by the bootstrap to plan spawns up-front and by
|
|
2809
2846
|
* diagnostics that want to render the current topology.
|
|
@@ -2814,10 +2851,7 @@ class AddonRegistryService {
|
|
|
2814
2851
|
const entry = this.addonEntries.get(id);
|
|
2815
2852
|
if (!entry?.declaration || !entry.addonDir)
|
|
2816
2853
|
continue;
|
|
2817
|
-
if (entry
|
|
2818
|
-
continue;
|
|
2819
|
-
const placement = (0, types_1.resolveAddonPlacement)(entry.declaration);
|
|
2820
|
-
if (placement === 'agent-only')
|
|
2854
|
+
if (!this.runsInOwnRunner(entry))
|
|
2821
2855
|
continue;
|
|
2822
2856
|
const runnerId = (0, types_1.resolveRunnerId)(entry.declaration, id);
|
|
2823
2857
|
const bucket = plan.get(runnerId) ?? [];
|
|
@@ -2826,6 +2860,26 @@ class AddonRegistryService {
|
|
|
2826
2860
|
}
|
|
2827
2861
|
return plan;
|
|
2828
2862
|
}
|
|
2863
|
+
/**
|
|
2864
|
+
* THE fork predicate: does this entry boot in its own runner subprocess?
|
|
2865
|
+
*
|
|
2866
|
+
* Every consumer of "forked vs in-process" asks THIS — the runner plan, the
|
|
2867
|
+
* in-process boot passes, route mounts, data-plane mounts, restart and
|
|
2868
|
+
* uninstall. They are required to agree, and when they did not
|
|
2869
|
+
* (`isForkedAddonEntry` demanding an `execution` block the plan did not)
|
|
2870
|
+
* `auth-oidc` was forked at boot yet classified in-process, and its routes
|
|
2871
|
+
* were registered against an async UDS proxy (`getRoutes(...).map is not a
|
|
2872
|
+
* function`).
|
|
2873
|
+
*/
|
|
2874
|
+
runsInOwnRunner(entry) {
|
|
2875
|
+
if (!entry.declaration || !entry.addonDir)
|
|
2876
|
+
return false;
|
|
2877
|
+
if ((0, types_1.resolveAddonPlacement)(entry.declaration) === 'agent-only')
|
|
2878
|
+
return false;
|
|
2879
|
+
if (entry.packageName !== '@camstack/system')
|
|
2880
|
+
return true;
|
|
2881
|
+
return (0, types_1.isIsolatedBuiltin)(entry.declaration);
|
|
2882
|
+
}
|
|
2829
2883
|
/**
|
|
2830
2884
|
* Spawn ONE runner subprocess that hosts every addon in `addons` and
|
|
2831
2885
|
* register their custom-actions catalogs against the shared
|
|
@@ -261,6 +261,17 @@ class MoleculerService {
|
|
|
261
261
|
// handler retries the hub-local route then throws a precise error rather
|
|
262
262
|
// than the unroutable broker fallback (`switch.switch.getStatus`).
|
|
263
263
|
isDeviceNativeCap: (capName) => this.capabilityService.getRegistry()?.getDefinition(capName)?.deviceNative === true,
|
|
264
|
+
// `native: true` route — a forked WRAPPER asking for the per-device
|
|
265
|
+
// native behind its own cap name (the snapshot wrapper, once it moved
|
|
266
|
+
// out of hub-main). `getNativeProvider` is the single authority that
|
|
267
|
+
// can tell the two apart: in-process natives, hub-local vendor children
|
|
268
|
+
// (via `setNativeFallback` → `resolveHubLocalUdsRoute`, which skips any
|
|
269
|
+
// in-hub provider shadowing the name) and remote agents. Every other
|
|
270
|
+
// route here resolves by cap name and would hand the wrapper back to
|
|
271
|
+
// itself.
|
|
272
|
+
resolveNative: (capName, deviceId) => this.capabilityService
|
|
273
|
+
.getRegistry()
|
|
274
|
+
?.getNativeProvider(capName, deviceId) ?? null,
|
|
264
275
|
// `nodeIdMode: 'data'` signal: for these caps (`addon-settings`,
|
|
265
276
|
// `addons`, `nodes`, `pipeline-orchestrator`) an inline `args.nodeId`
|
|
266
277
|
// is provider DATA — the hub singleton dispatches internally — never a
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* What a node WOULD install at first boot once the image stops carrying an
|
|
4
|
+
* addon seed tree — [D45](../../../docs/decisions/adr-0045.md), task 32.
|
|
5
|
+
*
|
|
6
|
+
* Operator directive, 2026-08-12: the docker seed stays for `@camstack/server`
|
|
7
|
+
* and the system packages ("una copia di server e dei pacchetti di sistema"),
|
|
8
|
+
* and *"tutti gli altri addons devono rimanere copia unica, sempre, installati
|
|
9
|
+
* durante l'avvio iniziale"*. Dropping `/opt/camstack-seed-addons` moves the
|
|
10
|
+
* version decision from image-build time — where the Dockerfile installs the
|
|
11
|
+
* exact version the hub closure resolved — to boot time. Something then has to
|
|
12
|
+
* answer two questions the seed answered implicitly:
|
|
13
|
+
*
|
|
14
|
+
* 1. **Which version?** The closure's own `package.json` is the pin, and it is
|
|
15
|
+
* not always there: `@camstack/server` 1.2.90…1.2.94 were published with
|
|
16
|
+
* `"@camstack/addon-pipeline": "*"` (pin-root-deps did not run on those
|
|
17
|
+
* releases), 1.2.96+ carry exact versions. Unpinned resolves to `latest`,
|
|
18
|
+
* and that is REPORTED rather than hidden — a node that installed
|
|
19
|
+
* latest-of-everything cannot answer "what shipped here".
|
|
20
|
+
* 2. **What about the copy already on disk?** It wins, always
|
|
21
|
+
* ([D90](../../../docs/decisions/adr-0090.md)). The live hub runs
|
|
22
|
+
* `addon-pipeline` 1.2.69 against an image seed of 1.2.64; replacing a
|
|
23
|
+
* deployed copy with the closure's number is exactly the rollback the
|
|
24
|
+
* unconditional `cp -a` caused. So an installed copy is ADOPTED — and a gap
|
|
25
|
+
* against the pin is reported, never repaired behind the operator's back.
|
|
26
|
+
*
|
|
27
|
+
* Everything here is pure: the caller supplies the roster, the pins and what is
|
|
28
|
+
* on disk. It installs nothing and it decides nothing about booting — the
|
|
29
|
+
* launcher runs it in OBSERVE mode, which prints the plan and changes no
|
|
30
|
+
* behaviour.
|
|
31
|
+
*/
|
|
32
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
33
|
+
exports.resolveWantedVersion = resolveWantedVersion;
|
|
34
|
+
exports.compareVersions = compareVersions;
|
|
35
|
+
exports.bootstrapPinsFrom = bootstrapPinsFrom;
|
|
36
|
+
exports.planFirstBootAddons = planFirstBootAddons;
|
|
37
|
+
exports.formatFirstBootPlan = formatFirstBootPlan;
|
|
38
|
+
/** A plain `major.minor.patch`, optionally with a prerelease/build suffix. */
|
|
39
|
+
const EXACT_VERSION = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z-.]+)?$/;
|
|
40
|
+
/**
|
|
41
|
+
* The version a fetch should ask for, given whatever the closure declared.
|
|
42
|
+
*
|
|
43
|
+
* Only an EXACT version is a pin. A range (`^1.2.0`) is deliberately not
|
|
44
|
+
* honoured: `installFromNpm`'s registry path resolves exact versions and
|
|
45
|
+
* dist-tags only (ranges fall back to `npm pack`), so pretending to support one
|
|
46
|
+
* here would produce a spec the fetch cannot use. Everything else — `*`, a
|
|
47
|
+
* range, an absent dep — becomes `latest`, flagged as unpinned so the caller
|
|
48
|
+
* can say it out loud.
|
|
49
|
+
*/
|
|
50
|
+
function resolveWantedVersion(raw) {
|
|
51
|
+
if (raw !== undefined && EXACT_VERSION.test(raw))
|
|
52
|
+
return { spec: raw, pinned: true };
|
|
53
|
+
return { spec: 'latest', pinned: false };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Compare two plain versions. `1` when `a` is newer, `-1` when older, `0` when
|
|
57
|
+
* equal, `null` when either side is not a plain `x.y.z`.
|
|
58
|
+
*
|
|
59
|
+
* A local comparator rather than `@camstack/node-root`'s: that package is
|
|
60
|
+
* `private: true` and is ABSENT from the server closure, and this module is
|
|
61
|
+
* reached from `launcher.ts`, which is `tsc` output rather than a bundle — an
|
|
62
|
+
* import of it would be an unresolvable `require` at boot (the trap recorded in
|
|
63
|
+
* `docs/architecture/update-and-release.md`). Prerelease ordering is not
|
|
64
|
+
* modelled: nothing in the bootstrap roster ships one, and guessing at
|
|
65
|
+
* prerelease precedence would be a second, subtly different semver.
|
|
66
|
+
*/
|
|
67
|
+
function compareVersions(a, b) {
|
|
68
|
+
if (!EXACT_VERSION.test(a) || !EXACT_VERSION.test(b))
|
|
69
|
+
return null;
|
|
70
|
+
const parse = (v) => v
|
|
71
|
+
.split(/[-+]/)[0]
|
|
72
|
+
.split('.')
|
|
73
|
+
.map((n) => Number.parseInt(n, 10));
|
|
74
|
+
const left = parse(a);
|
|
75
|
+
const right = parse(b);
|
|
76
|
+
for (let i = 0; i < 3; i++) {
|
|
77
|
+
const l = left[i] ?? 0;
|
|
78
|
+
const r = right[i] ?? 0;
|
|
79
|
+
if (l !== r)
|
|
80
|
+
return l > r ? 1 : -1;
|
|
81
|
+
}
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
/** The `@camstack/*` half of a closure manifest's dependencies — the pins. */
|
|
85
|
+
function bootstrapPinsFrom(manifest) {
|
|
86
|
+
const out = {};
|
|
87
|
+
for (const [name, spec] of Object.entries(manifest.dependencies ?? {})) {
|
|
88
|
+
if (name.startsWith('@camstack/'))
|
|
89
|
+
out[name] = spec;
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
/** The plan. Pure; installs nothing. */
|
|
94
|
+
function planFirstBootAddons(input) {
|
|
95
|
+
const provided = new Set(input.closureProvided);
|
|
96
|
+
const decisions = input.required.map((pkg) => decide(pkg, input.closurePins[pkg], input.installed, provided));
|
|
97
|
+
const wouldInstall = decisions.filter((d) => d.action === 'install-npm').map((d) => d.pkg);
|
|
98
|
+
return {
|
|
99
|
+
decisions,
|
|
100
|
+
wouldInstall,
|
|
101
|
+
unpinned: decisions
|
|
102
|
+
.filter((d) => d.action === 'install-npm' && !d.want.pinned)
|
|
103
|
+
.map((d) => d.pkg),
|
|
104
|
+
divergent: decisions.filter((d) => d.divergent).map((d) => d.pkg),
|
|
105
|
+
needsRegistry: wouldInstall.length > 0,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function decide(pkg, pin, installed, closureProvided) {
|
|
109
|
+
const want = resolveWantedVersion(pin);
|
|
110
|
+
const hasEntry = Object.hasOwn(installed, pkg);
|
|
111
|
+
const version = installed[pkg] ?? null;
|
|
112
|
+
// Host-provided and reachable: a copy under the addon root WINS over the
|
|
113
|
+
// closure and nothing refreshes it — the shape that left both agents 17
|
|
114
|
+
// versions behind. Same rule as `shouldSkipClosureProvidedSeed`.
|
|
115
|
+
if (closureProvided.has(pkg)) {
|
|
116
|
+
return {
|
|
117
|
+
pkg,
|
|
118
|
+
action: 'skip-closure-provided',
|
|
119
|
+
want,
|
|
120
|
+
installedVersion: version,
|
|
121
|
+
divergent: false,
|
|
122
|
+
reason: 'provided by the server closure — a copy here would shadow it (D15)',
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (version === null) {
|
|
126
|
+
return {
|
|
127
|
+
pkg,
|
|
128
|
+
action: 'install-npm',
|
|
129
|
+
want,
|
|
130
|
+
installedVersion: null,
|
|
131
|
+
divergent: false,
|
|
132
|
+
reason: hasEntry
|
|
133
|
+
? `installed copy has an unreadable package.json — fetching ${want.spec}`
|
|
134
|
+
: want.pinned
|
|
135
|
+
? `absent — fetch ${want.spec}, the version the running closure pins`
|
|
136
|
+
: 'absent, and the closure carries no pin — fetching latest',
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
pkg,
|
|
141
|
+
action: 'adopt-installed',
|
|
142
|
+
want,
|
|
143
|
+
installedVersion: version,
|
|
144
|
+
...adoptionVerdict(version, want),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Whether an installed copy agrees with the pin — and, when it does not, in
|
|
149
|
+
* which direction. The copy is adopted either way: `<dataDir>/addons` is the
|
|
150
|
+
* runtime authority and an image number must never outrank it (D90). What
|
|
151
|
+
* changes is whether the boot says something about it.
|
|
152
|
+
*/
|
|
153
|
+
function adoptionVerdict(version, want) {
|
|
154
|
+
if (!want.pinned) {
|
|
155
|
+
return { divergent: false, reason: `installed ${version}; the closure carries no pin to check` };
|
|
156
|
+
}
|
|
157
|
+
const order = compareVersions(version, want.spec);
|
|
158
|
+
if (order === null) {
|
|
159
|
+
return { divergent: true, reason: `installed ${version} is not comparable to pin ${want.spec}` };
|
|
160
|
+
}
|
|
161
|
+
if (order === 0)
|
|
162
|
+
return { divergent: false, reason: `installed ${version} matches the pin` };
|
|
163
|
+
const sameMajor = version.split('.')[0] === want.spec.split('.')[0];
|
|
164
|
+
if (!sameMajor) {
|
|
165
|
+
return {
|
|
166
|
+
divergent: true,
|
|
167
|
+
reason: `installed ${version} is a different major from the pinned ${want.spec} — a framework contract, not a version gap`,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
if (order > 0) {
|
|
171
|
+
return {
|
|
172
|
+
divergent: false,
|
|
173
|
+
reason: `installed ${version} is ahead of the pinned ${want.spec} — a deploy outranks the image (D90)`,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
divergent: true,
|
|
178
|
+
reason: `installed ${version} is BEHIND the pinned ${want.spec} — kept, because replacing it is an un-deploy`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The report.
|
|
183
|
+
*
|
|
184
|
+
* Always states the roster it checked and how much work it found, even when
|
|
185
|
+
* that is none: a boot that prints nothing is indistinguishable from a boot
|
|
186
|
+
* where the check did not run, and this repo has paid for that twice. In
|
|
187
|
+
* `observe` the lines say so explicitly — an observe line that reads like an
|
|
188
|
+
* install is worse than no line at all.
|
|
189
|
+
*/
|
|
190
|
+
function formatFirstBootPlan(plan, mode) {
|
|
191
|
+
const header = `first-boot addon plan (${mode}) — ${plan.decisions.length} package(s), ` +
|
|
192
|
+
`${plan.wouldInstall.length} to install, ${plan.divergent.length} divergent`;
|
|
193
|
+
const lines = [
|
|
194
|
+
mode === 'observe' ? `${header}; nothing was installed from this plan` : header,
|
|
195
|
+
];
|
|
196
|
+
for (const d of plan.decisions) {
|
|
197
|
+
const target = d.action === 'install-npm' ? `@${d.want.spec}` : '';
|
|
198
|
+
lines.push(` ${d.pkg}${target} — ${d.action}: ${d.reason}`);
|
|
199
|
+
}
|
|
200
|
+
if (plan.unpinned.length > 0) {
|
|
201
|
+
lines.push(` unpinned (would resolve the registry's latest): ${plan.unpinned.join(', ')} — ` +
|
|
202
|
+
'the running closure declares no exact version for these, so what a node ends ' +
|
|
203
|
+
'up with depends on when it booted');
|
|
204
|
+
}
|
|
205
|
+
if (!plan.needsRegistry) {
|
|
206
|
+
lines.push(' no registry access needed — every required addon is already on disk');
|
|
207
|
+
}
|
|
208
|
+
return lines;
|
|
209
|
+
}
|
package/dist/launcher.js
CHANGED
|
@@ -54,12 +54,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
54
54
|
const fs = __importStar(require("node:fs"));
|
|
55
55
|
const os = __importStar(require("node:os"));
|
|
56
56
|
const path = __importStar(require("node:path"));
|
|
57
|
-
const tar = __importStar(require("tar"));
|
|
58
57
|
const yaml = __importStar(require("js-yaml"));
|
|
59
|
-
const
|
|
58
|
+
const tar = __importStar(require("tar"));
|
|
60
59
|
const addon_root_inventory_js_1 = require("./addon-root-inventory.js");
|
|
61
|
-
const package_inventory_js_1 = require("./package-inventory.js");
|
|
62
60
|
const bootstrap_packages_js_1 = require("./bootstrap-packages.js");
|
|
61
|
+
const first_boot_addon_plan_js_1 = require("./first-boot-addon-plan.js");
|
|
62
|
+
const framework_nodepath_js_1 = require("./framework-nodepath.js");
|
|
63
|
+
const package_inventory_js_1 = require("./package-inventory.js");
|
|
63
64
|
/** Path of the manifest file embedded inside every archive. */
|
|
64
65
|
const ARCHIVE_MANIFEST_NAME = '.camstack-backup-manifest.json';
|
|
65
66
|
/** Resolve the data directory from env or default */
|
|
@@ -206,15 +207,44 @@ function readBootstrapInstallSource(dataDir, bootstrapSchema) {
|
|
|
206
207
|
* `launch()` on import, so nothing declared here can be imported by a spec).
|
|
207
208
|
*/
|
|
208
209
|
function deriveBootstrapFromSelf() {
|
|
210
|
+
const pkg = readOwnManifest();
|
|
211
|
+
return pkg === null ? [] : (0, bootstrap_packages_js_1.selectHubBootstrapPackages)(pkg);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* THIS server closure's own `package.json` — the roster's source and, when
|
|
215
|
+
* `pin-root-deps` ran for the release, the exact version of every bootstrap
|
|
216
|
+
* addon. Read by absolute path for the same reason `deriveBootstrapFromSelf`
|
|
217
|
+
* is: the slim image strips the `@camstack/*` symlinks, so `require.resolve`
|
|
218
|
+
* would answer nothing here.
|
|
219
|
+
*/
|
|
220
|
+
function readOwnManifest() {
|
|
209
221
|
try {
|
|
210
222
|
const pkgPath = path.resolve(__dirname, '..', 'package.json');
|
|
211
|
-
|
|
212
|
-
return (0, bootstrap_packages_js_1.selectHubBootstrapPackages)(pkg);
|
|
223
|
+
return JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
213
224
|
}
|
|
214
225
|
catch (err) {
|
|
215
|
-
console.warn(`[launcher] could not
|
|
216
|
-
return
|
|
226
|
+
console.warn(`[launcher] could not read the server manifest: ${err instanceof Error ? err.message : String(err)}`);
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/** The version each required package currently has under the addon root. */
|
|
231
|
+
function readInstalledAddonVersions(addonRoot, packages) {
|
|
232
|
+
const out = {};
|
|
233
|
+
for (const name of packages) {
|
|
234
|
+
const pkgJson = path.join(addonRoot, name, 'package.json');
|
|
235
|
+
if (!fs.existsSync(pkgJson))
|
|
236
|
+
continue;
|
|
237
|
+
try {
|
|
238
|
+
const raw = JSON.parse(fs.readFileSync(pkgJson, 'utf-8'));
|
|
239
|
+
const version = typeof raw === 'object' && raw !== null ? raw.version : undefined;
|
|
240
|
+
out[name] = typeof version === 'string' ? version : null;
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
// Present but unreadable is NOT absent — the plan says so in its own words.
|
|
244
|
+
out[name] = null;
|
|
245
|
+
}
|
|
217
246
|
}
|
|
247
|
+
return out;
|
|
218
248
|
}
|
|
219
249
|
/**
|
|
220
250
|
* Read `bootstrap.requiredAddons` from `<dataDir>/config.yaml` if present.
|
|
@@ -364,6 +394,44 @@ async function launch() {
|
|
|
364
394
|
const roleDefaultBootstrap = role === 'agent' ? AddonInstaller.AGENT_PACKAGES : deriveBootstrapFromSelf();
|
|
365
395
|
const bootstrapRequired = readBootstrapRequiredAddons(dataDir, bootstrapSchema) ?? roleDefaultBootstrap;
|
|
366
396
|
console.log(`[launcher] bootstrap (${role}): ${bootstrapRequired.length} required package(s)`);
|
|
397
|
+
// WHAT WOULD A SEED-FREE IMAGE DO HERE? (D45 task 32 — OBSERVE ONLY)
|
|
398
|
+
//
|
|
399
|
+
// The image still bakes an addon seed tree, so `ensureRequiredPackages` below
|
|
400
|
+
// usually finds every package already on disk. Once the seed leaves the image
|
|
401
|
+
// ("tutti gli altri addons devono rimanere copia unica, sempre, installati
|
|
402
|
+
// durante l'avvio iniziale", 2026-08-12) this same roster has to come from the
|
|
403
|
+
// registry, at a version somebody chose. This block prints that plan and
|
|
404
|
+
// NOTHING ELSE: no install, no version argument, no branch. It exists so the
|
|
405
|
+
// decision can be read off real boots — how many packages a node would fetch,
|
|
406
|
+
// which ones the closure carries no pin for, and where a deployed copy already
|
|
407
|
+
// disagrees with it — before any of it is switched on.
|
|
408
|
+
//
|
|
409
|
+
// Diagnostics, so it can never end a boot: same policy as the inventories.
|
|
410
|
+
try {
|
|
411
|
+
const ownManifest = readOwnManifest();
|
|
412
|
+
const plan = (0, first_boot_addon_plan_js_1.planFirstBootAddons)({
|
|
413
|
+
required: bootstrapRequired,
|
|
414
|
+
closurePins: ownManifest === null ? {} : (0, first_boot_addon_plan_js_1.bootstrapPinsFrom)(ownManifest),
|
|
415
|
+
installed: readInstalledAddonVersions(addonsDir, bootstrapRequired),
|
|
416
|
+
// Mirrors `shouldSkipClosureProvidedSeed`: only `@camstack/system`, and
|
|
417
|
+
// only while it actually resolves.
|
|
418
|
+
closureProvided: (() => {
|
|
419
|
+
try {
|
|
420
|
+
require.resolve('@camstack/system/package.json');
|
|
421
|
+
return ['@camstack/system'];
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
return [];
|
|
425
|
+
}
|
|
426
|
+
})(),
|
|
427
|
+
});
|
|
428
|
+
for (const line of (0, first_boot_addon_plan_js_1.formatFirstBootPlan)(plan, 'observe')) {
|
|
429
|
+
console.log(`[launcher] ${line}`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
catch (err) {
|
|
433
|
+
console.warn(`[launcher] first-boot addon plan failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
434
|
+
}
|
|
367
435
|
await installer.ensureRequiredPackages(bootstrapRequired);
|
|
368
436
|
// Reconcile the install manifest against what is on disk — the directory is
|
|
369
437
|
// the truth, because it is what the loader runs. Registers image-seeded
|
|
@@ -472,12 +540,21 @@ async function launch() {
|
|
|
472
540
|
// 1.2.30), so every version the node reported was true of a copy nobody was
|
|
473
541
|
// running.
|
|
474
542
|
//
|
|
475
|
-
//
|
|
476
|
-
// the image
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
//
|
|
543
|
+
// Only the copies nobody deposits on purpose are reported. The /opt seeds
|
|
544
|
+
// are baked into the image and shadowed by the /data/server-root/current
|
|
545
|
+
// deposit on every applyServerUpdate — calling those a violation printed the
|
|
546
|
+
// banner at every boot on every node, which is how it stopped being read.
|
|
547
|
+
// They get one compact line instead; an unexpected location (the legacy
|
|
548
|
+
// /data/framework, a bootstrap install under /data/addons) and two copies
|
|
549
|
+
// that could both run still scream.
|
|
550
|
+
//
|
|
551
|
+
// NEVER fatal — operator directive, 2026-08-12: "non dovremmo mai prevenire
|
|
552
|
+
// il boot". There is no flag that turns this into a refusal. A node that
|
|
553
|
+
// cannot start is a camera system that is not recording, and the inventory is
|
|
554
|
+
// the thing that was supposed to EXPLAIN a bad layout, not enact a verdict on
|
|
555
|
+
// it. Removing a copy stays an operator decision.
|
|
556
|
+
//
|
|
557
|
+
// [inventory-diagnostics-begin] enforced by scripts/check-inventory-never-fatal.ts
|
|
481
558
|
try {
|
|
482
559
|
const inventory = (0, package_inventory_js_1.inventoryHostProvided)((0, package_inventory_js_1.hostProvidedSearchRoots)({
|
|
483
560
|
nodePath: process.env['NODE_PATH'],
|
|
@@ -508,13 +585,12 @@ async function launch() {
|
|
|
508
585
|
}
|
|
509
586
|
},
|
|
510
587
|
});
|
|
588
|
+
for (const note of (0, package_inventory_js_1.formatShadowingNotes)(inventory)) {
|
|
589
|
+
console.log(`[launcher] ${note}`);
|
|
590
|
+
}
|
|
511
591
|
const report = (0, package_inventory_js_1.formatInventoryReport)(inventory);
|
|
512
592
|
if (report !== '') {
|
|
513
593
|
console.error(`[launcher] ${report}`);
|
|
514
|
-
if (process.env['CAMSTACK_INVENTORY_STRICT'] === '1') {
|
|
515
|
-
console.error('[launcher] CAMSTACK_INVENTORY_STRICT=1 — refusing to boot on this layout');
|
|
516
|
-
process.exit(1);
|
|
517
|
-
}
|
|
518
594
|
}
|
|
519
595
|
}
|
|
520
596
|
catch (err) {
|
|
@@ -532,10 +608,9 @@ async function launch() {
|
|
|
532
608
|
// not run since June. Measured on the Mac agent: four addon roots, four
|
|
533
609
|
// truthful `addon-pipeline` versions, one of them the running node's.
|
|
534
610
|
//
|
|
535
|
-
// Reported, never fatal, never deleted here — same policy as D45
|
|
536
|
-
// second location LOUD is the whole job;
|
|
537
|
-
//
|
|
538
|
-
// machine that HAS been cleaned keeps itself clean.
|
|
611
|
+
// Reported, never fatal, never deleted here — same policy as D45 above, and
|
|
612
|
+
// no flag changes it. Making the second location LOUD is the whole job;
|
|
613
|
+
// removing it stays an operator decision.
|
|
539
614
|
try {
|
|
540
615
|
const addonRoots = (0, addon_root_inventory_js_1.inventoryAddonRoots)({
|
|
541
616
|
dataDir: DATA_DIR,
|
|
@@ -579,15 +654,12 @@ async function launch() {
|
|
|
579
654
|
const addonRootReport = (0, addon_root_inventory_js_1.formatAddonRootReport)(addonRoots);
|
|
580
655
|
if (addonRootReport !== '') {
|
|
581
656
|
console.error(`[launcher] ${addonRootReport}`);
|
|
582
|
-
if (process.env['CAMSTACK_INVENTORY_STRICT'] === '1') {
|
|
583
|
-
console.error('[launcher] CAMSTACK_INVENTORY_STRICT=1 — refusing to boot on this layout');
|
|
584
|
-
process.exit(1);
|
|
585
|
-
}
|
|
586
657
|
}
|
|
587
658
|
}
|
|
588
659
|
catch (err) {
|
|
589
660
|
console.warn(`[launcher] addon-root inventory failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
590
661
|
}
|
|
662
|
+
// [inventory-diagnostics-end]
|
|
591
663
|
// Now safe to load the role's runtime entry (both have static imports from
|
|
592
664
|
// @camstack/system, resolved only after the framework dir + NODE_PATH setup
|
|
593
665
|
// above). The agent boots from THIS same @camstack/server closure — there is
|
package/dist/main.js
CHANGED
|
@@ -888,7 +888,15 @@ async function bootstrap() {
|
|
|
888
888
|
return reply.status(404).send({ error: 'Not found' });
|
|
889
889
|
}
|
|
890
890
|
if (method === 'GET' && spaIndexHtml) {
|
|
891
|
-
|
|
891
|
+
// An addon deep-link that is not an addon ROUTE is an admin-UI page —
|
|
892
|
+
// serve the admin shell, under the SAME no-store policy as `/`. This
|
|
893
|
+
// send used to carry no cache header at all, so browsers kept the
|
|
894
|
+
// index.html of a previous build (and its dead hashed asset names)
|
|
895
|
+
// across deploys until a force refresh.
|
|
896
|
+
return reply
|
|
897
|
+
.header('cache-control', (0, spa_static_1.spaShellCacheControl)())
|
|
898
|
+
.type('text/html')
|
|
899
|
+
.send(fs.createReadStream(spaIndexHtml));
|
|
892
900
|
}
|
|
893
901
|
return reply.status(404).send({ error: 'Not found' });
|
|
894
902
|
}
|
|
@@ -1115,7 +1123,7 @@ async function bootstrap() {
|
|
|
1115
1123
|
}
|
|
1116
1124
|
return reply.callNotFound();
|
|
1117
1125
|
}
|
|
1118
|
-
reply.header('cache-control',
|
|
1126
|
+
reply.header('cache-control', (0, spa_static_1.spaShellCacheControl)());
|
|
1119
1127
|
return reply.type('text/html').send(fs.createReadStream(indexPath));
|
|
1120
1128
|
});
|
|
1121
1129
|
fastify.get('/*', async (request, reply) => {
|
|
@@ -1143,7 +1151,7 @@ async function bootstrap() {
|
|
|
1143
1151
|
}
|
|
1144
1152
|
return reply.callNotFound();
|
|
1145
1153
|
}
|
|
1146
|
-
reply.header('cache-control',
|
|
1154
|
+
reply.header('cache-control', (0, spa_static_1.spaShellCacheControl)());
|
|
1147
1155
|
return reply.type('text/html').send(fs.createReadStream(indexPath));
|
|
1148
1156
|
});
|
|
1149
1157
|
const resolveAdminUi = async () => {
|
|
@@ -38,6 +38,7 @@ exports.hostProvidedSearchRoots = hostProvidedSearchRoots;
|
|
|
38
38
|
exports.seedNodeModulesRoots = seedNodeModulesRoots;
|
|
39
39
|
exports.inventoryHostProvided = inventoryHostProvided;
|
|
40
40
|
exports.formatInventoryReport = formatInventoryReport;
|
|
41
|
+
exports.formatShadowingNotes = formatShadowingNotes;
|
|
41
42
|
/**
|
|
42
43
|
* Boot-time inventory of host-provided packages — enforcement step 2 of
|
|
43
44
|
* [D45](../../../docs/decisions/adr-0045.md).
|
|
@@ -54,6 +55,16 @@ exports.formatInventoryReport = formatInventoryReport;
|
|
|
54
55
|
* This answers the other half — "what else is lying around that could have
|
|
55
56
|
* run" — which is the question a resolved path alone cannot.
|
|
56
57
|
*
|
|
58
|
+
* WHERE a copy sits decides whether it is noise or a violation. Two of the four
|
|
59
|
+
* copies above are EXPECTED: the `/opt` seed trees are baked into the image and
|
|
60
|
+
* deliberately shadowed by the `/data/server-root/current` deposit on every
|
|
61
|
+
* `applyServerUpdate`. Counting them as a violation printed the banner at every
|
|
62
|
+
* boot on every node, which taught the operator to scroll past the one boot
|
|
63
|
+
* where it mattered — the exact failure D45 exists to prevent. So the detector
|
|
64
|
+
* classifies locations and screams only for a copy nobody deposits on purpose,
|
|
65
|
+
* or for two copies that could BOTH be the one that runs. The expected
|
|
66
|
+
* shadowing gets one compact line instead.
|
|
67
|
+
*
|
|
57
68
|
* Everything here is pure: the caller injects the three filesystem operations,
|
|
58
69
|
* so the whole inventory is testable against a described layout rather than a
|
|
59
70
|
* real disk.
|
|
@@ -78,24 +89,39 @@ exports.HOST_PROVIDED_PACKAGES = [
|
|
|
78
89
|
* to a handful of stat calls at boot. `/data/addons` is a root in its own right
|
|
79
90
|
* because the bootstrap-install layout puts the package DIRECTLY there
|
|
80
91
|
* (`/data/addons/@camstack/system`), not under a `node_modules`.
|
|
92
|
+
*
|
|
93
|
+
* A `NODE_PATH` entry is classified by WHICH directory it names, not by being
|
|
94
|
+
* on `NODE_PATH`: the launcher puts the deposit and the seeds there itself, so
|
|
95
|
+
* "it is on NODE_PATH" would mark the whole fleet unexpected.
|
|
81
96
|
*/
|
|
82
97
|
function hostProvidedSearchRoots(input) {
|
|
83
98
|
const fromNodePath = (input.nodePath ?? '')
|
|
84
99
|
.split(input.pathSeparator)
|
|
85
100
|
.map((p) => p.trim())
|
|
86
101
|
.filter((p) => p.length > 0);
|
|
102
|
+
const seedPaths = new Set(input.seedRoots);
|
|
103
|
+
const expectationFor = (dir) => {
|
|
104
|
+
if (dir === input.serverNodeModules)
|
|
105
|
+
return 'active';
|
|
106
|
+
if (seedPaths.has(dir))
|
|
107
|
+
return 'seed';
|
|
108
|
+
return 'unexpected';
|
|
109
|
+
};
|
|
87
110
|
const roots = [
|
|
88
|
-
input.serverNodeModules,
|
|
89
|
-
...fromNodePath,
|
|
90
|
-
path.join(input.dataDir, 'framework', 'node_modules'),
|
|
91
|
-
path.join(input.dataDir, 'addons'),
|
|
92
|
-
...input.seedRoots,
|
|
111
|
+
{ path: input.serverNodeModules, expectation: 'active' },
|
|
112
|
+
...fromNodePath.map((p) => ({ path: p, expectation: expectationFor(p) })),
|
|
113
|
+
{ path: path.join(input.dataDir, 'framework', 'node_modules'), expectation: 'unexpected' },
|
|
114
|
+
{ path: path.join(input.dataDir, 'addons'), expectation: 'unexpected' },
|
|
115
|
+
...input.seedRoots.map((p) => ({ path: p, expectation: expectationFor(p) })),
|
|
93
116
|
];
|
|
117
|
+
// First occurrence wins, and the deposit is listed first — so a directory
|
|
118
|
+
// that is both the closure and a seed keeps the classification that decides
|
|
119
|
+
// whether it runs.
|
|
94
120
|
const seen = new Set();
|
|
95
121
|
return roots.filter((r) => {
|
|
96
|
-
if (seen.has(r))
|
|
122
|
+
if (seen.has(r.path))
|
|
97
123
|
return false;
|
|
98
|
-
seen.add(r);
|
|
124
|
+
seen.add(r.path);
|
|
99
125
|
return true;
|
|
100
126
|
});
|
|
101
127
|
}
|
|
@@ -136,45 +162,106 @@ function inventoryHostProvided(roots, fs, packages = exports.HOST_PROVIDED_PACKA
|
|
|
136
162
|
// them two would cry wolf on a node that is actually compliant.
|
|
137
163
|
const seenReal = new Set();
|
|
138
164
|
for (const root of roots) {
|
|
139
|
-
const dir = path.join(root, pkg);
|
|
165
|
+
const dir = path.join(root.path, pkg);
|
|
140
166
|
if (!fs.exists(dir))
|
|
141
167
|
continue;
|
|
142
168
|
const real = fs.realPath(dir);
|
|
143
169
|
if (seenReal.has(real))
|
|
144
170
|
continue;
|
|
145
171
|
seenReal.add(real);
|
|
146
|
-
copies.push({ pkg, path: dir, version: fs.readVersion(dir) });
|
|
172
|
+
copies.push({ pkg, path: dir, version: fs.readVersion(dir), location: root.expectation });
|
|
147
173
|
}
|
|
148
174
|
}
|
|
149
|
-
const
|
|
175
|
+
const byPkg = new Map();
|
|
150
176
|
for (const c of copies)
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
.
|
|
154
|
-
.
|
|
155
|
-
|
|
156
|
-
|
|
177
|
+
byPkg.set(c.pkg, [...(byPkg.get(c.pkg) ?? []), c]);
|
|
178
|
+
const inventories = [...byPkg.entries()]
|
|
179
|
+
.map(([pkg, found]) => classifyPackage(pkg, found))
|
|
180
|
+
.sort((a, b) => a.pkg.localeCompare(b.pkg));
|
|
181
|
+
return {
|
|
182
|
+
copies,
|
|
183
|
+
packages: inventories,
|
|
184
|
+
violations: inventories.filter((p) => p.violation).map((p) => p.pkg),
|
|
185
|
+
rootsScanned: roots.map((r) => r.path),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Sort one package's copies into what each one means.
|
|
190
|
+
*
|
|
191
|
+
* A deposit copy shadows every seed — that is what `applyServerUpdate` does on
|
|
192
|
+
* every release, so those seeds are not candidates for anything. With no
|
|
193
|
+
* deposit copy the seeds ARE the candidates, and two of them is the resolution
|
|
194
|
+
* race D45 was written about.
|
|
195
|
+
*/
|
|
196
|
+
function classifyPackage(pkg, found) {
|
|
197
|
+
const active = found.filter((c) => c.location === 'active');
|
|
198
|
+
const seeds = found.filter((c) => c.location === 'seed');
|
|
199
|
+
const unexpected = found.filter((c) => c.location === 'unexpected');
|
|
200
|
+
const activeCandidates = active.length > 0 ? active : seeds;
|
|
201
|
+
const shadowedSeeds = active.length > 0 ? seeds : [];
|
|
202
|
+
return {
|
|
203
|
+
pkg,
|
|
204
|
+
copies: found,
|
|
205
|
+
activeCandidates,
|
|
206
|
+
shadowedSeeds,
|
|
207
|
+
unexpected,
|
|
208
|
+
violation: unexpected.length > 0 || activeCandidates.length > 1,
|
|
209
|
+
};
|
|
157
210
|
}
|
|
158
211
|
/**
|
|
159
|
-
* The loud report. Empty string
|
|
160
|
-
*
|
|
212
|
+
* The loud report. Empty string unless a copy is somewhere nobody deposits on
|
|
213
|
+
* purpose, or two copies could both be the one that runs — an expected seed
|
|
214
|
+
* under the deposit is the fleet's normal shape and reports nothing here, or
|
|
215
|
+
* the banner would print on every boot of every node and be read on none.
|
|
161
216
|
*/
|
|
162
217
|
function formatInventoryReport(result) {
|
|
163
|
-
|
|
218
|
+
const violating = result.packages.filter((p) => p.violation);
|
|
219
|
+
if (violating.length === 0)
|
|
164
220
|
return '';
|
|
165
221
|
const lines = [
|
|
166
|
-
'D45 violation — a host-provided package
|
|
167
|
-
'
|
|
168
|
-
'
|
|
222
|
+
'D45 violation — a host-provided package sits somewhere it must not, or two',
|
|
223
|
+
'copies could both be the one that runs. Which copy runs is then a',
|
|
224
|
+
'module-resolution outcome, and every version this node reports is a claim',
|
|
225
|
+
'about one of them rather than about the node.',
|
|
169
226
|
'',
|
|
170
227
|
];
|
|
171
|
-
for (const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
lines.push(` ${c.version ?? '<unreadable package.json>'} ${c.path}`);
|
|
228
|
+
for (const inv of violating) {
|
|
229
|
+
lines.push(` ${inv.pkg} — ${inv.copies.length} copies:`);
|
|
230
|
+
for (const c of inv.copies) {
|
|
231
|
+
lines.push(` ${c.version ?? '<unreadable package.json>'} [${describeLocation(c, inv)}] ${c.path}`);
|
|
176
232
|
}
|
|
177
233
|
}
|
|
178
234
|
lines.push('', ` roots scanned: ${result.rootsScanned.join(', ')}`);
|
|
179
235
|
return lines.join('\n');
|
|
180
236
|
}
|
|
237
|
+
/** Why this one copy is (or is not) part of the problem, in one word or two. */
|
|
238
|
+
function describeLocation(copy, inv) {
|
|
239
|
+
if (copy.location === 'unexpected')
|
|
240
|
+
return 'UNEXPECTED — remove it';
|
|
241
|
+
if (inv.activeCandidates.includes(copy)) {
|
|
242
|
+
return inv.activeCandidates.length > 1 ? 'CONFLICTS — could run' : 'active';
|
|
243
|
+
}
|
|
244
|
+
return 'image seed, shadowed';
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* The quiet half: the expected seed shadowing, one compact line per package.
|
|
248
|
+
*
|
|
249
|
+
* Deliberately not a banner and deliberately not phrased as a fault — the image
|
|
250
|
+
* bakes these trees and the deposit shadows them on every release. A package
|
|
251
|
+
* already named in {@link formatInventoryReport} is skipped: the banner lists
|
|
252
|
+
* all of its copies, and a reassuring line beside it would read as verification.
|
|
253
|
+
*/
|
|
254
|
+
function formatShadowingNotes(result) {
|
|
255
|
+
return result.packages
|
|
256
|
+
.filter((p) => !p.violation && p.shadowedSeeds.length > 0)
|
|
257
|
+
.map((p) => {
|
|
258
|
+
const seedVersions = [...new Set(p.shadowedSeeds.map(describeVersion))].join(', ');
|
|
259
|
+
const active = p.activeCandidates[0];
|
|
260
|
+
const activeVersion = active === undefined ? 'unknown' : describeVersion(active);
|
|
261
|
+
const activePath = active === undefined ? '' : ` (${active.path})`;
|
|
262
|
+
return `${p.pkg} — seed ${seedVersions} shadowed by active ${activeVersion}${activePath}`;
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
function describeVersion(copy) {
|
|
266
|
+
return copy.version ?? '<unreadable package.json>';
|
|
267
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.101",
|
|
4
4
|
"private": false,
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -33,19 +33,19 @@
|
|
|
33
33
|
]
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@camstack/addon-admin-ui": "1.2.
|
|
37
|
-
"@camstack/addon-agent-ui": "1.2.
|
|
38
|
-
"@camstack/addon-auth": "1.2.
|
|
39
|
-
"@camstack/addon-decoder-nodeav": "1.2.
|
|
40
|
-
"@camstack/addon-notifiers": "1.2.
|
|
41
|
-
"@camstack/addon-pipeline": "1.2.
|
|
42
|
-
"@camstack/addon-pipeline-orchestrator": "1.2.
|
|
43
|
-
"@camstack/addon-post-analysis": "1.2.
|
|
44
|
-
"@camstack/sdk": "1.2.
|
|
45
|
-
"@camstack/shm-ring": "1.1.
|
|
46
|
-
"@camstack/system": "1.2.
|
|
47
|
-
"@camstack/types": "1.2.
|
|
48
|
-
"@camstack/ui-library": "1.2.
|
|
36
|
+
"@camstack/addon-admin-ui": "1.2.51",
|
|
37
|
+
"@camstack/addon-agent-ui": "1.2.14",
|
|
38
|
+
"@camstack/addon-auth": "1.2.15",
|
|
39
|
+
"@camstack/addon-decoder-nodeav": "1.2.13",
|
|
40
|
+
"@camstack/addon-notifiers": "1.2.18",
|
|
41
|
+
"@camstack/addon-pipeline": "1.2.70",
|
|
42
|
+
"@camstack/addon-pipeline-orchestrator": "1.2.50",
|
|
43
|
+
"@camstack/addon-post-analysis": "1.2.67",
|
|
44
|
+
"@camstack/sdk": "1.2.15",
|
|
45
|
+
"@camstack/shm-ring": "1.1.13",
|
|
46
|
+
"@camstack/system": "1.2.85",
|
|
47
|
+
"@camstack/types": "1.2.64",
|
|
48
|
+
"@camstack/ui-library": "1.2.43",
|
|
49
49
|
"@fastify/compress": "^9.0.0",
|
|
50
50
|
"@fastify/cookie": "^11.0.2",
|
|
51
51
|
"@fastify/cors": "^11.2.0",
|