@camstack/server 1.2.93 → 1.2.95
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/addon-scan.js +131 -0
- package/dist/agent/main.js +35 -40
- package/dist/api/core/cap-providers.js +53 -9
- package/dist/api/trpc/generated-cap-mounts.js +3 -1
- package/dist/api/trpc/generated-cap-routers.js +471 -309
- package/dist/core/addon/addon-package.service.js +17 -2
- package/package.json +14 -14
- package/dist/agent/builtins-seed.js +0 -89
- package/dist/api/addons-custom.router.js +0 -99
- package/dist/api/core/bulk-update-coordinator.js +0 -229
- package/dist/api/core/settings-backend.router.js +0 -121
- package/dist/boot/resume-framework-swap.js +0 -119
- package/dist/core/addon/framework-live-sync.js +0 -344
- package/dist/launcher-framework-swap.js +0 -408
- package/dist/request-framework-swap.js +0 -41
- package/dist/server-root/boot-plan.js +0 -110
- package/dist/server-root/semver-compare.js +0 -45
- package/dist/server-root/server-root-state.js +0 -220
- package/dist/server-root/workspace-detect.js +0 -73
|
@@ -1032,6 +1032,15 @@ class AddonPackageService {
|
|
|
1032
1032
|
* for use by the lifecycle job engine (TarballFetcher signature). The
|
|
1033
1033
|
* `signal` is forwarded to the underlying fetch calls so the AbortController
|
|
1034
1034
|
* timeout wired in LifecycleJobEngine fires correctly.
|
|
1035
|
+
*
|
|
1036
|
+
* `version` may be an exact semver OR a dist-tag. It looked up only
|
|
1037
|
+
* `versions[version]` until 2026-08-11, so every caller that passed the
|
|
1038
|
+
* DEFAULT — `updatePackage` resolves an absent input to `'latest'` — failed
|
|
1039
|
+
* with `no tarball url`, because `latest` is a dist-tag key and never a
|
|
1040
|
+
* `versions` key. The Addons page hid this by always sending the resolved
|
|
1041
|
+
* `latestVersion` from `listUpdates`; the API path had no such caller and
|
|
1042
|
+
* could not update anything. Mirrors `AddonInstaller.downloadNpmTarball`.
|
|
1043
|
+
* Ranges (`^1.2.0`) are still not supported — resolve those upstream.
|
|
1035
1044
|
*/
|
|
1036
1045
|
async fetchAddonTarball(name, version, signal) {
|
|
1037
1046
|
const registry = process.env['CAMSTACK_NPM_REGISTRY'];
|
|
@@ -1041,9 +1050,15 @@ class AddonPackageService {
|
|
|
1041
1050
|
if (!metaRes.ok)
|
|
1042
1051
|
throw new Error(`registry GET ${metaUrl} → ${metaRes.status}`);
|
|
1043
1052
|
const meta = (await metaRes.json());
|
|
1044
|
-
const
|
|
1053
|
+
const versions = meta.versions ?? {};
|
|
1054
|
+
const distTags = meta['dist-tags'] ?? {};
|
|
1055
|
+
const resolvedVersion = versions[version] ? version : distTags[version];
|
|
1056
|
+
if (resolvedVersion === undefined) {
|
|
1057
|
+
throw new Error(`no version resolved for ${name}@${version}`);
|
|
1058
|
+
}
|
|
1059
|
+
const tarballUrl = versions[resolvedVersion]?.dist?.tarball;
|
|
1045
1060
|
if (typeof tarballUrl !== 'string') {
|
|
1046
|
-
throw new Error(`no tarball url for ${name}@${
|
|
1061
|
+
throw new Error(`no tarball url for ${name}@${resolvedVersion}`);
|
|
1047
1062
|
}
|
|
1048
1063
|
const tarRes = await fetch(tarballUrl, { signal });
|
|
1049
1064
|
if (!tarRes.ok)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.95",
|
|
4
4
|
"private": false,
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -33,19 +33,19 @@
|
|
|
33
33
|
]
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@camstack/addon-admin-ui": "
|
|
37
|
-
"@camstack/addon-agent-ui": "
|
|
38
|
-
"@camstack/addon-auth": "
|
|
39
|
-
"@camstack/addon-decoder-nodeav": "
|
|
40
|
-
"@camstack/addon-notifiers": "
|
|
41
|
-
"@camstack/addon-pipeline": "
|
|
42
|
-
"@camstack/addon-pipeline-orchestrator": "
|
|
43
|
-
"@camstack/addon-post-analysis": "
|
|
44
|
-
"@camstack/sdk": "
|
|
45
|
-
"@camstack/shm-ring": "
|
|
46
|
-
"@camstack/system": "
|
|
47
|
-
"@camstack/types": "
|
|
48
|
-
"@camstack/ui-library": "
|
|
36
|
+
"@camstack/addon-admin-ui": "1.2.48",
|
|
37
|
+
"@camstack/addon-agent-ui": "1.2.13",
|
|
38
|
+
"@camstack/addon-auth": "1.2.14",
|
|
39
|
+
"@camstack/addon-decoder-nodeav": "1.2.12",
|
|
40
|
+
"@camstack/addon-notifiers": "1.2.17",
|
|
41
|
+
"@camstack/addon-pipeline": "1.2.64",
|
|
42
|
+
"@camstack/addon-pipeline-orchestrator": "1.2.45",
|
|
43
|
+
"@camstack/addon-post-analysis": "1.2.64",
|
|
44
|
+
"@camstack/sdk": "1.2.14",
|
|
45
|
+
"@camstack/shm-ring": "1.1.12",
|
|
46
|
+
"@camstack/system": "1.2.79",
|
|
47
|
+
"@camstack/types": "1.2.59",
|
|
48
|
+
"@camstack/ui-library": "1.2.41",
|
|
49
49
|
"@fastify/compress": "^9.0.0",
|
|
50
50
|
"@fastify/cookie": "^11.0.2",
|
|
51
51
|
"@fastify/cors": "^11.2.0",
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
-
if (k2 === undefined) k2 = k;
|
|
4
|
-
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
-
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
-
}
|
|
8
|
-
Object.defineProperty(o, k2, desc);
|
|
9
|
-
}) : (function(o, m, k, k2) {
|
|
10
|
-
if (k2 === undefined) k2 = k;
|
|
11
|
-
o[k2] = m[k];
|
|
12
|
-
}));
|
|
13
|
-
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
-
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
-
}) : function(o, v) {
|
|
16
|
-
o["default"] = v;
|
|
17
|
-
});
|
|
18
|
-
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
-
var ownKeys = function(o) {
|
|
20
|
-
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
-
var ar = [];
|
|
22
|
-
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
-
return ar;
|
|
24
|
-
};
|
|
25
|
-
return ownKeys(o);
|
|
26
|
-
};
|
|
27
|
-
return function (mod) {
|
|
28
|
-
if (mod && mod.__esModule) return mod;
|
|
29
|
-
var result = {};
|
|
30
|
-
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
-
__setModuleDefault(result, mod);
|
|
32
|
-
return result;
|
|
33
|
-
};
|
|
34
|
-
})();
|
|
35
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.seedBuiltinsFromClosure = seedBuiltinsFromClosure;
|
|
37
|
-
/**
|
|
38
|
-
* Self-heal the agent's builtins package before the infra scan.
|
|
39
|
-
*
|
|
40
|
-
* `bootCoreAddons` scans `config.addonsDir` and nothing else, the image seed
|
|
41
|
-
* ships no `@camstack/system`, and the running closure's copy is NOT loaded
|
|
42
|
-
* as an addon — so an agent whose `/data/addons/@camstack/system` is missing
|
|
43
|
-
* (greenfield container, an operator cleanup, a failed deploy) boots with
|
|
44
|
-
* zero infrastructure. Before D87 that produced a silently gutted node; with
|
|
45
|
-
* the D87 guard it produces a crash-loop. Both are wrong answers to a
|
|
46
|
-
* question the node can answer itself: the closure it is RUNNING carries the
|
|
47
|
-
* exact builtins package it needs.
|
|
48
|
-
*
|
|
49
|
-
* The copy is files-list shaped — `package.json` + `dist` — and NEVER
|
|
50
|
-
* `node_modules`: a nested `node_modules/@camstack/*` carries its own
|
|
51
|
-
* `package.json`s, the addon scan discovers those too, and the same addon
|
|
52
|
-
* initializing twice is a boot abort (measured live 2026-08-08, twice, two
|
|
53
|
-
* different vehicles).
|
|
54
|
-
*/
|
|
55
|
-
const fs = __importStar(require("node:fs"));
|
|
56
|
-
const path = __importStar(require("node:path"));
|
|
57
|
-
function seedBuiltinsFromClosure(addonsDir, log = console.log, resolveClosurePkg = () => require.resolve('@camstack/system/package.json')) {
|
|
58
|
-
const target = path.join(addonsDir, '@camstack', 'system');
|
|
59
|
-
if (fs.existsSync(path.join(target, 'package.json')))
|
|
60
|
-
return 'present';
|
|
61
|
-
let closurePkgJson;
|
|
62
|
-
try {
|
|
63
|
-
closurePkgJson = resolveClosurePkg();
|
|
64
|
-
}
|
|
65
|
-
catch {
|
|
66
|
-
log('[Agent] builtins seed: @camstack/system not resolvable from the closure — cannot self-heal');
|
|
67
|
-
return 'unavailable';
|
|
68
|
-
}
|
|
69
|
-
const closureRoot = path.dirname(closurePkgJson);
|
|
70
|
-
const closureDist = path.join(closureRoot, 'dist');
|
|
71
|
-
if (!fs.existsSync(closureDist)) {
|
|
72
|
-
log(`[Agent] builtins seed: closure copy at ${closureRoot} has no dist — cannot self-heal`);
|
|
73
|
-
return 'unavailable';
|
|
74
|
-
}
|
|
75
|
-
// The WHOLE package, node_modules included. A deps-free copy was tried
|
|
76
|
-
// first and failed live (2026-08-08): the builtins' dist requires native
|
|
77
|
-
// deps (better-sqlite3) that resolve relative to the COPY, so the addon
|
|
78
|
-
// scan logged "Failed to scan" and the guard aborted with "no addon
|
|
79
|
-
// under /data/addons" — while the log right above it said the seed had
|
|
80
|
-
// run. Nested `node_modules` are safe: the scan reads one level of
|
|
81
|
-
// `addonsDir/@scope/*`, never inside a package. (The double-registration
|
|
82
|
-
// the deps-free copy was guarding against was actually the per-capability
|
|
83
|
-
// init loop — fixed by `planInfraBoot` — not nested discovery.)
|
|
84
|
-
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
85
|
-
fs.cpSync(closureRoot, target, { recursive: true });
|
|
86
|
-
log(`[Agent] builtins seed: @camstack/system was missing under ${addonsDir} — ` +
|
|
87
|
-
`seeded the full package from the running closure (${closureRoot})`);
|
|
88
|
-
return 'seeded';
|
|
89
|
-
}
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.createAddonsCustomProcedures = createAddonsCustomProcedures;
|
|
4
|
-
/**
|
|
5
|
-
* `api.addons.custom` — generic dispatcher for addon-defined custom actions.
|
|
6
|
-
*
|
|
7
|
-
* Task 7.2 of the device-proxy redesign. Addons declare a catalog of
|
|
8
|
-
* custom actions at boot via `AddonInitResult.customActions` + a single
|
|
9
|
-
* `handleCustomAction(action, input)` handler. The catalog is registered
|
|
10
|
-
* with a per-process `CustomActionRegistry` (Task 7.1). This endpoint is
|
|
11
|
-
* the single tRPC entry point that resolves an `(addonId, action)` pair,
|
|
12
|
-
* validates input + output against the action's Zod schemas, enforces the
|
|
13
|
-
* action's declared auth level, and dispatches to the addon handler.
|
|
14
|
-
*
|
|
15
|
-
* The factory returns a record of procedures (not a router) so the caller
|
|
16
|
-
* can spread it into the existing `addons` namespace:
|
|
17
|
-
*
|
|
18
|
-
* trpcRouter({
|
|
19
|
-
* ...existingAddonsProcedures,
|
|
20
|
-
* ...createAddonsCustomProcedures({ getCustomActionRegistry: ... }),
|
|
21
|
-
* })
|
|
22
|
-
*
|
|
23
|
-
* This avoids `mergeRouters` (which requires sharing the `t` instance
|
|
24
|
-
* across modules) while still mounting the procedure at `api.addons.custom`.
|
|
25
|
-
*/
|
|
26
|
-
const zod_1 = require("zod");
|
|
27
|
-
const server_1 = require("@trpc/server");
|
|
28
|
-
const trpc_middleware_js_1 = require("./trpc/trpc.middleware.js");
|
|
29
|
-
/**
|
|
30
|
-
* Build the procedure record for the `custom` endpoint.
|
|
31
|
-
*
|
|
32
|
-
* The OUTER procedure is `protectedProcedure` — every caller must be
|
|
33
|
-
* authenticated. The INNER per-action auth declared in `spec.auth` is
|
|
34
|
-
* enforced manually by `ensureAuth` because the auth level is not known
|
|
35
|
-
* until after the registry lookup.
|
|
36
|
-
*/
|
|
37
|
-
function createAddonsCustomProcedures(deps) {
|
|
38
|
-
return {
|
|
39
|
-
custom: trpc_middleware_js_1.protectedProcedure
|
|
40
|
-
.input(zod_1.z.object({
|
|
41
|
-
addonId: zod_1.z.string().min(1),
|
|
42
|
-
action: zod_1.z.string().min(1),
|
|
43
|
-
input: zod_1.z.unknown(),
|
|
44
|
-
}))
|
|
45
|
-
.output(zod_1.z.unknown())
|
|
46
|
-
.mutation(async ({ input, ctx }) => {
|
|
47
|
-
const registry = deps.getCustomActionRegistry();
|
|
48
|
-
const entry = registry.resolve(input.addonId, input.action);
|
|
49
|
-
if (!entry) {
|
|
50
|
-
throw new server_1.TRPCError({
|
|
51
|
-
code: 'NOT_FOUND',
|
|
52
|
-
message: `addon '${input.addonId}' has no custom action '${input.action}'`,
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
// Per-action authorization. The outer procedure already requires
|
|
56
|
-
// authentication; here we additionally enforce the declared role
|
|
57
|
-
// when it's stricter than 'protected'.
|
|
58
|
-
ensureAuth(ctx, entry.spec.auth);
|
|
59
|
-
// Validate input against the action's declared Zod schema.
|
|
60
|
-
const parsedInput = entry.spec.input.parse(input.input);
|
|
61
|
-
// Dispatch through the addon handler, forwarding the authenticated
|
|
62
|
-
// caller when the action declares `caller: 'required'`. The caller is
|
|
63
|
-
// derived server-side from the request principal (never trusted from
|
|
64
|
-
// input); `ctx.user` is guaranteed present because `ensureAuth` above
|
|
65
|
-
// rejects unauthenticated callers for any non-public action, and the
|
|
66
|
-
// outer `protectedProcedure` rejects them for public ones.
|
|
67
|
-
const caller = entry.spec.caller === 'required' && ctx.user
|
|
68
|
-
? { userId: ctx.user.id, isAdmin: ctx.user.isAdmin }
|
|
69
|
-
: undefined;
|
|
70
|
-
const result = await entry.handler(parsedInput, caller);
|
|
71
|
-
// Validate the addon's output. Crash-early on misbehaving addons.
|
|
72
|
-
return entry.spec.output.parse(result);
|
|
73
|
-
}),
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
/**
|
|
77
|
-
* Enforce the action's declared auth level.
|
|
78
|
-
*
|
|
79
|
-
* Mirrors the role checks performed by `protectedProcedure` and
|
|
80
|
-
* `adminProcedure` in trpc.middleware.ts:
|
|
81
|
-
* - public: no auth
|
|
82
|
-
* - protected: any authenticated user
|
|
83
|
-
* - admin: isAdmin only (scoped tokens bounce)
|
|
84
|
-
*/
|
|
85
|
-
function ensureAuth(ctx, level) {
|
|
86
|
-
if (level === 'public')
|
|
87
|
-
return;
|
|
88
|
-
if (!ctx.user) {
|
|
89
|
-
throw new server_1.TRPCError({ code: 'UNAUTHORIZED' });
|
|
90
|
-
}
|
|
91
|
-
if (level === 'protected')
|
|
92
|
-
return;
|
|
93
|
-
if (level === 'admin') {
|
|
94
|
-
if (!ctx.user.isAdmin) {
|
|
95
|
-
throw new server_1.TRPCError({ code: 'FORBIDDEN', message: 'custom action requires admin' });
|
|
96
|
-
}
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
@@ -1,229 +0,0 @@
|
|
|
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,121 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.createSettingsBackendRouter = createSettingsBackendRouter;
|
|
4
|
-
/**
|
|
5
|
-
* Settings backend router — tRPC proxy for ISettingsBackend operations.
|
|
6
|
-
*
|
|
7
|
-
* Exposes the core collection-based operations (get, set, query, insert,
|
|
8
|
-
* update, delete, count, isEmpty) so forked worker addons can use
|
|
9
|
-
* context.settingsBackend via tRPC instead of requiring in-process access
|
|
10
|
-
* to the SQLite database.
|
|
11
|
-
*
|
|
12
|
-
* Introduced for Task 11 — TrpcSettingsBackend for forked workers.
|
|
13
|
-
*/
|
|
14
|
-
const zod_1 = require("zod");
|
|
15
|
-
const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
|
|
16
|
-
// ---------------------------------------------------------------------------
|
|
17
|
-
// Zod schemas
|
|
18
|
-
// ---------------------------------------------------------------------------
|
|
19
|
-
const CollectionKeySchema = zod_1.z.object({
|
|
20
|
-
collection: zod_1.z.string(),
|
|
21
|
-
key: zod_1.z.string(),
|
|
22
|
-
});
|
|
23
|
-
const SetValueSchema = zod_1.z.object({
|
|
24
|
-
collection: zod_1.z.string(),
|
|
25
|
-
key: zod_1.z.string(),
|
|
26
|
-
value: zod_1.z.unknown(),
|
|
27
|
-
});
|
|
28
|
-
const QueryFilterSchema = zod_1.z
|
|
29
|
-
.object({
|
|
30
|
-
where: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).optional(),
|
|
31
|
-
whereIn: zod_1.z.record(zod_1.z.string(), zod_1.z.array(zod_1.z.unknown())).optional(),
|
|
32
|
-
whereBetween: zod_1.z.record(zod_1.z.string(), zod_1.z.tuple([zod_1.z.unknown(), zod_1.z.unknown()])).optional(),
|
|
33
|
-
orderBy: zod_1.z
|
|
34
|
-
.object({
|
|
35
|
-
field: zod_1.z.string(),
|
|
36
|
-
direction: zod_1.z.enum(['asc', 'desc']),
|
|
37
|
-
})
|
|
38
|
-
.optional(),
|
|
39
|
-
limit: zod_1.z.number().optional(),
|
|
40
|
-
offset: zod_1.z.number().optional(),
|
|
41
|
-
})
|
|
42
|
-
.optional();
|
|
43
|
-
const QueryInputSchema = zod_1.z.object({
|
|
44
|
-
collection: zod_1.z.string(),
|
|
45
|
-
filter: QueryFilterSchema,
|
|
46
|
-
});
|
|
47
|
-
const InsertInputSchema = zod_1.z.object({
|
|
48
|
-
collection: zod_1.z.string(),
|
|
49
|
-
record: zod_1.z.object({
|
|
50
|
-
id: zod_1.z.string(),
|
|
51
|
-
data: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()),
|
|
52
|
-
}),
|
|
53
|
-
});
|
|
54
|
-
const UpdateInputSchema = zod_1.z.object({
|
|
55
|
-
collection: zod_1.z.string(),
|
|
56
|
-
id: zod_1.z.string(),
|
|
57
|
-
data: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()),
|
|
58
|
-
});
|
|
59
|
-
const CountInputSchema = zod_1.z.object({
|
|
60
|
-
collection: zod_1.z.string(),
|
|
61
|
-
filter: QueryFilterSchema,
|
|
62
|
-
});
|
|
63
|
-
const IsEmptyInputSchema = zod_1.z.object({
|
|
64
|
-
collection: zod_1.z.string(),
|
|
65
|
-
});
|
|
66
|
-
// ---------------------------------------------------------------------------
|
|
67
|
-
// Router factory
|
|
68
|
-
// ---------------------------------------------------------------------------
|
|
69
|
-
function createSettingsBackendRouter(getBackend) {
|
|
70
|
-
const requireBackend = () => {
|
|
71
|
-
const backend = getBackend();
|
|
72
|
-
if (!backend) {
|
|
73
|
-
throw new Error('Settings backend not available — settings-store addon may not be initialized yet');
|
|
74
|
-
}
|
|
75
|
-
return backend;
|
|
76
|
-
};
|
|
77
|
-
return (0, trpc_middleware_js_1.trpcRouter)({
|
|
78
|
-
get: trpc_middleware_js_1.protectedProcedure.input(CollectionKeySchema).query(async ({ input }) => {
|
|
79
|
-
const result = await requireBackend().get(input);
|
|
80
|
-
return { value: result };
|
|
81
|
-
}),
|
|
82
|
-
set: trpc_middleware_js_1.protectedProcedure.input(SetValueSchema).mutation(async ({ input }) => {
|
|
83
|
-
await requireBackend().set({
|
|
84
|
-
collection: input.collection,
|
|
85
|
-
key: input.key,
|
|
86
|
-
value: input.value,
|
|
87
|
-
});
|
|
88
|
-
return { success: true };
|
|
89
|
-
}),
|
|
90
|
-
query: trpc_middleware_js_1.protectedProcedure.input(QueryInputSchema).query(async ({ input }) => {
|
|
91
|
-
const records = await requireBackend().query({
|
|
92
|
-
collection: input.collection,
|
|
93
|
-
filter: input.filter ?? undefined,
|
|
94
|
-
});
|
|
95
|
-
return { records: records.map((r) => ({ id: r.id, data: r.data })) };
|
|
96
|
-
}),
|
|
97
|
-
insert: trpc_middleware_js_1.protectedProcedure.input(InsertInputSchema).mutation(async ({ input }) => {
|
|
98
|
-
await requireBackend().insert(input);
|
|
99
|
-
return { success: true };
|
|
100
|
-
}),
|
|
101
|
-
update: trpc_middleware_js_1.protectedProcedure.input(UpdateInputSchema).mutation(async ({ input }) => {
|
|
102
|
-
await requireBackend().update(input);
|
|
103
|
-
return { success: true };
|
|
104
|
-
}),
|
|
105
|
-
delete: trpc_middleware_js_1.protectedProcedure.input(CollectionKeySchema).mutation(async ({ input }) => {
|
|
106
|
-
await requireBackend().delete(input);
|
|
107
|
-
return { success: true };
|
|
108
|
-
}),
|
|
109
|
-
count: trpc_middleware_js_1.protectedProcedure.input(CountInputSchema).query(async ({ input }) => {
|
|
110
|
-
const result = await requireBackend().count({
|
|
111
|
-
collection: input.collection,
|
|
112
|
-
filter: input.filter ?? undefined,
|
|
113
|
-
});
|
|
114
|
-
return { count: result };
|
|
115
|
-
}),
|
|
116
|
-
isEmpty: trpc_middleware_js_1.protectedProcedure.input(IsEmptyInputSchema).query(async ({ input }) => {
|
|
117
|
-
const result = await requireBackend().isEmpty(input);
|
|
118
|
-
return { empty: result };
|
|
119
|
-
}),
|
|
120
|
-
});
|
|
121
|
-
}
|