@camstack/server 1.0.4 → 1.0.6
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/core/cap-providers.js +38 -42
- package/dist/api/core/lifecycle-job-runner.js +157 -0
- package/dist/api/trpc/generated-cap-routers.js +32 -32
- package/dist/api/trpc/trpc.router.js +1 -1
- package/dist/boot/post-boot.service.js +25 -0
- package/dist/boot/reconcile-lifecycle-jobs.js +29 -0
- package/dist/boot/resume-framework-swap.js +119 -0
- package/dist/core/addon/addon-package.service.js +446 -28
- package/dist/core/addon/addon-registry.service.js +16 -2
- package/dist/core/agent/agent-registry.service.js +43 -1
- package/dist/core/lifecycle/lifecycle-runner.singleton.js +40 -0
- package/dist/framework-nodepath.js +49 -0
- package/dist/launcher-framework-swap.js +408 -0
- package/dist/launcher.js +92 -15
- package/dist/lifecycle-journal-path.js +41 -0
- package/dist/manual-boot.js +93 -0
- package/dist/request-framework-swap.js +41 -0
- package/package.json +1 -1
- package/dist/api/core/bulk-update-coordinator.js +0 -229
|
@@ -35,6 +35,9 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.AddonPackageService = exports.SYSTEM_PACKAGE = void 0;
|
|
37
37
|
exports.isFrameworkPackage = isFrameworkPackage;
|
|
38
|
+
exports.extractTgzStripped = extractTgzStripped;
|
|
39
|
+
exports.sweepStaleFrameworkBackups = sweepStaleFrameworkBackups;
|
|
40
|
+
exports.swapInFrameworkPackage = swapInFrameworkPackage;
|
|
38
41
|
const fs = __importStar(require("node:fs"));
|
|
39
42
|
const path = __importStar(require("node:path"));
|
|
40
43
|
const os = __importStar(require("node:os"));
|
|
@@ -810,6 +813,112 @@ class AddonPackageService {
|
|
|
810
813
|
return { success: false, version: '', requiresRestart: false, error: msg };
|
|
811
814
|
}
|
|
812
815
|
}
|
|
816
|
+
/**
|
|
817
|
+
* Apply a staged addon update without running npm — the fast swap-from-staged
|
|
818
|
+
* path. Mirrors the addon branch of `updatePackage` exactly, but calls
|
|
819
|
+
* `addonInstaller.applyUpdateFromStaged(name, version, stagedPath)` instead
|
|
820
|
+
* of `applyUpdate`/`installFromNpm`.
|
|
821
|
+
*
|
|
822
|
+
* The staged directory must already be validated and unpacked by the caller
|
|
823
|
+
* (e.g. the upload handler that previously called `installFromTgz`). This
|
|
824
|
+
* method performs the atomic swap, restarts the addon, and clears the backup
|
|
825
|
+
* on success — identical lifecycle to `updatePackage` addon branch.
|
|
826
|
+
*/
|
|
827
|
+
async applyStagedAddonUpdate(name, version, stagedPath) {
|
|
828
|
+
if (!this.isAllowedPackage(name)) {
|
|
829
|
+
return {
|
|
830
|
+
success: false,
|
|
831
|
+
version: '',
|
|
832
|
+
requiresRestart: false,
|
|
833
|
+
error: `Package "${name}" is not an allowed @camstack/* package`,
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
const category = this.categorize(name);
|
|
837
|
+
this.logger.info('Applying staged addon update', { meta: { name, version, category } });
|
|
838
|
+
try {
|
|
839
|
+
if (category !== 'addon') {
|
|
840
|
+
return {
|
|
841
|
+
success: false,
|
|
842
|
+
version: '',
|
|
843
|
+
requiresRestart: false,
|
|
844
|
+
error: `applyStagedAddonUpdate only supports addon packages; "${name}" categorized as "${category}"`,
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
this.requireInstaller();
|
|
848
|
+
const addonInstaller = this.installer;
|
|
849
|
+
const previousVersion = this.getInstalledPackageVersion(name);
|
|
850
|
+
const r = await addonInstaller.applyUpdateFromStaged(name, version, stagedPath);
|
|
851
|
+
const updatedVersion = r.version;
|
|
852
|
+
// Update version in registry so UI reflects the change immediately
|
|
853
|
+
this.addonRegistry.refreshPackageVersion(name, updatedVersion);
|
|
854
|
+
// Emit addon.updated lifecycle event
|
|
855
|
+
this.addonRegistry.emitUpdateEvent(name, previousVersion, updatedVersion);
|
|
856
|
+
this.logger.info('Staged addon swap complete, triggering hot-reload', {
|
|
857
|
+
meta: { name, updatedVersion },
|
|
858
|
+
});
|
|
859
|
+
const addonId = this.extractAddonId(name);
|
|
860
|
+
if (addonId) {
|
|
861
|
+
try {
|
|
862
|
+
await this.addonRegistry.restartAddon(addonId);
|
|
863
|
+
this.logger.info('Addon restarted after staged update', { tags: { addonId } });
|
|
864
|
+
// Restart succeeded — drop the backup
|
|
865
|
+
addonInstaller.clearBackup(name);
|
|
866
|
+
}
|
|
867
|
+
catch (reloadError) {
|
|
868
|
+
const msg = (0, types_1.errMsg)(reloadError);
|
|
869
|
+
this.logger.warn('Hot-reload failed for addon — backup retained for rollback', {
|
|
870
|
+
tags: { addonId },
|
|
871
|
+
meta: { error: msg, backupDir: r.backupDir },
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
// Clear update cache so next check reflects new state
|
|
876
|
+
this.cachedUpdates = null;
|
|
877
|
+
this.sendUpdateNotification(name, updatedVersion);
|
|
878
|
+
return { success: true, version: updatedVersion, requiresRestart: false };
|
|
879
|
+
}
|
|
880
|
+
catch (error) {
|
|
881
|
+
const msg = (0, types_1.errMsg)(error);
|
|
882
|
+
this.logger.error('Failed to apply staged addon update', { meta: { name, error: msg } });
|
|
883
|
+
return { success: false, version: '', requiresRestart: false, error: msg };
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Download the npm tarball for `name@version` as a Buffer.
|
|
888
|
+
*
|
|
889
|
+
* Wraps the module-level `httpsDownloadTarball` for use by the
|
|
890
|
+
* lifecycle job engine (TarballFetcher signature). The `signal` is
|
|
891
|
+
* forwarded to the underlying fetch calls so the AbortController
|
|
892
|
+
* timeout wired in LifecycleJobEngine fires correctly.
|
|
893
|
+
*/
|
|
894
|
+
async fetchAddonTarball(name, version, signal) {
|
|
895
|
+
const registry = process.env['CAMSTACK_NPM_REGISTRY'];
|
|
896
|
+
const reg = (registry ?? 'https://registry.npmjs.org').replace(/\/+$/, '');
|
|
897
|
+
const metaUrl = `${reg}/${encodeURIComponent(name).replace(/^%40/, '@')}`;
|
|
898
|
+
const metaRes = await fetch(metaUrl, { signal });
|
|
899
|
+
if (!metaRes.ok)
|
|
900
|
+
throw new Error(`registry GET ${metaUrl} → ${metaRes.status}`);
|
|
901
|
+
const meta = (await metaRes.json());
|
|
902
|
+
const tarballUrl = meta.versions?.[version]?.dist?.tarball;
|
|
903
|
+
if (typeof tarballUrl !== 'string') {
|
|
904
|
+
throw new Error(`no tarball url for ${name}@${version}`);
|
|
905
|
+
}
|
|
906
|
+
const tarRes = await fetch(tarballUrl, { signal });
|
|
907
|
+
if (!tarRes.ok)
|
|
908
|
+
throw new Error(`tarball GET ${tarballUrl} → ${tarRes.status}`);
|
|
909
|
+
return Buffer.from(await tarRes.arrayBuffer());
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Extract a gzipped tarball Buffer into `destDir`.
|
|
913
|
+
*
|
|
914
|
+
* Wraps the existing `tar -xzf` shell call for use by the lifecycle job
|
|
915
|
+
* engine (ExtractFn signature). The Buffer is written to a temp file,
|
|
916
|
+
* extracted with `--strip-components=1` (removes the npm `package/` prefix),
|
|
917
|
+
* then the temp file is removed.
|
|
918
|
+
*/
|
|
919
|
+
async extractTarball(tgz, destDir) {
|
|
920
|
+
await extractTgzStripped(tgz, destDir);
|
|
921
|
+
}
|
|
813
922
|
/**
|
|
814
923
|
* Gracefully restart the server process.
|
|
815
924
|
*
|
|
@@ -916,32 +1025,160 @@ class AddonPackageService {
|
|
|
916
1025
|
* Returns BEFORE the exit fires so the admin UI receives `restartingAt`
|
|
917
1026
|
* and can pivot to the reconnect overlay.
|
|
918
1027
|
*/
|
|
1028
|
+
/**
|
|
1029
|
+
* Install the framework by SWAPPING each package's own directory, exactly
|
|
1030
|
+
* like every addon is installed (download the single-package tarball →
|
|
1031
|
+
* extract → atomic dir swap). We NEVER run `npm install --prefix <appRoot>`.
|
|
1032
|
+
*
|
|
1033
|
+
* Why: `npm install --prefix /data` manages the WHOLE node_modules tree — it
|
|
1034
|
+
* prunes every package not in @camstack/system's closure (the addons +
|
|
1035
|
+
* ui-library), deletes the stray manifest.json, and (via temp-install + merge
|
|
1036
|
+
* variants) can clobber working native bindings. A per-package tarball swap
|
|
1037
|
+
* touches only that package's folder; nothing else can be pruned, deleted, or
|
|
1038
|
+
* corrupted.
|
|
1039
|
+
*
|
|
1040
|
+
* Swaps the @camstack framework packages versioned in lockstep with system:
|
|
1041
|
+
* the pure-JS libs (types, sdk) AND @camstack/shm-ring. shm-ring is native
|
|
1042
|
+
* but SAFE to swap because it ships its compiled `.node` prebuilds INSIDE the
|
|
1043
|
+
* npm tarball (prebuildify + node-gyp-build resolves the matching one at
|
|
1044
|
+
* require-time) — a bare extract is sufficient, no build or download.
|
|
1045
|
+
*
|
|
1046
|
+
* It does NOT swap download/compile-on-install natives such as `better-sqlite3`
|
|
1047
|
+
* (prebuild-install fetches the binary from GitHub releases at install time —
|
|
1048
|
+
* a bare extract would leave it without a binding). Those are third-party,
|
|
1049
|
+
* hoisted, change only across majors, and are updated by an image redeploy.
|
|
1050
|
+
* System's own deps stay exactly where they are, bindings intact.
|
|
1051
|
+
*/
|
|
1052
|
+
async installFrameworkPackages(packageName, toVersion, appRoot) {
|
|
1053
|
+
const registry = process.env['CAMSTACK_NPM_REGISTRY'];
|
|
1054
|
+
// @camstack/shm-ring is native but ships prebuilds in its tarball → safe.
|
|
1055
|
+
const lockstepDeps = ['@camstack/types', '@camstack/sdk', '@camstack/shm-ring'];
|
|
1056
|
+
const swapTargets = [packageName, ...lockstepDeps];
|
|
1057
|
+
// Self-heal: a previous run killed mid-swap (e.g. process restart while a
|
|
1058
|
+
// slow download was in flight) can leave a `<pkg>.fw-bak` backup behind.
|
|
1059
|
+
// It's inert (not a valid package the loader picks up) but sweep it so the
|
|
1060
|
+
// tree stays clean.
|
|
1061
|
+
sweepStaleFrameworkBackups(appRoot);
|
|
1062
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'camstack-fw-'));
|
|
1063
|
+
try {
|
|
1064
|
+
for (const pkg of swapTargets) {
|
|
1065
|
+
const isPrimary = pkg === packageName;
|
|
1066
|
+
try {
|
|
1067
|
+
const tgzPath = await packTarball(pkg, toVersion, tmpRoot, registry);
|
|
1068
|
+
const extractDir = fs.mkdtempSync(path.join(tmpRoot, 'x-'));
|
|
1069
|
+
await execFileAsync('tar', ['-xzf', tgzPath, '-C', extractDir], { timeout: 60_000 });
|
|
1070
|
+
const result = swapInFrameworkPackage(path.join(extractDir, 'package'), appRoot);
|
|
1071
|
+
this.logger.info('updateFrameworkPackage: package swapped', {
|
|
1072
|
+
meta: { name: result.name, version: result.version },
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
catch (err) {
|
|
1076
|
+
if (isPrimary)
|
|
1077
|
+
throw err;
|
|
1078
|
+
// A lockstep dep may not be published at this exact version (or the
|
|
1079
|
+
// download flaked) — keep the existing copy rather than fail the whole
|
|
1080
|
+
// framework update. System is the one that MUST succeed.
|
|
1081
|
+
this.logger.warn(`updateFrameworkPackage: lockstep dep ${pkg}@${toVersion} not swapped`, {
|
|
1082
|
+
meta: { error: (0, types_1.errMsg)(err) },
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
finally {
|
|
1088
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* Resolve a version specifier (`'latest'`, semver tag, exact) for a
|
|
1093
|
+
* framework package to a concrete version via `npm view`.
|
|
1094
|
+
* Delegates to the module-level `resolveNpmVersion` helper.
|
|
1095
|
+
* Exposed as a public method so the lifecycle job engine wiring in
|
|
1096
|
+
* cap-providers can pass it as `resolveVersion` without duplicating the
|
|
1097
|
+
* npm-view/manifest logic.
|
|
1098
|
+
*/
|
|
1099
|
+
async resolveFrameworkVersion(packageName, versionSpec) {
|
|
1100
|
+
return resolveNpmVersion(packageName, versionSpec, process.env['CAMSTACK_NPM_REGISTRY']);
|
|
1101
|
+
}
|
|
1102
|
+
/**
|
|
1103
|
+
* Return the currently-installed version of a framework package, or `null`
|
|
1104
|
+
* when it cannot be determined (not installed / no manifest).
|
|
1105
|
+
* Delegates to the module-level `readResolvedPackageManifest` helper.
|
|
1106
|
+
* Exposed as a public method for use by the lifecycle job engine wiring.
|
|
1107
|
+
*/
|
|
1108
|
+
currentFrameworkVersionOf(packageName) {
|
|
1109
|
+
const manifest = readResolvedPackageManifest(packageName);
|
|
1110
|
+
if (manifest !== null && typeof manifest['version'] === 'string') {
|
|
1111
|
+
return manifest['version'];
|
|
1112
|
+
}
|
|
1113
|
+
return null;
|
|
1114
|
+
}
|
|
1115
|
+
/**
|
|
1116
|
+
* Update a framework package via the staged launcher-swap engine.
|
|
1117
|
+
*
|
|
1118
|
+
* New path (when `runner` is provided):
|
|
1119
|
+
* 1. Resolve toVersion via `npm view`.
|
|
1120
|
+
* 2. Delegate to `runner.startJob` with a `target:'framework'` task.
|
|
1121
|
+
* The engine calls `stageFramework` → downloads+stages the 4 lockstep
|
|
1122
|
+
* packages → calls the wired `requestFrameworkSwap`
|
|
1123
|
+
* (`requestFrameworkSwapAndRestart`), which writes the
|
|
1124
|
+
* `.pending-framework-swap.json` marker AND schedules the self-restart.
|
|
1125
|
+
* The hub exits; the launcher applies the swap on the next boot. The
|
|
1126
|
+
* restart is owned by that seam, so this method does NOT schedule one.
|
|
1127
|
+
* 3. Return `{ packageName, fromVersion, toVersion, restartingAt }`.
|
|
1128
|
+
* `restartingAt = 0` only on the legacy in-hub path when `deferRestart`
|
|
1129
|
+
* is set (engine path always restarts once the framework is staged).
|
|
1130
|
+
*
|
|
1131
|
+
* Legacy path (when no `runner`): keeps the original in-hub live-swap for
|
|
1132
|
+
* backward compatibility with callers (e.g. tests) that have not wired the
|
|
1133
|
+
* engine deps.
|
|
1134
|
+
*/
|
|
919
1135
|
async updateFrameworkPackage(input) {
|
|
920
1136
|
const { packageName } = input;
|
|
921
1137
|
if (packageName !== exports.SYSTEM_PACKAGE) {
|
|
922
1138
|
throw new Error(`updateFrameworkPackage: '${packageName}' is not a framework package. Allowed: ${exports.SYSTEM_PACKAGE}`);
|
|
923
1139
|
}
|
|
924
|
-
const
|
|
925
|
-
const fromManifest = readResolvedPackageManifest(packageName);
|
|
926
|
-
const fromVersion = fromManifest !== null && typeof fromManifest['version'] === 'string'
|
|
927
|
-
? fromManifest['version']
|
|
928
|
-
: 'unknown';
|
|
1140
|
+
const fromVersion = this.currentFrameworkVersionOf(packageName) ?? 'unknown';
|
|
929
1141
|
const requestedVersion = input.version ?? 'latest';
|
|
930
|
-
const toVersion = await
|
|
931
|
-
|
|
932
|
-
|
|
1142
|
+
const toVersion = await this.resolveFrameworkVersion(packageName, requestedVersion);
|
|
1143
|
+
// ── New staged-swap engine path ──────────────────────────────────
|
|
1144
|
+
if (input.runner !== undefined) {
|
|
1145
|
+
this.logger.info('updateFrameworkPackage: delegating to staged swap engine', {
|
|
1146
|
+
meta: { packageName, fromVersion, toVersion, deferRestart: input.deferRestart ?? false },
|
|
1147
|
+
});
|
|
1148
|
+
await input.runner.startJob({
|
|
1149
|
+
kind: 'update',
|
|
1150
|
+
targets: [{ name: packageName, version: toVersion, target: 'framework' }],
|
|
1151
|
+
createdBy: input.requestedBy ?? 'system',
|
|
1152
|
+
});
|
|
1153
|
+
// The engine's framework task already wrote the pending-swap marker AND
|
|
1154
|
+
// scheduled the self-restart via the wired `requestFrameworkSwap`
|
|
1155
|
+
// (`requestFrameworkSwapAndRestart`) — that is the single restart owner,
|
|
1156
|
+
// so we must NOT schedule a second one here. We only surface the
|
|
1157
|
+
// pre-restart toast event for the interactive single-update path.
|
|
1158
|
+
if (input.deferRestart !== true) {
|
|
1159
|
+
this.eventBusService.emit({
|
|
1160
|
+
id: (0, node_crypto_1.randomUUID)(),
|
|
1161
|
+
timestamp: new Date(),
|
|
1162
|
+
source: { type: 'core', id: 'addon-package-service' },
|
|
1163
|
+
category: types_1.EventCategory.SystemRestarting,
|
|
1164
|
+
data: {
|
|
1165
|
+
kind: 'framework-update',
|
|
1166
|
+
packageName,
|
|
1167
|
+
fromVersion,
|
|
1168
|
+
toVersion,
|
|
1169
|
+
requestedAt: Date.now(),
|
|
1170
|
+
},
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
const restartingAt = input.deferRestart === true ? 0 : Date.now() + 500;
|
|
1174
|
+
return { packageName, fromVersion, toVersion, restartingAt };
|
|
1175
|
+
}
|
|
1176
|
+
// ── Legacy in-hub live-swap path (no runner injected) ───────────
|
|
1177
|
+
const appRoot = resolveFrameworkPackageAppRoot(packageName, this.logger);
|
|
1178
|
+
this.logger.info('updateFrameworkPackage: installing (legacy in-hub path)', {
|
|
933
1179
|
meta: { packageName, fromVersion, toVersion, appRoot },
|
|
934
1180
|
});
|
|
935
|
-
|
|
936
|
-
const args = [
|
|
937
|
-
'install',
|
|
938
|
-
'--prefix',
|
|
939
|
-
appRoot,
|
|
940
|
-
spec,
|
|
941
|
-
'--no-save',
|
|
942
|
-
...buildNpmRegistryArgs(registry),
|
|
943
|
-
];
|
|
944
|
-
await execFileAsync('npm', args, { timeout: 180_000 });
|
|
1181
|
+
await this.installFrameworkPackages(packageName, toVersion, appRoot);
|
|
945
1182
|
if (input.deferRestart === true) {
|
|
946
1183
|
this.logger.info(`updateFrameworkPackage(${packageName}@${toVersion}): install done, restart deferred`);
|
|
947
1184
|
// Sentinel: 0 signals "no restart scheduled" to the caller
|
|
@@ -1063,11 +1300,22 @@ class AddonPackageService {
|
|
|
1063
1300
|
});
|
|
1064
1301
|
}, intervalMs);
|
|
1065
1302
|
}
|
|
1066
|
-
/**
|
|
1303
|
+
/**
|
|
1304
|
+
* Run auto-update: check each installed package against its configured
|
|
1305
|
+
* channel, then route ALL packages that have a newer version through ONE
|
|
1306
|
+
* durable engine bulk job (F3 Task 5). The framework (`@camstack/system`) is
|
|
1307
|
+
* auto-detected by the engine and ordered LAST; auto-updates survive a reboot
|
|
1308
|
+
* via the boot reconcile, exactly like a manual "Update all".
|
|
1309
|
+
*
|
|
1310
|
+
* Candidate selection (channel resolution + npm metadata fetch + "is there a
|
|
1311
|
+
* newer version?" comparison) is unchanged from the previous per-item loop;
|
|
1312
|
+
* only the EXECUTION moved from sequential `updatePackage` calls to a single
|
|
1313
|
+
* `startJob`.
|
|
1314
|
+
*/
|
|
1067
1315
|
async runAutoUpdate() {
|
|
1068
1316
|
this.logger.info('Running auto-update check...');
|
|
1069
1317
|
const installed = this.listInstalled();
|
|
1070
|
-
|
|
1318
|
+
const targets = [];
|
|
1071
1319
|
for (const pkg of installed) {
|
|
1072
1320
|
try {
|
|
1073
1321
|
// Determine effective channel for this addon
|
|
@@ -1092,7 +1340,7 @@ class AddonPackageService {
|
|
|
1092
1340
|
: asString(distTags['latest']);
|
|
1093
1341
|
if (!targetVersion || targetVersion === pkg.version)
|
|
1094
1342
|
continue;
|
|
1095
|
-
this.logger.info('Auto-
|
|
1343
|
+
this.logger.info('Auto-update candidate', {
|
|
1096
1344
|
meta: {
|
|
1097
1345
|
name: pkg.name,
|
|
1098
1346
|
currentVersion: pkg.version,
|
|
@@ -1100,18 +1348,34 @@ class AddonPackageService {
|
|
|
1100
1348
|
channel: effectiveChannel,
|
|
1101
1349
|
},
|
|
1102
1350
|
});
|
|
1103
|
-
|
|
1104
|
-
updatedCount++;
|
|
1351
|
+
targets.push({ name: pkg.name, version: targetVersion });
|
|
1105
1352
|
}
|
|
1106
1353
|
catch (err) {
|
|
1107
|
-
this.logger.warn('Auto-update failed', {
|
|
1354
|
+
this.logger.warn('Auto-update check failed', {
|
|
1355
|
+
meta: { name: pkg.name, error: (0, types_1.errMsg)(err) },
|
|
1356
|
+
});
|
|
1108
1357
|
}
|
|
1109
1358
|
}
|
|
1110
|
-
if (
|
|
1111
|
-
this.logger.info('Auto-update complete', { meta: { updatedCount } });
|
|
1112
|
-
}
|
|
1113
|
-
else {
|
|
1359
|
+
if (targets.length === 0) {
|
|
1114
1360
|
this.logger.debug('Auto-update: all packages up-to-date');
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
// Execute as ONE durable engine bulk job. The runner singleton is resolved
|
|
1364
|
+
// lazily via a dynamic import (the singleton module transitively imports
|
|
1365
|
+
// SYSTEM_PACKAGE back from this module) to avoid an init-order cycle. The
|
|
1366
|
+
// singleton is initialized at boot, before the scheduler timer fires. The
|
|
1367
|
+
// framework target, if present, is ordered last by the engine. Wrap so a
|
|
1368
|
+
// failure is logged, not thrown out of the timer callback.
|
|
1369
|
+
try {
|
|
1370
|
+
const { getLifecycleRunner } = await Promise.resolve().then(() => __importStar(require('../lifecycle/lifecycle-runner.singleton.js')));
|
|
1371
|
+
const runner = getLifecycleRunner();
|
|
1372
|
+
const { jobId } = await runner.startJob({ kind: 'update', targets, createdBy: 'system' });
|
|
1373
|
+
this.logger.info('Auto-update job started', {
|
|
1374
|
+
meta: { jobId, packageCount: targets.length },
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
catch (err) {
|
|
1378
|
+
this.logger.error('Auto-update job failed to start', { meta: { error: (0, types_1.errMsg)(err) } });
|
|
1115
1379
|
}
|
|
1116
1380
|
}
|
|
1117
1381
|
// =========================================================================
|
|
@@ -1429,6 +1693,35 @@ class AddonPackageService {
|
|
|
1429
1693
|
}
|
|
1430
1694
|
exports.AddonPackageService = AddonPackageService;
|
|
1431
1695
|
// ---------------------------------------------------------------------------
|
|
1696
|
+
// Tarball extraction helper (exported for regression tests)
|
|
1697
|
+
// ---------------------------------------------------------------------------
|
|
1698
|
+
/**
|
|
1699
|
+
* Extract a gzipped tarball Buffer into `destDir`, stripping the npm
|
|
1700
|
+
* `package/` top-level prefix so that the extracted `package.json` lands at
|
|
1701
|
+
* `destDir/package.json` (not `destDir/package/package.json`).
|
|
1702
|
+
*
|
|
1703
|
+
* Uses `tar --strip-components=1` which mirrors what `installFromTgz` does
|
|
1704
|
+
* when it reads `path.join(tmpDir, 'package')` after a plain extract and
|
|
1705
|
+
* then uses that sub-directory as the source root.
|
|
1706
|
+
*
|
|
1707
|
+
* Exported so the regression spec in
|
|
1708
|
+
* `src/core/addon/__tests__/extract-tarball-strip.spec.ts` can test the real
|
|
1709
|
+
* implementation without instantiating `AddonPackageService`.
|
|
1710
|
+
*/
|
|
1711
|
+
async function extractTgzStripped(tgz, destDir) {
|
|
1712
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
1713
|
+
const tmpFile = path.join(os.tmpdir(), `camstack-tgz-${(0, node_crypto_1.randomUUID)()}.tgz`);
|
|
1714
|
+
try {
|
|
1715
|
+
fs.writeFileSync(tmpFile, tgz);
|
|
1716
|
+
await execFileAsync('tar', ['--strip-components=1', '-xzf', tmpFile, '-C', destDir], {
|
|
1717
|
+
timeout: 60_000,
|
|
1718
|
+
});
|
|
1719
|
+
}
|
|
1720
|
+
finally {
|
|
1721
|
+
fs.rmSync(tmpFile, { force: true });
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
// ---------------------------------------------------------------------------
|
|
1432
1725
|
// Framework live-update helpers
|
|
1433
1726
|
// ---------------------------------------------------------------------------
|
|
1434
1727
|
/**
|
|
@@ -1451,6 +1744,131 @@ function buildNpmRegistryArgs(registry) {
|
|
|
1451
1744
|
return [];
|
|
1452
1745
|
return ['--registry', registry, `--@camstack:registry=${registry}`];
|
|
1453
1746
|
}
|
|
1747
|
+
/**
|
|
1748
|
+
* Remove any leftover `<pkg>.fw-bak` backup dirs under `appRoot/node_modules/
|
|
1749
|
+
* @camstack` — debris from a framework swap that was interrupted before its
|
|
1750
|
+
* own cleanup ran. Best-effort; returns the swept names.
|
|
1751
|
+
*/
|
|
1752
|
+
function sweepStaleFrameworkBackups(appRoot) {
|
|
1753
|
+
const scopeDir = path.join(appRoot, 'node_modules', '@camstack');
|
|
1754
|
+
if (!fs.existsSync(scopeDir))
|
|
1755
|
+
return [];
|
|
1756
|
+
const swept = [];
|
|
1757
|
+
for (const entry of fs.readdirSync(scopeDir, { withFileTypes: true })) {
|
|
1758
|
+
if (!entry.isDirectory() || !entry.name.endsWith('.fw-bak'))
|
|
1759
|
+
continue;
|
|
1760
|
+
try {
|
|
1761
|
+
fs.rmSync(path.join(scopeDir, entry.name), { recursive: true, force: true });
|
|
1762
|
+
swept.push(entry.name);
|
|
1763
|
+
}
|
|
1764
|
+
catch {
|
|
1765
|
+
// ignore — inert leftover, next sweep retries
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
return swept;
|
|
1769
|
+
}
|
|
1770
|
+
/**
|
|
1771
|
+
* Download the `.tgz` for an EXACT `pkg@version` into `destRoot`, returning its
|
|
1772
|
+
* path. Prefers a direct HTTPS GET (registry metadata → tarball URL) because
|
|
1773
|
+
* `fetch` + `AbortSignal.timeout` aborts RELIABLY on a slow/flaky network — an
|
|
1774
|
+
* `npm pack` child can stall for minutes past its `execFile` timeout (npm holds
|
|
1775
|
+
* its stdio pipes open, so the awaited promise never settles), which would wedge
|
|
1776
|
+
* the whole framework update. Falls back to `npm pack` (SIGKILL on timeout) only
|
|
1777
|
+
* when the HTTPS path fails (private registry quirks, auth, etc.).
|
|
1778
|
+
*/
|
|
1779
|
+
async function packTarball(pkg, version, destRoot, registry) {
|
|
1780
|
+
const dir = fs.mkdtempSync(path.join(destRoot, 'dl-'));
|
|
1781
|
+
try {
|
|
1782
|
+
return await httpsDownloadTarball(pkg, version, dir, registry);
|
|
1783
|
+
}
|
|
1784
|
+
catch (httpErr) {
|
|
1785
|
+
const args = [
|
|
1786
|
+
'pack',
|
|
1787
|
+
`${pkg}@${version}`,
|
|
1788
|
+
'--pack-destination',
|
|
1789
|
+
dir,
|
|
1790
|
+
...buildNpmRegistryArgs(registry),
|
|
1791
|
+
];
|
|
1792
|
+
// SIGKILL (not the default SIGTERM): npm can ignore SIGTERM while a
|
|
1793
|
+
// grandchild holds the pipes — SIGKILL guarantees the timeout fires.
|
|
1794
|
+
await execFileAsync('npm', args, { timeout: 60_000, killSignal: 'SIGKILL' });
|
|
1795
|
+
const tgz = fs.readdirSync(dir).find((f) => f.endsWith('.tgz'));
|
|
1796
|
+
if (tgz === undefined) {
|
|
1797
|
+
throw new Error(`download failed for ${pkg}@${version}: ${(0, types_1.errMsg)(httpErr)}`);
|
|
1798
|
+
}
|
|
1799
|
+
return path.join(dir, tgz);
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
/**
|
|
1803
|
+
* Direct HTTPS download of an exact `pkg@version` tarball. Resolves the tarball
|
|
1804
|
+
* URL from the registry packument, then streams it to a file. Every network
|
|
1805
|
+
* call is bounded by an `AbortSignal.timeout` so a DNS/registry stall fails fast
|
|
1806
|
+
* instead of hanging the framework update.
|
|
1807
|
+
*/
|
|
1808
|
+
async function httpsDownloadTarball(pkg, version, dir, registry) {
|
|
1809
|
+
const reg = (registry ?? 'https://registry.npmjs.org').replace(/\/+$/, '');
|
|
1810
|
+
const metaUrl = `${reg}/${encodeURIComponent(pkg).replace(/^%40/, '@')}`;
|
|
1811
|
+
const metaRes = await fetch(metaUrl, { signal: AbortSignal.timeout(20_000) });
|
|
1812
|
+
if (!metaRes.ok)
|
|
1813
|
+
throw new Error(`registry GET ${metaUrl} → ${metaRes.status}`);
|
|
1814
|
+
const meta = (await metaRes.json());
|
|
1815
|
+
const tarballUrl = meta.versions?.[version]?.dist?.tarball;
|
|
1816
|
+
if (typeof tarballUrl !== 'string') {
|
|
1817
|
+
throw new Error(`no tarball url for ${pkg}@${version}`);
|
|
1818
|
+
}
|
|
1819
|
+
const tarRes = await fetch(tarballUrl, { signal: AbortSignal.timeout(120_000) });
|
|
1820
|
+
if (!tarRes.ok)
|
|
1821
|
+
throw new Error(`tarball GET ${tarballUrl} → ${tarRes.status}`);
|
|
1822
|
+
const buf = Buffer.from(await tarRes.arrayBuffer());
|
|
1823
|
+
const outPath = path.join(dir, `${pkg.replace('@', '').replace('/', '-')}-${version}.tgz`);
|
|
1824
|
+
fs.writeFileSync(outPath, buf);
|
|
1825
|
+
return outPath;
|
|
1826
|
+
}
|
|
1827
|
+
/**
|
|
1828
|
+
* Atomically replace one `@camstack/*` package directory in
|
|
1829
|
+
* `appRoot/node_modules` with the contents of an extracted npm package dir
|
|
1830
|
+
* (`stagedPackageDir` = the `package/` folder from a `.tgz`).
|
|
1831
|
+
*
|
|
1832
|
+
* This is the framework-update primitive: it touches ONLY the target package's
|
|
1833
|
+
* folder. The current copy is renamed to a sibling `.fw-bak` backup first and
|
|
1834
|
+
* restored if the copy fails, so a crash mid-swap can't leave a half-written
|
|
1835
|
+
* package. Refuses anything outside the `@camstack/` scope as a safety guard.
|
|
1836
|
+
* Returns the installed package name + version.
|
|
1837
|
+
*/
|
|
1838
|
+
function swapInFrameworkPackage(stagedPackageDir, appRoot) {
|
|
1839
|
+
const pkgJsonPath = path.join(stagedPackageDir, 'package.json');
|
|
1840
|
+
if (!fs.existsSync(pkgJsonPath)) {
|
|
1841
|
+
throw new Error(`swapInFrameworkPackage: no package.json in ${stagedPackageDir}`);
|
|
1842
|
+
}
|
|
1843
|
+
const parsed = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
|
|
1844
|
+
const pkg = parsed;
|
|
1845
|
+
if (typeof pkg.name !== 'string' || typeof pkg.version !== 'string') {
|
|
1846
|
+
throw new Error(`swapInFrameworkPackage: invalid package.json in ${stagedPackageDir}`);
|
|
1847
|
+
}
|
|
1848
|
+
if (!pkg.name.startsWith('@camstack/')) {
|
|
1849
|
+
throw new Error(`swapInFrameworkPackage: refusing non-@camstack package ${pkg.name}`);
|
|
1850
|
+
}
|
|
1851
|
+
const targetDir = path.join(appRoot, 'node_modules', pkg.name);
|
|
1852
|
+
const backupDir = `${targetDir}.fw-bak`;
|
|
1853
|
+
fs.rmSync(backupDir, { recursive: true, force: true });
|
|
1854
|
+
const hadExisting = fs.existsSync(targetDir);
|
|
1855
|
+
if (hadExisting)
|
|
1856
|
+
fs.renameSync(targetDir, backupDir);
|
|
1857
|
+
try {
|
|
1858
|
+
fs.mkdirSync(path.dirname(targetDir), { recursive: true });
|
|
1859
|
+
fs.cpSync(stagedPackageDir, targetDir, { recursive: true });
|
|
1860
|
+
}
|
|
1861
|
+
catch (err) {
|
|
1862
|
+
// Roll back to the previous copy so a failed swap never leaves the
|
|
1863
|
+
// package missing or half-written.
|
|
1864
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
1865
|
+
if (hadExisting)
|
|
1866
|
+
fs.renameSync(backupDir, targetDir);
|
|
1867
|
+
throw err;
|
|
1868
|
+
}
|
|
1869
|
+
fs.rmSync(backupDir, { recursive: true, force: true });
|
|
1870
|
+
return { name: pkg.name, version: pkg.version };
|
|
1871
|
+
}
|
|
1454
1872
|
/**
|
|
1455
1873
|
* Resolve the directory whose `node_modules/<pkg>/` holds the currently-
|
|
1456
1874
|
* installed copy of a framework package. `npm install --prefix <appRoot>`
|
|
@@ -1358,13 +1358,27 @@ class AddonRegistryService {
|
|
|
1358
1358
|
// the group-runner crashed for real, `$node.disconnected`
|
|
1359
1359
|
// surfaces it through the health monitor; the operator can
|
|
1360
1360
|
// always click Cancel on the UI mutation.
|
|
1361
|
-
|
|
1361
|
+
// Wait for declared caps to re-register, but BOUNDED — and on timeout
|
|
1362
|
+
// do NOT throw. DEVICE-SCOPED caps (e.g. a camera provider's
|
|
1363
|
+
// `device-provider` / `snapshot-provider`) only ever register once
|
|
1364
|
+
// devices exist; on a hub with none they NEVER come back, so an
|
|
1365
|
+
// infinite wait hangs the whole update — and the lifecycle job that
|
|
1366
|
+
// drives it (apply task stuck at `applying`, framework-last blocked,
|
|
1367
|
+
// the job uncancellable). The on-disk swap + runner restart have
|
|
1368
|
+
// already succeeded here; any caps still pending re-register
|
|
1369
|
+
// asynchronously as their devices come online. We only WARN which
|
|
1370
|
+
// caps were still pending (the comment's original objection was a
|
|
1371
|
+
// *misleading error* on timeout — a warning avoids that while still
|
|
1372
|
+
// letting the update finalize).
|
|
1373
|
+
const REGISTER_WAIT_MS = 120_000;
|
|
1374
|
+
const waits = declared.map((cap) => this.capabilityRegistry.waitForProvider(cap.name, addonId, REGISTER_WAIT_MS));
|
|
1362
1375
|
const settled = await Promise.all(waits);
|
|
1363
1376
|
const missing = declared
|
|
1364
1377
|
.map((cap, i) => (settled[i] == null ? cap.name : null))
|
|
1365
1378
|
.filter((name) => name !== null);
|
|
1366
1379
|
if (missing.length > 0) {
|
|
1367
|
-
|
|
1380
|
+
this.logger.warn(`Addon "${addonId}" restarted; ${missing.length} capability(ies) not yet re-registered ` +
|
|
1381
|
+
`(re-register asynchronously — e.g. device-scoped caps awaiting devices): ${missing.join(', ')}`, { tags: { addonId } });
|
|
1368
1382
|
}
|
|
1369
1383
|
}
|
|
1370
1384
|
// Re-register the addon's custom-action catalog. `$process.restart`
|
|
@@ -39,6 +39,36 @@ const os = __importStar(require("node:os"));
|
|
|
39
39
|
const types_1 = require("@camstack/types");
|
|
40
40
|
/** Per-call timeout for `$agent.*` RPC during reconciliation. */
|
|
41
41
|
const AGENT_RECONCILE_RPC_TIMEOUT_MS = 8_000;
|
|
42
|
+
/**
|
|
43
|
+
* Package name of the system/infrastructure builtins. Addons shipped in
|
|
44
|
+
* `@camstack/system` (filesystem-storage, storage-orchestrator,
|
|
45
|
+
* sqlite-settings, hub-forwarder, metrics-native, console-logging, …) are
|
|
46
|
+
* bootstrap infrastructure that EVERY node — hub and agent alike — runs
|
|
47
|
+
* in-process. They are never `$agent.deploy`-pushed and must NEVER be
|
|
48
|
+
* undeployed by reconciliation: tearing them down on an agent destroys its
|
|
49
|
+
* own storage/settings/metrics/logging and bricks the node. This is the
|
|
50
|
+
* same canonical "core builtin" predicate the hub uses in
|
|
51
|
+
* `AddonRegistryService.buildAddonGroupPlan`.
|
|
52
|
+
*/
|
|
53
|
+
/**
|
|
54
|
+
* Agent bootstrap-infrastructure packages — seeded by the agent's own
|
|
55
|
+
* `AGENT_PACKAGES` bootstrap, NEVER hub-managed deploy targets, so the
|
|
56
|
+
* reconcile must never undeploy them (they are absent from the hub's
|
|
57
|
+
* installed-addon set and would otherwise look "stale"):
|
|
58
|
+
* - `@camstack/system` — storage/settings/metrics/logging builtins.
|
|
59
|
+
* - `@camstack/addon-agent-ui` — the agent's own status dashboard
|
|
60
|
+
* (placement: agent-only; the hub never installs it, so it is missing
|
|
61
|
+
* from `hubPlacements`). The hub's deploy planner already SKIPS
|
|
62
|
+
* agent-only addons, so undeploying one is a reconcile asymmetry bug.
|
|
63
|
+
*
|
|
64
|
+
* The hub cannot consult `AddonInstaller.AGENT_PACKAGES` directly: it
|
|
65
|
+
* derives from `@camstack/agent`, which is not installed in a hub image,
|
|
66
|
+
* so that list is empty hub-side. Hence this explicit allowlist.
|
|
67
|
+
*/
|
|
68
|
+
const AGENT_BOOTSTRAP_PACKAGES = new Set([
|
|
69
|
+
'@camstack/system',
|
|
70
|
+
'@camstack/addon-agent-ui',
|
|
71
|
+
]);
|
|
42
72
|
class AgentRegistryService {
|
|
43
73
|
eventBus;
|
|
44
74
|
moleculer;
|
|
@@ -229,6 +259,16 @@ class AgentRegistryService {
|
|
|
229
259
|
hubPlacements.set(declId, (0, types_1.resolveAddonPlacement)(decl));
|
|
230
260
|
}
|
|
231
261
|
const stale = agentAddons.filter((addon) => {
|
|
262
|
+
// Agent bootstrap infrastructure (storage/settings/metrics/logging from
|
|
263
|
+
// `@camstack/system`, plus the agent-only `addon-agent-ui` dashboard)
|
|
264
|
+
// runs on every node as bootstrap infrastructure — never hub-managed
|
|
265
|
+
// deploy targets, must never be undeployed. Without this guard the
|
|
266
|
+
// reconcile tears down the agent's own builtins (they aren't in the
|
|
267
|
+
// hub's installed-addon set, so they look "stale"), which bricks the
|
|
268
|
+
// agent and makes it permanently unroutable. See AGENT_BOOTSTRAP_PACKAGES.
|
|
269
|
+
if (addon.packageName !== undefined && AGENT_BOOTSTRAP_PACKAGES.has(addon.packageName)) {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
232
272
|
const placement = hubPlacements.get(addon.id);
|
|
233
273
|
// Not installed on the hub → stale.
|
|
234
274
|
if (placement === undefined)
|
|
@@ -281,7 +321,9 @@ class AgentRegistryService {
|
|
|
281
321
|
const id = entry.id;
|
|
282
322
|
if (typeof id !== 'string' || id.length === 0)
|
|
283
323
|
continue;
|
|
284
|
-
|
|
324
|
+
// Preserve `packageName` — the reconcile uses it to skip system builtins.
|
|
325
|
+
const packageName = entry.packageName;
|
|
326
|
+
result.push({ id, packageName: typeof packageName === 'string' ? packageName : undefined });
|
|
285
327
|
}
|
|
286
328
|
return result;
|
|
287
329
|
}
|