@camstack/server 1.2.29 → 1.2.32
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/api/addons-custom.router.js +99 -0
- package/dist/api/core/bulk-update-coordinator.js +229 -0
- package/dist/api/core/cap-providers.js +40 -31
- package/dist/api/trpc/generated-cap-mounts.js +1 -1
- package/dist/api/trpc/trpc.router.js +3 -3
- package/dist/boot/resume-framework-swap.js +119 -0
- package/dist/bootstrap-packages.js +35 -0
- package/dist/core/addon/framework-live-sync.js +344 -0
- package/dist/launcher-framework-swap.js +408 -0
- package/dist/launcher.js +23 -19
- package/dist/main.js +12 -4
- package/dist/request-framework-swap.js +41 -0
- package/dist/server-root/boot-plan.js +110 -0
- package/dist/server-root/semver-compare.js +45 -0
- package/dist/server-root/server-root-state.js +220 -0
- package/dist/server-root/workspace-detect.js +73 -0
- package/package.json +14 -14
|
@@ -0,0 +1,99 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BulkUpdateCoordinator = void 0;
|
|
4
|
+
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument -- The server installs @camstack/types@0.1.38 (last published) in server/backend/node_modules, while the workspace has 0.1.39 with the new BulkUpdate* types. ESLint's type-checker resolves against 0.1.38 and treats the new imports as `any`. Runtime is correct because Node module resolution walks up to root node_modules → workspace symlink. This disable mirrors the pattern in cap-providers.ts (same root cause). Will resolve when 0.1.39 is published and the local dist is synced. */
|
|
5
|
+
const node_crypto_1 = require("node:crypto");
|
|
6
|
+
const types_1 = require("@camstack/types");
|
|
7
|
+
const DEFAULT_CLEANUP_AFTER_MS = 5 * 60 * 1_000;
|
|
8
|
+
class BulkUpdateCoordinator {
|
|
9
|
+
deps;
|
|
10
|
+
states = new Map();
|
|
11
|
+
cancelFlags = new Map();
|
|
12
|
+
/**
|
|
13
|
+
* Tracks wall-clock time (ms) when each bulk completed. Used for lazy
|
|
14
|
+
* cleanup in `get()` — avoids scheduling a fake-timer `setTimeout` that
|
|
15
|
+
* would be eagerly fired by `vi.runAllTimersAsync()` in tests.
|
|
16
|
+
*/
|
|
17
|
+
completedWallMs = new Map();
|
|
18
|
+
/** Tracks which nodeIds currently have an active (non-completed) bulk update. */
|
|
19
|
+
activeNodeIds = new Set();
|
|
20
|
+
now;
|
|
21
|
+
cleanupAfterMs;
|
|
22
|
+
/** Wall-clock source. Fake timers intercept `Date.now`, so tests can advance via `advanceTimersByTimeAsync`. */
|
|
23
|
+
wallNow;
|
|
24
|
+
constructor(deps) {
|
|
25
|
+
this.deps = deps;
|
|
26
|
+
this.now = deps.clock ?? (() => Date.now());
|
|
27
|
+
this.cleanupAfterMs = deps.cleanupAfterMs ?? DEFAULT_CLEANUP_AFTER_MS;
|
|
28
|
+
this.wallNow = () => Date.now();
|
|
29
|
+
}
|
|
30
|
+
// ── Public API ────────────────────────────────────────────────────
|
|
31
|
+
start(input) {
|
|
32
|
+
if (this.activeNodeIds.has(input.nodeId)) {
|
|
33
|
+
throw new Error(`Bulk update already in progress for node ${input.nodeId}`);
|
|
34
|
+
}
|
|
35
|
+
const id = (0, node_crypto_1.randomUUID)();
|
|
36
|
+
const items = input.items.map((i) => ({
|
|
37
|
+
name: i.name,
|
|
38
|
+
isSystem: i.isSystem,
|
|
39
|
+
// fromVersion: the cap interface receives name+version+isSystem only;
|
|
40
|
+
// the caller (cap-providers.ts) may enrich this with the current version
|
|
41
|
+
// if available. Empty string is acceptable per plan spec.
|
|
42
|
+
fromVersion: '',
|
|
43
|
+
toVersion: i.version,
|
|
44
|
+
status: 'queued',
|
|
45
|
+
}));
|
|
46
|
+
const state = {
|
|
47
|
+
id,
|
|
48
|
+
nodeId: input.nodeId,
|
|
49
|
+
startedAtMs: this.now(),
|
|
50
|
+
total: items.length,
|
|
51
|
+
completed: 0,
|
|
52
|
+
failed: 0,
|
|
53
|
+
current: null,
|
|
54
|
+
phase: 'regular',
|
|
55
|
+
cancelled: false,
|
|
56
|
+
items,
|
|
57
|
+
};
|
|
58
|
+
this.states.set(id, state);
|
|
59
|
+
this.activeNodeIds.add(input.nodeId);
|
|
60
|
+
const cancelFlag = { cancelled: false };
|
|
61
|
+
this.cancelFlags.set(id, cancelFlag);
|
|
62
|
+
// Emit initial state so clients see the bulk as started immediately
|
|
63
|
+
this.emit(state);
|
|
64
|
+
void this.runLoop(id, cancelFlag).catch((err) => {
|
|
65
|
+
this.deps.logger.error('BulkUpdateCoordinator: loop crashed unexpectedly', err);
|
|
66
|
+
});
|
|
67
|
+
return { id };
|
|
68
|
+
}
|
|
69
|
+
get(id) {
|
|
70
|
+
const state = this.states.get(id);
|
|
71
|
+
if (state === undefined)
|
|
72
|
+
return null;
|
|
73
|
+
// Lazy cleanup: purge if the wall-clock elapsed since completion exceeds threshold.
|
|
74
|
+
// This avoids scheduling a long-lived setTimeout that would be eagerly fired
|
|
75
|
+
// by vi.runAllTimersAsync() in tests.
|
|
76
|
+
const completedWall = this.completedWallMs.get(id);
|
|
77
|
+
if (completedWall !== undefined && this.wallNow() - completedWall >= this.cleanupAfterMs) {
|
|
78
|
+
this.purge(id);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
return state;
|
|
82
|
+
}
|
|
83
|
+
list(nodeId) {
|
|
84
|
+
const all = [...this.states.keys()]
|
|
85
|
+
.map((id) => this.get(id)) // get() applies lazy-cleanup
|
|
86
|
+
.filter((s) => s !== null);
|
|
87
|
+
return nodeId === undefined ? all : all.filter((s) => s.nodeId === nodeId);
|
|
88
|
+
}
|
|
89
|
+
cancel(id) {
|
|
90
|
+
const state = this.states.get(id);
|
|
91
|
+
const flag = this.cancelFlags.get(id);
|
|
92
|
+
if (state === undefined || flag === undefined)
|
|
93
|
+
return { cancelled: false };
|
|
94
|
+
// Once restarting, the hub restart is committed — cancel has no effect.
|
|
95
|
+
if (state.phase === 'restarting')
|
|
96
|
+
return { cancelled: false };
|
|
97
|
+
// Already completed.
|
|
98
|
+
if (state.completedAtMs !== undefined)
|
|
99
|
+
return { cancelled: false };
|
|
100
|
+
flag.cancelled = true;
|
|
101
|
+
this.mutate(id, (s) => ({ ...s, cancelled: true }));
|
|
102
|
+
return { cancelled: true };
|
|
103
|
+
}
|
|
104
|
+
// ── Internal loop ─────────────────────────────────────────────────
|
|
105
|
+
async runLoop(id, cancelFlag) {
|
|
106
|
+
const initial = this.states.get(id);
|
|
107
|
+
// ── Phase 1: regular addons ──────────────────────────────────────
|
|
108
|
+
this.transitionPhase(id, 'regular');
|
|
109
|
+
for (const item of initial.items.filter((i) => !i.isSystem)) {
|
|
110
|
+
if (cancelFlag.cancelled)
|
|
111
|
+
break;
|
|
112
|
+
await this.processItem(id, item, false);
|
|
113
|
+
}
|
|
114
|
+
// ── Phase 2: system packages (deferRestart: true) ────────────────
|
|
115
|
+
if (!cancelFlag.cancelled && initial.items.some((i) => i.isSystem)) {
|
|
116
|
+
this.transitionPhase(id, 'system');
|
|
117
|
+
for (const item of initial.items.filter((i) => i.isSystem)) {
|
|
118
|
+
if (cancelFlag.cancelled)
|
|
119
|
+
break;
|
|
120
|
+
await this.processItem(id, item, true);
|
|
121
|
+
}
|
|
122
|
+
// ── Phase 3: single restart ──────────────────────────────────
|
|
123
|
+
const anySystemPendingRestart = this.states
|
|
124
|
+
.get(id)
|
|
125
|
+
.items.some((i) => i.isSystem && i.status === 'done-pending-restart');
|
|
126
|
+
if (anySystemPendingRestart && !cancelFlag.cancelled) {
|
|
127
|
+
this.transitionPhase(id, 'restarting');
|
|
128
|
+
try {
|
|
129
|
+
await this.deps.restartServer({ confirm: true });
|
|
130
|
+
// NOTE: In production, restartServer kills+respawns the hub process.
|
|
131
|
+
// Code below this point will not execute in that scenario.
|
|
132
|
+
// If the mock/stub returns (e.g. in tests), we fall through to finalizing.
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
// Restart failed but the npm installs already completed. Promote all
|
|
136
|
+
// done-pending-restart items to done with a caveat error so the UI
|
|
137
|
+
// can inform the user that a manual restart is needed.
|
|
138
|
+
this.deps.logger.error('BulkUpdateCoordinator: restart failed', err);
|
|
139
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
140
|
+
for (const it of this.states.get(id).items) {
|
|
141
|
+
if (it.status === 'done-pending-restart') {
|
|
142
|
+
this.setItemStatus(id, it.name, 'done', {
|
|
143
|
+
error: `Restart failed; manual restart required (${errMsg})`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// ── Phase 4: finalize ────────────────────────────────────────────
|
|
151
|
+
// Reached when:
|
|
152
|
+
// a) no system packages at all, OR
|
|
153
|
+
// b) restart failed (process continued), OR
|
|
154
|
+
// c) cancelled before the restart phase.
|
|
155
|
+
this.transitionPhase(id, 'finalizing');
|
|
156
|
+
this.completeBulk(id);
|
|
157
|
+
}
|
|
158
|
+
async processItem(id, item, isSystem) {
|
|
159
|
+
this.setItemStatus(id, item.name, 'updating', { startedAtMs: this.now() });
|
|
160
|
+
this.mutate(id, (s) => ({ ...s, current: item.name }));
|
|
161
|
+
this.emit(this.states.get(id));
|
|
162
|
+
try {
|
|
163
|
+
if (isSystem) {
|
|
164
|
+
await this.deps.updateFrameworkPackage({
|
|
165
|
+
packageName: item.name,
|
|
166
|
+
version: item.toVersion,
|
|
167
|
+
deferRestart: true,
|
|
168
|
+
});
|
|
169
|
+
this.setItemStatus(id, item.name, 'done-pending-restart', { completedAtMs: this.now() });
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
await this.deps.updateAddon({ name: item.name, version: item.toVersion });
|
|
173
|
+
this.setItemStatus(id, item.name, 'done', { completedAtMs: this.now() });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
178
|
+
this.setItemStatus(id, item.name, 'failed', { error: msg, completedAtMs: this.now() });
|
|
179
|
+
}
|
|
180
|
+
this.mutate(id, (s) => ({ ...s, current: null }));
|
|
181
|
+
this.emit(this.states.get(id));
|
|
182
|
+
}
|
|
183
|
+
// ── State mutation helpers ────────────────────────────────────────
|
|
184
|
+
setItemStatus(id, name, status, fields = {}) {
|
|
185
|
+
this.mutate(id, (s) => {
|
|
186
|
+
const items = s.items.map((it) => (it.name === name ? { ...it, status, ...fields } : it));
|
|
187
|
+
// completed = all terminal states: done | done-pending-restart | failed
|
|
188
|
+
const completed = items.filter((it) => it.status === 'done' || it.status === 'done-pending-restart' || it.status === 'failed').length;
|
|
189
|
+
const failed = items.filter((it) => it.status === 'failed').length;
|
|
190
|
+
return { ...s, items, completed, failed };
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
transitionPhase(id, phase) {
|
|
194
|
+
this.mutate(id, (s) => ({ ...s, phase }));
|
|
195
|
+
this.emit(this.states.get(id));
|
|
196
|
+
}
|
|
197
|
+
completeBulk(id) {
|
|
198
|
+
this.mutate(id, (s) => ({ ...s, completedAtMs: this.now(), current: null }));
|
|
199
|
+
this.emit(this.states.get(id));
|
|
200
|
+
// Free the nodeId slot so a new bulk for the same node can be started
|
|
201
|
+
const nodeId = this.states.get(id).nodeId;
|
|
202
|
+
this.activeNodeIds.delete(nodeId);
|
|
203
|
+
// Record wall-clock completion time for lazy cleanup in `get()`.
|
|
204
|
+
// We intentionally avoid scheduling a setTimeout here: a long-lived
|
|
205
|
+
// setTimeout (5 min) would be eagerly fired by vi.runAllTimersAsync()
|
|
206
|
+
// in tests, causing `get()` to return null immediately after the run.
|
|
207
|
+
// Instead, `get()` lazily checks whether the cleanup threshold has
|
|
208
|
+
// elapsed using Date.now() — which fake timers DO advance via
|
|
209
|
+
// advanceTimersByTimeAsync(), making the cleanup testable without
|
|
210
|
+
// a long-running timer.
|
|
211
|
+
this.completedWallMs.set(id, this.wallNow());
|
|
212
|
+
}
|
|
213
|
+
purge(id) {
|
|
214
|
+
this.states.delete(id);
|
|
215
|
+
this.cancelFlags.delete(id);
|
|
216
|
+
this.completedWallMs.delete(id);
|
|
217
|
+
}
|
|
218
|
+
/** Immutably update the state for the given id. No-op if id is unknown. */
|
|
219
|
+
mutate(id, update) {
|
|
220
|
+
const current = this.states.get(id);
|
|
221
|
+
if (current === undefined)
|
|
222
|
+
return;
|
|
223
|
+
this.states.set(id, update(current));
|
|
224
|
+
}
|
|
225
|
+
emit(state) {
|
|
226
|
+
this.deps.eventBus.emit(types_1.EventCategory.AddonsBulkUpdateProgress, state);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
exports.BulkUpdateCoordinator = BulkUpdateCoordinator;
|
|
@@ -1,4 +1,29 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
/* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access -- pre-existing lint debt across this 800-line provider-factory module. The flagged sites delegate into Moleculer/EventBus/IntegrationRegistry surfaces typed as `unknown` to break circular dependencies; runtime contracts are validated by the cap-mount-helper layer above. Tracked separately. */
|
|
3
|
+
/**
|
|
4
|
+
* Capability provider factories for the Phase E core caps —
|
|
5
|
+
* `system`, `network-quality`, `toast`, `nodes`, `integrations`,
|
|
6
|
+
* `addons`. Each factory builds a fresh provider object that
|
|
7
|
+
* fulfils the cap's `InferProvider<...>` contract by delegating
|
|
8
|
+
* to the existing backend services.
|
|
9
|
+
*
|
|
10
|
+
* Why factories instead of static singletons?
|
|
11
|
+
* - `addons.custom` needs per-request `ctx.user` for per-action
|
|
12
|
+
* auth checks. The cap-router codegen already passes `ctx`
|
|
13
|
+
* into `getProvider(ctx)`, so closing over it is cheap and
|
|
14
|
+
* keeps the auth surface tight.
|
|
15
|
+
* - The other caps don't need ctx, but we keep the signature
|
|
16
|
+
* uniform for symmetry.
|
|
17
|
+
*
|
|
18
|
+
* No addon "owns" these surfaces — they manage cluster state
|
|
19
|
+
* (cluster topology, integrations, addon packages) or expose
|
|
20
|
+
* server-level singletons (toast bus, network-quality tracker,
|
|
21
|
+
* feature flags + retention controls).
|
|
22
|
+
*
|
|
23
|
+
* Phase E (2026-05-06): replaces the hand-written core routers in
|
|
24
|
+
* `server/backend/src/api/core/{system,network-quality,toast,
|
|
25
|
+
* nodes,integrations,addons}.router.ts`.
|
|
26
|
+
*/
|
|
2
27
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
28
|
if (k2 === undefined) k2 = k;
|
|
4
29
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
@@ -43,42 +68,17 @@ exports.buildNodesProvider = buildNodesProvider;
|
|
|
43
68
|
exports.buildIntegrationsProvider = buildIntegrationsProvider;
|
|
44
69
|
exports.dispatchCustomAction = dispatchCustomAction;
|
|
45
70
|
exports.buildAddonsProvider = buildAddonsProvider;
|
|
46
|
-
/* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access -- pre-existing lint debt across this 800-line provider-factory module. The flagged sites delegate into Moleculer/EventBus/IntegrationRegistry surfaces typed as `unknown` to break circular dependencies; runtime contracts are validated by the cap-mount-helper layer above. Tracked separately. */
|
|
47
|
-
/**
|
|
48
|
-
* Capability provider factories for the Phase E core caps —
|
|
49
|
-
* `system`, `network-quality`, `toast`, `nodes`, `integrations`,
|
|
50
|
-
* `addons`. Each factory builds a fresh provider object that
|
|
51
|
-
* fulfils the cap's `InferProvider<...>` contract by delegating
|
|
52
|
-
* to the existing backend services.
|
|
53
|
-
*
|
|
54
|
-
* Why factories instead of static singletons?
|
|
55
|
-
* - `addons.custom` needs per-request `ctx.user` for per-action
|
|
56
|
-
* auth checks. The cap-router codegen already passes `ctx`
|
|
57
|
-
* into `getProvider(ctx)`, so closing over it is cheap and
|
|
58
|
-
* keeps the auth surface tight.
|
|
59
|
-
* - The other caps don't need ctx, but we keep the signature
|
|
60
|
-
* uniform for symmetry.
|
|
61
|
-
*
|
|
62
|
-
* No addon "owns" these surfaces — they manage cluster state
|
|
63
|
-
* (cluster topology, integrations, addon packages) or expose
|
|
64
|
-
* server-level singletons (toast bus, network-quality tracker,
|
|
65
|
-
* feature flags + retention controls).
|
|
66
|
-
*
|
|
67
|
-
* Phase E (2026-05-06): replaces the hand-written core routers in
|
|
68
|
-
* `server/backend/src/api/core/{system,network-quality,toast,
|
|
69
|
-
* nodes,integrations,addons}.router.ts`.
|
|
70
|
-
*/
|
|
71
|
-
const os = __importStar(require("node:os"));
|
|
72
71
|
const node_child_process_1 = require("node:child_process");
|
|
73
|
-
const node_util_1 = require("node:util");
|
|
74
72
|
const node_crypto_1 = require("node:crypto");
|
|
75
|
-
const
|
|
76
|
-
const
|
|
73
|
+
const os = __importStar(require("node:os"));
|
|
74
|
+
const node_util_1 = require("node:util");
|
|
77
75
|
const system_1 = require("@camstack/system");
|
|
76
|
+
const types_1 = require("@camstack/types");
|
|
77
|
+
const server_1 = require("@trpc/server");
|
|
78
78
|
const integration_id_backfill_1 = require("../../boot/integration-id-backfill");
|
|
79
|
-
const collection_preference_js_1 = require("./collection-preference.js");
|
|
80
79
|
const addon_package_service_js_1 = require("../../core/addon/addon-package.service.js");
|
|
81
80
|
const lifecycle_runner_singleton_js_1 = require("../../core/lifecycle/lifecycle-runner.singleton.js");
|
|
81
|
+
const collection_preference_js_1 = require("./collection-preference.js");
|
|
82
82
|
const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
|
83
83
|
// ── system ──────────────────────────────────────────────────────────
|
|
84
84
|
function getRetention(registry) {
|
|
@@ -358,7 +358,7 @@ function createNodeRootPackageLookup(moleculer, serverUpdate) {
|
|
|
358
358
|
return moleculer.getNodeRootPackage(nodeId);
|
|
359
359
|
};
|
|
360
360
|
}
|
|
361
|
-
function buildNodesProvider(agentRegistry, moleculer, addonRegistry, getNodeRootPackage) {
|
|
361
|
+
function buildNodesProvider(agentRegistry, moleculer, addonRegistry, getNodeRootPackage, logger) {
|
|
362
362
|
const broker = moleculer.broker;
|
|
363
363
|
return {
|
|
364
364
|
topology: async () => computeTopology(agentRegistry, addonRegistry, getNodeRootPackage),
|
|
@@ -414,6 +414,15 @@ function buildNodesProvider(agentRegistry, moleculer, addonRegistry, getNodeRoot
|
|
|
414
414
|
},
|
|
415
415
|
shutdownNode: async (input) => {
|
|
416
416
|
if (input.nodeId === 'hub') {
|
|
417
|
+
// A whole-hub shutdown used to be COMPLETELY silent — process.exit(0)
|
|
418
|
+
// with not one log line. On 2026-08-01 the hub container went down
|
|
419
|
+
// cleanly at 15:02:55Z (docker restart policy brought it back 9s
|
|
420
|
+
// later, viewer stalled through the boot) and the initiator was
|
|
421
|
+
// unrecoverable from the logs precisely because this branch said
|
|
422
|
+
// nothing. An operator-facing kill switch logs before it fires.
|
|
423
|
+
logger?.warn('shutdownNode: HUB shutdown requested via API — exiting in 500ms', {
|
|
424
|
+
meta: { nodeId: input.nodeId },
|
|
425
|
+
});
|
|
417
426
|
setTimeout(() => process.exit(0), 500);
|
|
418
427
|
return { success: true };
|
|
419
428
|
}
|
|
@@ -260,7 +260,7 @@ function mountAllCaps(services) {
|
|
|
260
260
|
modelConvert: (0, generated_cap_routers_js_1.createCapRouter_modelConvert)((_ctx) => reg?.getSingleton('model-convert') ?? null, remoteCapProxy),
|
|
261
261
|
modelDistributor: (0, generated_cap_routers_js_1.createCapRouter_modelDistributor)((_ctx) => reg?.getSingleton('model-distributor') ??
|
|
262
262
|
null, remoteCapProxy),
|
|
263
|
-
motion: (0, generated_cap_routers_js_1.createCapRouter_motion)((_ctx) => reg
|
|
263
|
+
motion: (0, generated_cap_routers_js_1.createCapRouter_motion)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'motion'), remoteCapProxy),
|
|
264
264
|
motionDetection: (0, generated_cap_routers_js_1.createCapRouter_motionDetection)((_ctx) => reg?.getSingleton('motion-detection') ??
|
|
265
265
|
null, remoteCapProxy),
|
|
266
266
|
motionTrigger: (0, generated_cap_routers_js_1.createCapRouter_motionTrigger)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'motion-trigger'), remoteCapProxy),
|
|
@@ -8,15 +8,15 @@ exports.buildAppRouter = buildAppRouter;
|
|
|
8
8
|
const addon_settings_router_js_1 = require("../core/addon-settings.router.js");
|
|
9
9
|
const auth_router_js_1 = require("../core/auth.router.js");
|
|
10
10
|
const cap_providers_js_1 = require("../core/cap-providers.js");
|
|
11
|
-
const server_management_provider_js_1 = require("../core/server-management.provider.js");
|
|
12
11
|
const capabilities_router_js_1 = require("../core/capabilities.router.js");
|
|
12
|
+
const cluster_nodes_router_js_1 = require("../core/cluster-nodes.router.js");
|
|
13
13
|
const event_bus_proxy_router_js_1 = require("../core/event-bus-proxy.router.js");
|
|
14
14
|
const hwaccel_router_js_1 = require("../core/hwaccel.router.js");
|
|
15
15
|
const live_events_router_js_1 = require("../core/live-events.router.js");
|
|
16
16
|
const logs_router_js_1 = require("../core/logs.router.js");
|
|
17
17
|
const notifications_router_js_1 = require("../core/notifications.router.js");
|
|
18
|
-
const cluster_nodes_router_js_1 = require("../core/cluster-nodes.router.js");
|
|
19
18
|
const repl_router_js_1 = require("../core/repl.router.js");
|
|
19
|
+
const server_management_provider_js_1 = require("../core/server-management.provider.js");
|
|
20
20
|
const settings_backend_router_js_1 = require("../core/settings-backend.router.js");
|
|
21
21
|
const stream_probe_router_js_1 = require("../core/stream-probe.router.js");
|
|
22
22
|
const system_events_router_js_1 = require("../core/system-events.router.js");
|
|
@@ -195,7 +195,7 @@ function buildServerProviders(services) {
|
|
|
195
195
|
system: wrap(() => (0, cap_providers_js_1.buildSystemProvider)(services.featureService, services.capabilityRegistry)),
|
|
196
196
|
toast: wrap((ctx) => (0, cap_providers_js_1.buildToastProvider)(services.toastService, ctx)),
|
|
197
197
|
integrations: wrap(() => (0, cap_providers_js_1.buildIntegrationsProvider)(services.addonRegistry, services.eventBus, services.loggingService, services.capabilityRegistry)),
|
|
198
|
-
nodes: wrap(() => (0, cap_providers_js_1.buildNodesProvider)(services.agentRegistry, services.moleculer, services.addonRegistry, (0, cap_providers_js_1.createNodeRootPackageLookup)(services.moleculer, services.serverUpdateService))),
|
|
198
|
+
nodes: wrap(() => (0, cap_providers_js_1.buildNodesProvider)(services.agentRegistry, services.moleculer, services.addonRegistry, (0, cap_providers_js_1.createNodeRootPackageLookup)(services.moleculer, services.serverUpdateService), services.loggingService.createLogger('nodes'))),
|
|
199
199
|
// server-management — runtime-updatable root package (phase 1: hub only).
|
|
200
200
|
'server-management': wrap(() => (0, server_management_provider_js_1.buildServerManagementProvider)(services.serverUpdateService)),
|
|
201
201
|
addons: wrap((ctx) => (0, cap_providers_js_1.buildAddonsProvider)(services.addonRegistry, services.addonPackageService, services.loggingService, services.moleculer, services.configService, ctx)),
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Boot-time framework-swap job resume + health confirm.
|
|
4
|
+
*
|
|
5
|
+
* After a framework swap reboot, `post-boot.service.ts` calls this once the
|
|
6
|
+
* hub is healthy. It:
|
|
7
|
+
* 1. Reads `.framework-swap-confirm.json` (written by the launcher on apply).
|
|
8
|
+
* 2. Marks the journal task `applied` → `done`, then finalises the job →
|
|
9
|
+
* `completed`.
|
|
10
|
+
* 3. Calls `confirmFrameworkSwapHealthy` to delete the confirm marker +
|
|
11
|
+
* backup dirs (disarms the crash-loop rollback).
|
|
12
|
+
*
|
|
13
|
+
* Best-effort: a missing/corrupt journal is tolerated — `confirmFrameworkSwapHealthy`
|
|
14
|
+
* is still called so the rollback is always disarmed when the hub boots healthy.
|
|
15
|
+
*/
|
|
16
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
17
|
+
if (k2 === undefined) k2 = k;
|
|
18
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
19
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
20
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
21
|
+
}
|
|
22
|
+
Object.defineProperty(o, k2, desc);
|
|
23
|
+
}) : (function(o, m, k, k2) {
|
|
24
|
+
if (k2 === undefined) k2 = k;
|
|
25
|
+
o[k2] = m[k];
|
|
26
|
+
}));
|
|
27
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
28
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
29
|
+
}) : function(o, v) {
|
|
30
|
+
o["default"] = v;
|
|
31
|
+
});
|
|
32
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
33
|
+
var ownKeys = function(o) {
|
|
34
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
35
|
+
var ar = [];
|
|
36
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
37
|
+
return ar;
|
|
38
|
+
};
|
|
39
|
+
return ownKeys(o);
|
|
40
|
+
};
|
|
41
|
+
return function (mod) {
|
|
42
|
+
if (mod && mod.__esModule) return mod;
|
|
43
|
+
var result = {};
|
|
44
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
45
|
+
__setModuleDefault(result, mod);
|
|
46
|
+
return result;
|
|
47
|
+
};
|
|
48
|
+
})();
|
|
49
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
50
|
+
exports.resumeFrameworkSwapJob = resumeFrameworkSwapJob;
|
|
51
|
+
const fs = __importStar(require("node:fs"));
|
|
52
|
+
const path = __importStar(require("node:path"));
|
|
53
|
+
const types_1 = require("@camstack/types");
|
|
54
|
+
const system_1 = require("@camstack/system");
|
|
55
|
+
const launcher_framework_swap_js_1 = require("../launcher-framework-swap.js");
|
|
56
|
+
const lifecycle_journal_path_js_1 = require("../lifecycle-journal-path.js");
|
|
57
|
+
const SWAP_CONFIRM_FILE = '.framework-swap-confirm.json';
|
|
58
|
+
/**
|
|
59
|
+
* Resume a framework-swap journal job to `done`/`completed` and confirm the
|
|
60
|
+
* hub is healthy (deletes the confirm marker + backups).
|
|
61
|
+
*
|
|
62
|
+
* @returns `{ resumed: false }` when no confirm marker exists.
|
|
63
|
+
* `{ resumed: true, jobId }` when the marker was found and processed.
|
|
64
|
+
* Never throws — errors are swallowed to avoid crashing the post-boot path.
|
|
65
|
+
*/
|
|
66
|
+
async function resumeFrameworkSwapJob(dataDir) {
|
|
67
|
+
try {
|
|
68
|
+
const confirmMarker = readConfirmMarker(dataDir);
|
|
69
|
+
if (confirmMarker === null) {
|
|
70
|
+
return { resumed: false };
|
|
71
|
+
}
|
|
72
|
+
const { jobId, taskId } = confirmMarker;
|
|
73
|
+
let journalPatched = false;
|
|
74
|
+
try {
|
|
75
|
+
const journal = new system_1.JobJournal((0, lifecycle_journal_path_js_1.lifecycleJobsDir)(dataDir));
|
|
76
|
+
const job = journal.getJob(jobId);
|
|
77
|
+
if (job !== null) {
|
|
78
|
+
const task = job.tasks.find((t) => t.taskId === taskId);
|
|
79
|
+
if (task !== undefined && task.phase === 'applied') {
|
|
80
|
+
journal.patchTask(jobId, taskId, { phase: 'done', finishedAtMs: Date.now() });
|
|
81
|
+
// Single-task framework job: if all tasks are now terminal and none
|
|
82
|
+
// failed, mark the job completed (mirrors the engine's finalize logic).
|
|
83
|
+
const updatedJob = journal.getJob(jobId);
|
|
84
|
+
if (updatedJob !== null) {
|
|
85
|
+
const allTerminal = updatedJob.tasks.every((t) => t.phase === 'done' || t.phase === 'failed' || t.phase === 'skipped');
|
|
86
|
+
const anyFailed = updatedJob.tasks.some((t) => t.phase === 'failed');
|
|
87
|
+
if (allTerminal && !anyFailed) {
|
|
88
|
+
journal.setJobState(jobId, 'completed');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
journalPatched = true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Journal is missing or corrupt — still clean up the confirm marker so
|
|
97
|
+
// the rollback is disarmed on a healthy hub boot.
|
|
98
|
+
}
|
|
99
|
+
(0, launcher_framework_swap_js_1.confirmFrameworkSwapHealthy)(dataDir);
|
|
100
|
+
return journalPatched ? { resumed: true, jobId } : { resumed: false };
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Never crash the caller (post-boot service).
|
|
104
|
+
return { resumed: false };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** Read and shape-check the confirm marker. Returns null on any error. */
|
|
108
|
+
function readConfirmMarker(dataDir) {
|
|
109
|
+
try {
|
|
110
|
+
const raw = JSON.parse(fs.readFileSync(path.join(dataDir, SWAP_CONFIRM_FILE), 'utf-8'));
|
|
111
|
+
const parsed = types_1.frameworkSwapConfirmSchema.safeParse(raw);
|
|
112
|
+
if (!parsed.success)
|
|
113
|
+
return null;
|
|
114
|
+
return { jobId: parsed.data.jobId, taskId: parsed.data.taskId };
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Hub bootstrap package selection.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from `launcher.ts` so it can be unit-tested: the launcher
|
|
6
|
+
* self-executes `launch()` at import time, so nothing inside it is reachable
|
|
7
|
+
* from a spec. Same shape as `framework-nodepath.ts`.
|
|
8
|
+
*
|
|
9
|
+
* The selection MUST stay in lockstep with
|
|
10
|
+
* `AddonInstaller.deriveBootstrapList` in `@camstack/system` — that one reads
|
|
11
|
+
* the same `@camstack/server` manifest through `require.resolve`, this one
|
|
12
|
+
* reads the launcher's own `package.json` by absolute path (the slim image
|
|
13
|
+
* strips the `@camstack/*` symlinks, so `require.resolve` yields an empty list
|
|
14
|
+
* at launcher import time). Two derivations of the same list is exactly how
|
|
15
|
+
* `@camstack/addon-agent-ui` ended up installed on the hub: the installer's
|
|
16
|
+
* copy honoured `camstack.agentOnlyBootstrapAddons`, this one did not, and the
|
|
17
|
+
* hub is the side that actually boots through here.
|
|
18
|
+
*/
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.selectHubBootstrapPackages = selectHubBootstrapPackages;
|
|
21
|
+
/**
|
|
22
|
+
* The packages a HUB-role node bootstrap-installs: every `@camstack/addon-*`
|
|
23
|
+
* runtime dependency plus `@camstack/system` (the carrier of the required
|
|
24
|
+
* infrastructure builtins — storage-provider / settings / logging — without
|
|
25
|
+
* which boot aborts).
|
|
26
|
+
*
|
|
27
|
+
* `camstack.agentOnlyBootstrapAddons` is subtracted. Those addons are declared
|
|
28
|
+
* as runtime dependencies so the published `@camstack/server` closure CARRIES
|
|
29
|
+
* them for agent-role nodes to seed, but they are `execution.placement:
|
|
30
|
+
* 'agent-only'` and the hub must never install them.
|
|
31
|
+
*/
|
|
32
|
+
function selectHubBootstrapPackages(pkg) {
|
|
33
|
+
const agentOnly = new Set(pkg.camstack?.agentOnlyBootstrapAddons ?? []);
|
|
34
|
+
return Object.keys(pkg.dependencies ?? {}).filter((name) => !agentOnly.has(name) && (name.startsWith('@camstack/addon-') || name === '@camstack/system'));
|
|
35
|
+
}
|