@camstack/core 0.1.0 → 0.1.1
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/chunk-2F3XZYRW.mjs +89 -0
- package/dist/chunk-2F3XZYRW.mjs.map +1 -0
- package/dist/chunk-LZOMFHX3.mjs +38 -0
- package/dist/chunk-LZOMFHX3.mjs.map +1 -0
- package/dist/index.d.mts +1536 -5
- package/dist/index.d.ts +1536 -5
- package/dist/index.js +8230 -526
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +3929 -25
- package/dist/index.mjs.map +1 -1
- package/dist/storage-location-manager-F4YZMHGM.mjs +8 -0
- package/dist/storage-location-manager-F4YZMHGM.mjs.map +1 -0
- package/dist/wrapper-NTBY5HOA.mjs +3652 -0
- package/dist/wrapper-NTBY5HOA.mjs.map +1 -0
- package/package.json +20 -1
package/dist/index.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
import {
|
|
2
|
+
FsStorageBackend,
|
|
3
|
+
StorageLocationManager
|
|
4
|
+
} from "./chunk-2F3XZYRW.mjs";
|
|
5
|
+
import {
|
|
6
|
+
__require
|
|
7
|
+
} from "./chunk-LZOMFHX3.mjs";
|
|
7
8
|
|
|
8
9
|
// src/events/event-bus.ts
|
|
9
10
|
var EventBus = class {
|
|
@@ -60,10 +61,10 @@ async function downloadModel(options) {
|
|
|
60
61
|
try {
|
|
61
62
|
const bytes = await downloadFile(tryUrl, destPath, onProgress);
|
|
62
63
|
if (expectedSha256) {
|
|
63
|
-
const
|
|
64
|
-
if (
|
|
64
|
+
const hash2 = await computeSha256(destPath);
|
|
65
|
+
if (hash2 !== expectedSha256) {
|
|
65
66
|
fs.unlinkSync(destPath);
|
|
66
|
-
throw new Error(`SHA256 mismatch: expected ${expectedSha256}, got ${
|
|
67
|
+
throw new Error(`SHA256 mismatch: expected ${expectedSha256}, got ${hash2}`);
|
|
67
68
|
}
|
|
68
69
|
}
|
|
69
70
|
return { filePath: destPath, downloadedBytes: bytes, fromCache: false };
|
|
@@ -104,10 +105,10 @@ async function downloadFile(url, destPath, onProgress) {
|
|
|
104
105
|
}
|
|
105
106
|
async function computeSha256(filePath) {
|
|
106
107
|
return new Promise((resolve, reject) => {
|
|
107
|
-
const
|
|
108
|
+
const hash2 = createHash("sha256");
|
|
108
109
|
const stream = fs.createReadStream(filePath);
|
|
109
|
-
stream.on("data", (chunk) =>
|
|
110
|
-
stream.on("end", () => resolve(
|
|
110
|
+
stream.on("data", (chunk) => hash2.update(chunk));
|
|
111
|
+
stream.on("end", () => resolve(hash2.digest("hex")));
|
|
111
112
|
stream.on("error", reject);
|
|
112
113
|
});
|
|
113
114
|
}
|
|
@@ -180,6 +181,13 @@ var PythonEnvManager = class {
|
|
|
180
181
|
// src/addon/addon-loader.ts
|
|
181
182
|
import * as fs3 from "fs";
|
|
182
183
|
import * as path3 from "path";
|
|
184
|
+
function resolveAddonClass(mod) {
|
|
185
|
+
let candidate = mod["default"] ?? mod[Object.keys(mod)[0]];
|
|
186
|
+
if (candidate && typeof candidate === "object" && "default" in candidate) {
|
|
187
|
+
candidate = candidate["default"];
|
|
188
|
+
}
|
|
189
|
+
return typeof candidate === "function" ? candidate : void 0;
|
|
190
|
+
}
|
|
183
191
|
var AddonLoader = class {
|
|
184
192
|
addons = /* @__PURE__ */ new Map();
|
|
185
193
|
/** Load all addons from an npm package */
|
|
@@ -195,7 +203,7 @@ var AddonLoader = class {
|
|
|
195
203
|
`${packageName}/${declaration.entry.replace("./dist/", "").replace(/\.js$/, "")}`
|
|
196
204
|
);
|
|
197
205
|
const mod = await import(entryPath);
|
|
198
|
-
const AddonClass =
|
|
206
|
+
const AddonClass = resolveAddonClass(mod);
|
|
199
207
|
if (!AddonClass) {
|
|
200
208
|
throw new Error(`Addon ${declaration.id} from ${packageName} has no default export`);
|
|
201
209
|
}
|
|
@@ -207,14 +215,19 @@ var AddonLoader = class {
|
|
|
207
215
|
}
|
|
208
216
|
}
|
|
209
217
|
/** Load addon from a direct path (for development/testing) */
|
|
210
|
-
async loadFromPath(addonId, modulePath, packageName) {
|
|
218
|
+
async loadFromPath(addonId, modulePath, packageName, declaration) {
|
|
211
219
|
const mod = await import(modulePath);
|
|
212
|
-
const AddonClass =
|
|
220
|
+
const AddonClass = resolveAddonClass(mod);
|
|
213
221
|
if (!AddonClass) {
|
|
214
222
|
throw new Error(`Module ${modulePath} has no default export`);
|
|
215
223
|
}
|
|
216
224
|
this.addons.set(addonId, {
|
|
217
|
-
declaration: {
|
|
225
|
+
declaration: {
|
|
226
|
+
id: addonId,
|
|
227
|
+
entry: modulePath,
|
|
228
|
+
slot: "detector",
|
|
229
|
+
...declaration
|
|
230
|
+
},
|
|
218
231
|
packageName,
|
|
219
232
|
addonClass: AddonClass
|
|
220
233
|
});
|
|
@@ -262,7 +275,7 @@ var AddonLoader = class {
|
|
|
262
275
|
for (const declaration of manifest.addons) {
|
|
263
276
|
const entryPath = path3.join(packagePath, declaration.entry);
|
|
264
277
|
const mod = await import(entryPath);
|
|
265
|
-
const AddonClass =
|
|
278
|
+
const AddonClass = resolveAddonClass(mod);
|
|
266
279
|
if (!AddonClass) {
|
|
267
280
|
throw new Error(`Addon ${declaration.id} from ${packageName} has no default export`);
|
|
268
281
|
}
|
|
@@ -359,7 +372,15 @@ var AddonInstaller = class {
|
|
|
359
372
|
}
|
|
360
373
|
/** Install builtin packages if not already present */
|
|
361
374
|
async installBuiltins() {
|
|
362
|
-
|
|
375
|
+
await this.ensureInstalled([...this.config.builtinPackages]);
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Ensure a set of packages are installed — installs any that are missing.
|
|
379
|
+
* This is the public entry-point used during boot to guarantee required
|
|
380
|
+
* addon packages are present before the loader tries to resolve them.
|
|
381
|
+
*/
|
|
382
|
+
async ensureInstalled(packages) {
|
|
383
|
+
const missing = packages.filter((pkg) => !this.isInstalled(pkg));
|
|
363
384
|
if (missing.length === 0) return;
|
|
364
385
|
await this.installPackages(missing);
|
|
365
386
|
}
|
|
@@ -441,6 +462,13 @@ var AddonInstaller = class {
|
|
|
441
462
|
timeout: 12e4
|
|
442
463
|
});
|
|
443
464
|
}
|
|
465
|
+
/** Install an addon from a local .tgz file */
|
|
466
|
+
async installFromTgz(tgzPath) {
|
|
467
|
+
await execFileAsync2("npm", ["install", "--save", tgzPath], {
|
|
468
|
+
cwd: this.config.addonsDir,
|
|
469
|
+
timeout: 6e4
|
|
470
|
+
});
|
|
471
|
+
}
|
|
444
472
|
/** Update all packages */
|
|
445
473
|
async updateAll() {
|
|
446
474
|
await execFileAsync2("npm", ["update"], {
|
|
@@ -454,9 +482,15 @@ var AddonInstaller = class {
|
|
|
454
482
|
}
|
|
455
483
|
};
|
|
456
484
|
var BUILTIN_PACKAGES = [
|
|
457
|
-
"@camstack/
|
|
458
|
-
"@camstack/
|
|
459
|
-
"@camstack/
|
|
485
|
+
"@camstack/addon-pipeline",
|
|
486
|
+
"@camstack/addon-vision",
|
|
487
|
+
"@camstack/addon-go2rtc",
|
|
488
|
+
"@camstack/addon-benchmark",
|
|
489
|
+
"@camstack/addon-cloudflare-tunnel",
|
|
490
|
+
"@camstack/addon-cloudflare-turn",
|
|
491
|
+
"@camstack/addon-provider-frigate",
|
|
492
|
+
"@camstack/addon-provider-onvif",
|
|
493
|
+
"@camstack/addon-provider-rtsp"
|
|
460
494
|
];
|
|
461
495
|
|
|
462
496
|
// src/pipeline/pipeline-validator.ts
|
|
@@ -526,6 +560,64 @@ var PipelineRunner = class {
|
|
|
526
560
|
frameTimestamp: frame.timestamp
|
|
527
561
|
};
|
|
528
562
|
}
|
|
563
|
+
/**
|
|
564
|
+
* Run only the audio classification node on an audio chunk.
|
|
565
|
+
* Used by the audio path in DetectionWiringService (separate from video pipeline).
|
|
566
|
+
*/
|
|
567
|
+
async runAudioNode(chunk, audioNode) {
|
|
568
|
+
const startTime = performance.now();
|
|
569
|
+
const results = [];
|
|
570
|
+
const timings = {};
|
|
571
|
+
const resultId = randomUUID();
|
|
572
|
+
const stepStart = performance.now();
|
|
573
|
+
try {
|
|
574
|
+
const globalConfig = this.addonConfigs.get(audioNode.addon) ?? {};
|
|
575
|
+
const engine = await this.engineManager.getOrCreateEngine(
|
|
576
|
+
audioNode.addon,
|
|
577
|
+
globalConfig,
|
|
578
|
+
audioNode.configOverride
|
|
579
|
+
);
|
|
580
|
+
if (!("classifyAudio" in engine) || typeof engine["classifyAudio"] !== "function") {
|
|
581
|
+
throw new Error(`Addon "${audioNode.addon}" has no classifyAudio method`);
|
|
582
|
+
}
|
|
583
|
+
const output = await engine.classifyAudio(chunk);
|
|
584
|
+
const stepMs = performance.now() - stepStart;
|
|
585
|
+
const stepResult = {
|
|
586
|
+
addon: audioNode.addon,
|
|
587
|
+
slot: "classifier",
|
|
588
|
+
output,
|
|
589
|
+
resultId,
|
|
590
|
+
inferenceMs: output.inferenceMs,
|
|
591
|
+
preprocessMs: 0,
|
|
592
|
+
totalMs: stepMs
|
|
593
|
+
};
|
|
594
|
+
results.push(stepResult);
|
|
595
|
+
timings[audioNode.step] = stepMs;
|
|
596
|
+
} catch (error) {
|
|
597
|
+
const stepMs = performance.now() - stepStart;
|
|
598
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
599
|
+
results.push({
|
|
600
|
+
addon: audioNode.addon,
|
|
601
|
+
slot: "classifier",
|
|
602
|
+
output: { classifications: [], inferenceMs: 0, modelId: "" },
|
|
603
|
+
resultId,
|
|
604
|
+
inferenceMs: 0,
|
|
605
|
+
preprocessMs: 0,
|
|
606
|
+
totalMs: stepMs,
|
|
607
|
+
error: {
|
|
608
|
+
code: "ADDON_ERROR",
|
|
609
|
+
message,
|
|
610
|
+
childrenSkipped: true
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
return {
|
|
615
|
+
results,
|
|
616
|
+
totalMs: performance.now() - startTime,
|
|
617
|
+
timings,
|
|
618
|
+
frameTimestamp: chunk.timestamp
|
|
619
|
+
};
|
|
620
|
+
}
|
|
529
621
|
async executeNode(node, frame, parentDetection, results, timings) {
|
|
530
622
|
const resultId = randomUUID();
|
|
531
623
|
const stepStart = performance.now();
|
|
@@ -605,26 +697,3838 @@ var PipelineRunner = class {
|
|
|
605
697
|
}
|
|
606
698
|
}
|
|
607
699
|
async executeChildren(children, frame, parentDetections, parentResultId, results, timings) {
|
|
608
|
-
const
|
|
700
|
+
const promises2 = [];
|
|
609
701
|
for (const detection of parentDetections) {
|
|
610
702
|
for (const child of children) {
|
|
611
|
-
|
|
703
|
+
promises2.push(
|
|
612
704
|
this.executeNode(child, frame, { det: detection, resultId: parentResultId }, results, timings)
|
|
613
705
|
);
|
|
614
706
|
}
|
|
615
707
|
}
|
|
616
|
-
await Promise.allSettled(
|
|
708
|
+
await Promise.allSettled(promises2);
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
// src/capability/capability-registry.ts
|
|
713
|
+
var CapabilityRegistry = class {
|
|
714
|
+
constructor(logger, configReader) {
|
|
715
|
+
this.logger = logger;
|
|
716
|
+
this.configReader = configReader;
|
|
717
|
+
}
|
|
718
|
+
capabilities = /* @__PURE__ */ new Map();
|
|
719
|
+
/** Per-device singleton overrides: deviceId → (capability → addonId) */
|
|
720
|
+
deviceOverrides = /* @__PURE__ */ new Map();
|
|
721
|
+
/** Per-device collection filters: deviceId → (capability → addonIds[]) */
|
|
722
|
+
deviceCollectionFilters = /* @__PURE__ */ new Map();
|
|
723
|
+
/**
|
|
724
|
+
* Declare a capability (typically called when addon manifests are loaded).
|
|
725
|
+
* Must be called before registerProvider/registerConsumer for that capability.
|
|
726
|
+
*/
|
|
727
|
+
declareCapability(declaration) {
|
|
728
|
+
if (this.capabilities.has(declaration.name)) {
|
|
729
|
+
this.logger.debug(`Capability "${declaration.name}" already declared, skipping`);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
this.capabilities.set(declaration.name, {
|
|
733
|
+
declaration,
|
|
734
|
+
available: /* @__PURE__ */ new Map(),
|
|
735
|
+
activeAddonId: null,
|
|
736
|
+
activeProvider: null,
|
|
737
|
+
activeCollection: [],
|
|
738
|
+
consumers: /* @__PURE__ */ new Set()
|
|
739
|
+
});
|
|
740
|
+
this.logger.debug(`Capability declared: ${declaration.name} (mode=${declaration.mode})`);
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Register a capability provider (called by addon loader when addon is enabled).
|
|
744
|
+
* For singleton: auto-activates if user-preferred or first registered.
|
|
745
|
+
* For collection: adds to active set and notifies consumers.
|
|
746
|
+
*/
|
|
747
|
+
registerProvider(capability, addonId, provider) {
|
|
748
|
+
const state = this.capabilities.get(capability);
|
|
749
|
+
if (!state) {
|
|
750
|
+
this.logger.warn(`Cannot register provider for undeclared capability "${capability}"`);
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
state.available.set(addonId, provider);
|
|
754
|
+
this.logger.info(`Provider registered: ${addonId} \u2192 ${capability}`);
|
|
755
|
+
if (state.declaration.mode === "singleton") {
|
|
756
|
+
const userChoice = this.configReader(capability);
|
|
757
|
+
if (userChoice === addonId) {
|
|
758
|
+
this.activateSingleton(state, addonId, provider);
|
|
759
|
+
} else if (userChoice === void 0 && state.activeAddonId === null) {
|
|
760
|
+
this.activateSingleton(state, addonId, provider);
|
|
761
|
+
}
|
|
762
|
+
} else {
|
|
763
|
+
state.activeCollection.push({ addonId, provider });
|
|
764
|
+
for (const consumer of state.consumers) {
|
|
765
|
+
if (consumer.onAdded) {
|
|
766
|
+
try {
|
|
767
|
+
consumer.onAdded(provider);
|
|
768
|
+
} catch (error) {
|
|
769
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
770
|
+
this.logger.error(`Consumer onAdded failed for ${capability}: ${msg}`);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Unregister a provider (called when addon is disabled/uninstalled).
|
|
778
|
+
*/
|
|
779
|
+
unregisterProvider(capability, addonId) {
|
|
780
|
+
const state = this.capabilities.get(capability);
|
|
781
|
+
if (!state) return;
|
|
782
|
+
const provider = state.available.get(addonId);
|
|
783
|
+
state.available.delete(addonId);
|
|
784
|
+
if (state.declaration.mode === "singleton") {
|
|
785
|
+
if (state.activeAddonId === addonId) {
|
|
786
|
+
state.activeAddonId = null;
|
|
787
|
+
state.activeProvider = null;
|
|
788
|
+
this.logger.info(`Singleton deactivated: ${capability} (was ${addonId})`);
|
|
789
|
+
}
|
|
790
|
+
} else {
|
|
791
|
+
const idx = state.activeCollection.findIndex((e) => e.addonId === addonId);
|
|
792
|
+
if (idx !== -1) {
|
|
793
|
+
state.activeCollection.splice(idx, 1);
|
|
794
|
+
for (const consumer of state.consumers) {
|
|
795
|
+
if (consumer.onRemoved && provider !== void 0) {
|
|
796
|
+
try {
|
|
797
|
+
consumer.onRemoved(provider);
|
|
798
|
+
} catch (error) {
|
|
799
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
800
|
+
this.logger.error(`Consumer onRemoved failed for ${capability}: ${msg}`);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Register a consumer that wants to be notified when providers change.
|
|
809
|
+
* If a provider is already active, the consumer is immediately notified.
|
|
810
|
+
* Returns a disposer function for cleanup.
|
|
811
|
+
*/
|
|
812
|
+
registerConsumer(registration) {
|
|
813
|
+
const state = this.capabilities.get(registration.capability);
|
|
814
|
+
if (!state) {
|
|
815
|
+
this.logger.debug(`Consumer registered for undeclared capability "${registration.capability}" \u2014 auto-declaring`);
|
|
816
|
+
this.declareCapability({ name: registration.capability, mode: "singleton" });
|
|
817
|
+
return this.registerConsumer(registration);
|
|
818
|
+
}
|
|
819
|
+
const untypedReg = registration;
|
|
820
|
+
state.consumers.add(untypedReg);
|
|
821
|
+
if (state.declaration.mode === "singleton") {
|
|
822
|
+
if (state.activeProvider !== null && registration.onSet) {
|
|
823
|
+
try {
|
|
824
|
+
registration.onSet(state.activeProvider);
|
|
825
|
+
} catch (error) {
|
|
826
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
827
|
+
this.logger.error(`Consumer onSet (immediate) failed for ${registration.capability}: ${msg}`);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
} else {
|
|
831
|
+
if (registration.onAdded) {
|
|
832
|
+
for (const entry of state.activeCollection) {
|
|
833
|
+
try {
|
|
834
|
+
registration.onAdded(entry.provider);
|
|
835
|
+
} catch (error) {
|
|
836
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
837
|
+
this.logger.error(`Consumer onAdded (immediate) failed for ${registration.capability}: ${msg}`);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
return () => {
|
|
843
|
+
state.consumers.delete(untypedReg);
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Get the active singleton provider for a capability.
|
|
848
|
+
* Returns null if none set.
|
|
849
|
+
*/
|
|
850
|
+
getSingleton(capability) {
|
|
851
|
+
const state = this.capabilities.get(capability);
|
|
852
|
+
if (!state || state.declaration.mode !== "singleton") return null;
|
|
853
|
+
return state.activeProvider ?? null;
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Get all active collection providers for a capability.
|
|
857
|
+
*/
|
|
858
|
+
getCollection(capability) {
|
|
859
|
+
const state = this.capabilities.get(capability);
|
|
860
|
+
if (!state || state.declaration.mode !== "collection") return [];
|
|
861
|
+
return state.activeCollection.map((e) => e.provider);
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Set which addon should be the active singleton for a capability.
|
|
865
|
+
* Call with `immediate: true` to also swap the runtime provider now
|
|
866
|
+
* (consumers' onSet will be awaited).
|
|
867
|
+
*/
|
|
868
|
+
async setActiveSingleton(capability, addonId, immediate = false) {
|
|
869
|
+
const state = this.capabilities.get(capability);
|
|
870
|
+
if (!state) {
|
|
871
|
+
throw new Error(`Unknown capability: ${capability}`);
|
|
872
|
+
}
|
|
873
|
+
if (state.declaration.mode !== "singleton") {
|
|
874
|
+
throw new Error(`Capability "${capability}" is not a singleton`);
|
|
875
|
+
}
|
|
876
|
+
const provider = state.available.get(addonId);
|
|
877
|
+
if (!provider) {
|
|
878
|
+
throw new Error(`No provider "${addonId}" registered for capability "${capability}"`);
|
|
879
|
+
}
|
|
880
|
+
if (immediate) {
|
|
881
|
+
await this.activateSingletonAsync(state, addonId, provider);
|
|
882
|
+
}
|
|
883
|
+
this.logger.info(`Singleton preference set: ${capability} \u2192 ${addonId}`);
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Get the mode declared for a capability.
|
|
887
|
+
*/
|
|
888
|
+
getMode(capability) {
|
|
889
|
+
return this.capabilities.get(capability)?.declaration.mode;
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* List all registered capabilities with their providers.
|
|
893
|
+
*/
|
|
894
|
+
listCapabilities() {
|
|
895
|
+
const result = [];
|
|
896
|
+
for (const [name, state] of this.capabilities) {
|
|
897
|
+
result.push({
|
|
898
|
+
name,
|
|
899
|
+
mode: state.declaration.mode,
|
|
900
|
+
providers: [...state.available.keys()],
|
|
901
|
+
activeProvider: state.activeAddonId
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
return result;
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Check if all dependencies for a capability are satisfied (have active providers).
|
|
908
|
+
*/
|
|
909
|
+
areDependenciesMet(declaration) {
|
|
910
|
+
if (!declaration.dependsOn?.length) return true;
|
|
911
|
+
return declaration.dependsOn.every((dep) => {
|
|
912
|
+
const state = this.capabilities.get(dep);
|
|
913
|
+
if (!state) return false;
|
|
914
|
+
if (state.declaration.mode === "singleton") {
|
|
915
|
+
return state.activeProvider !== null;
|
|
916
|
+
}
|
|
917
|
+
return state.activeCollection.length > 0;
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Get the dependency-ordered list of capability names for boot sequencing.
|
|
922
|
+
* Returns capabilities sorted topologically by dependsOn.
|
|
923
|
+
* Throws if a cycle is detected.
|
|
924
|
+
*/
|
|
925
|
+
getBootOrder() {
|
|
926
|
+
const visited = /* @__PURE__ */ new Set();
|
|
927
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
928
|
+
const order = [];
|
|
929
|
+
const visit = (name) => {
|
|
930
|
+
if (visited.has(name)) return;
|
|
931
|
+
if (visiting.has(name)) {
|
|
932
|
+
throw new Error(`Circular dependency detected involving capability "${name}"`);
|
|
933
|
+
}
|
|
934
|
+
visiting.add(name);
|
|
935
|
+
const state = this.capabilities.get(name);
|
|
936
|
+
if (state?.declaration.dependsOn) {
|
|
937
|
+
for (const dep of state.declaration.dependsOn) {
|
|
938
|
+
visit(dep);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
visiting.delete(name);
|
|
942
|
+
visited.add(name);
|
|
943
|
+
order.push(name);
|
|
944
|
+
};
|
|
945
|
+
for (const name of this.capabilities.keys()) {
|
|
946
|
+
visit(name);
|
|
947
|
+
}
|
|
948
|
+
return order;
|
|
949
|
+
}
|
|
950
|
+
// ---- Per-device overrides ----
|
|
951
|
+
/**
|
|
952
|
+
* Set a per-device singleton override. When resolveForDevice is called for
|
|
953
|
+
* this device + capability, the specified addon's provider is returned
|
|
954
|
+
* instead of the global singleton.
|
|
955
|
+
*/
|
|
956
|
+
setDeviceOverride(deviceId, capability, addonId) {
|
|
957
|
+
const state = this.capabilities.get(capability);
|
|
958
|
+
if (!state) {
|
|
959
|
+
this.logger.warn(`Cannot set device override for undeclared capability "${capability}"`);
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
if (!state.available.has(addonId)) {
|
|
963
|
+
this.logger.warn(`Cannot set device override: addon "${addonId}" not registered for "${capability}"`);
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
let deviceMap = this.deviceOverrides.get(deviceId);
|
|
967
|
+
if (!deviceMap) {
|
|
968
|
+
deviceMap = /* @__PURE__ */ new Map();
|
|
969
|
+
this.deviceOverrides.set(deviceId, deviceMap);
|
|
970
|
+
}
|
|
971
|
+
deviceMap.set(capability, addonId);
|
|
972
|
+
this.logger.info(`Device override set: ${deviceId} \u2192 ${capability} = ${addonId}`);
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Clear a per-device singleton override, reverting to the global singleton.
|
|
976
|
+
*/
|
|
977
|
+
clearDeviceOverride(deviceId, capability) {
|
|
978
|
+
const deviceMap = this.deviceOverrides.get(deviceId);
|
|
979
|
+
if (!deviceMap) return;
|
|
980
|
+
deviceMap.delete(capability);
|
|
981
|
+
if (deviceMap.size === 0) {
|
|
982
|
+
this.deviceOverrides.delete(deviceId);
|
|
983
|
+
}
|
|
984
|
+
this.logger.info(`Device override cleared: ${deviceId} \u2192 ${capability}`);
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Get all per-device singleton overrides for a device.
|
|
988
|
+
* Returns a Map of capability name to addon ID.
|
|
989
|
+
*/
|
|
990
|
+
getDeviceOverrides(deviceId) {
|
|
991
|
+
return new Map(this.deviceOverrides.get(deviceId) ?? []);
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Resolve a singleton provider for a specific device.
|
|
995
|
+
* 1. Check device override — return that addon's provider
|
|
996
|
+
* 2. Fallback to global singleton
|
|
997
|
+
*/
|
|
998
|
+
resolveForDevice(capability, deviceId) {
|
|
999
|
+
const state = this.capabilities.get(capability);
|
|
1000
|
+
if (!state || state.declaration.mode !== "singleton") return null;
|
|
1001
|
+
const deviceMap = this.deviceOverrides.get(deviceId);
|
|
1002
|
+
if (deviceMap) {
|
|
1003
|
+
const overrideAddonId = deviceMap.get(capability);
|
|
1004
|
+
if (overrideAddonId) {
|
|
1005
|
+
const provider = state.available.get(overrideAddonId);
|
|
1006
|
+
if (provider) return provider;
|
|
1007
|
+
this.logger.warn(
|
|
1008
|
+
`Device override for ${deviceId}/${capability} references unregistered addon "${overrideAddonId}" \u2014 falling back to global`
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
return state.activeProvider ?? null;
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Set a per-device collection filter. When resolveCollectionForDevice is called
|
|
1016
|
+
* for this device + capability, only providers from the specified addon IDs
|
|
1017
|
+
* are returned instead of the full collection.
|
|
1018
|
+
*/
|
|
1019
|
+
setDeviceCollectionFilter(deviceId, capability, addonIds) {
|
|
1020
|
+
const state = this.capabilities.get(capability);
|
|
1021
|
+
if (!state) {
|
|
1022
|
+
this.logger.warn(`Cannot set device collection filter for undeclared capability "${capability}"`);
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
let deviceMap = this.deviceCollectionFilters.get(deviceId);
|
|
1026
|
+
if (!deviceMap) {
|
|
1027
|
+
deviceMap = /* @__PURE__ */ new Map();
|
|
1028
|
+
this.deviceCollectionFilters.set(deviceId, deviceMap);
|
|
1029
|
+
}
|
|
1030
|
+
deviceMap.set(capability, [...addonIds]);
|
|
1031
|
+
this.logger.info(`Device collection filter set: ${deviceId} \u2192 ${capability} = [${addonIds.join(", ")}]`);
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* Clear a per-device collection filter, reverting to the full collection.
|
|
1035
|
+
*/
|
|
1036
|
+
clearDeviceCollectionFilter(deviceId, capability) {
|
|
1037
|
+
const deviceMap = this.deviceCollectionFilters.get(deviceId);
|
|
1038
|
+
if (!deviceMap) return;
|
|
1039
|
+
deviceMap.delete(capability);
|
|
1040
|
+
if (deviceMap.size === 0) {
|
|
1041
|
+
this.deviceCollectionFilters.delete(deviceId);
|
|
1042
|
+
}
|
|
1043
|
+
this.logger.info(`Device collection filter cleared: ${deviceId} \u2192 ${capability}`);
|
|
1044
|
+
}
|
|
1045
|
+
/**
|
|
1046
|
+
* Resolve collection providers for a specific device.
|
|
1047
|
+
* If a filter exists for the device + capability, only those addon's providers are returned.
|
|
1048
|
+
* If no filter exists, the full collection is returned.
|
|
1049
|
+
*/
|
|
1050
|
+
resolveCollectionForDevice(capability, deviceId) {
|
|
1051
|
+
const state = this.capabilities.get(capability);
|
|
1052
|
+
if (!state || state.declaration.mode !== "collection") return [];
|
|
1053
|
+
const deviceMap = this.deviceCollectionFilters.get(deviceId);
|
|
1054
|
+
if (deviceMap) {
|
|
1055
|
+
const filterAddonIds = deviceMap.get(capability);
|
|
1056
|
+
if (filterAddonIds) {
|
|
1057
|
+
const filterSet = new Set(filterAddonIds);
|
|
1058
|
+
return state.activeCollection.filter((e) => filterSet.has(e.addonId)).map((e) => e.provider);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
return state.activeCollection.map((e) => e.provider);
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Get a specific addon's provider by addon ID, regardless of whether it's the active singleton.
|
|
1065
|
+
* Useful for per-device overrides that need to look up any registered provider.
|
|
1066
|
+
*/
|
|
1067
|
+
getProviderByAddonId(capability, addonId) {
|
|
1068
|
+
const state = this.capabilities.get(capability);
|
|
1069
|
+
if (!state) return null;
|
|
1070
|
+
const provider = state.available.get(addonId);
|
|
1071
|
+
return provider ?? null;
|
|
1072
|
+
}
|
|
1073
|
+
activateSingleton(state, addonId, provider) {
|
|
1074
|
+
state.activeAddonId = addonId;
|
|
1075
|
+
state.activeProvider = provider;
|
|
1076
|
+
this.logger.info(`Singleton activated: ${state.declaration.name} \u2192 ${addonId}`);
|
|
1077
|
+
for (const consumer of state.consumers) {
|
|
1078
|
+
if (consumer.onSet) {
|
|
1079
|
+
try {
|
|
1080
|
+
consumer.onSet(provider);
|
|
1081
|
+
} catch (error) {
|
|
1082
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
1083
|
+
this.logger.error(`Consumer onSet failed for ${state.declaration.name}: ${msg}`);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
async activateSingletonAsync(state, addonId, provider) {
|
|
1089
|
+
state.activeAddonId = addonId;
|
|
1090
|
+
state.activeProvider = provider;
|
|
1091
|
+
this.logger.info(`Singleton activated (async): ${state.declaration.name} \u2192 ${addonId}`);
|
|
1092
|
+
for (const consumer of state.consumers) {
|
|
1093
|
+
if (consumer.onSet) {
|
|
1094
|
+
try {
|
|
1095
|
+
await consumer.onSet(provider);
|
|
1096
|
+
} catch (error) {
|
|
1097
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
1098
|
+
this.logger.error(`Consumer onSet (async) failed for ${state.declaration.name}: ${msg}`);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1104
|
+
|
|
1105
|
+
// src/capability/infra-capabilities.ts
|
|
1106
|
+
var INFRA_CAPABILITIES = [
|
|
1107
|
+
{ name: "storage", required: true },
|
|
1108
|
+
{ name: "log-destination", required: false }
|
|
1109
|
+
];
|
|
1110
|
+
var infraNames = new Set(INFRA_CAPABILITIES.map((c) => c.name));
|
|
1111
|
+
function isInfraCapability(name) {
|
|
1112
|
+
return infraNames.has(name);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// src/process/managed-process.ts
|
|
1116
|
+
import { fork, spawn as spawn2 } from "child_process";
|
|
1117
|
+
var DEFAULT_MAX_RESTARTS = 10;
|
|
1118
|
+
var MAX_BACKOFF_MS = 6e4;
|
|
1119
|
+
var GRACEFUL_SHUTDOWN_MS = 5e3;
|
|
1120
|
+
var ManagedProcess = class {
|
|
1121
|
+
constructor(config, events, logger) {
|
|
1122
|
+
this.config = config;
|
|
1123
|
+
this.events = events;
|
|
1124
|
+
this.logger = logger;
|
|
1125
|
+
}
|
|
1126
|
+
childProcess = null;
|
|
1127
|
+
_state = "stopped";
|
|
1128
|
+
_startedAt;
|
|
1129
|
+
restartTimer;
|
|
1130
|
+
_restartCount = 0;
|
|
1131
|
+
_lastCrashAt;
|
|
1132
|
+
_lastCrashError;
|
|
1133
|
+
get state() {
|
|
1134
|
+
return this._state;
|
|
1135
|
+
}
|
|
1136
|
+
async start() {
|
|
1137
|
+
this._state = "starting";
|
|
1138
|
+
try {
|
|
1139
|
+
if (this.config.modulePath) {
|
|
1140
|
+
this.childProcess = fork(this.config.modulePath, this.config.args ?? [], {
|
|
1141
|
+
env: { ...process.env, ...this.config.env },
|
|
1142
|
+
stdio: ["pipe", "pipe", "pipe", "ipc"]
|
|
1143
|
+
});
|
|
1144
|
+
} else if (this.config.command) {
|
|
1145
|
+
this.childProcess = spawn2(this.config.command, this.config.args ?? [], {
|
|
1146
|
+
env: { ...process.env, ...this.config.env },
|
|
1147
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
1148
|
+
});
|
|
1149
|
+
} else {
|
|
1150
|
+
throw new Error("No command or modulePath specified");
|
|
1151
|
+
}
|
|
1152
|
+
this.childProcess.stdout?.on("data", (data) => {
|
|
1153
|
+
this.logger.debug(data.toString().trim());
|
|
1154
|
+
});
|
|
1155
|
+
this.childProcess.stderr?.on("data", (data) => {
|
|
1156
|
+
this.logger.warn(data.toString().trim());
|
|
1157
|
+
});
|
|
1158
|
+
this.childProcess.on("exit", (code, signal) => {
|
|
1159
|
+
const msg = `Process exited: code=${code}, signal=${signal}`;
|
|
1160
|
+
if (code === 0) {
|
|
1161
|
+
this.logger.info(msg);
|
|
1162
|
+
this._state = "stopped";
|
|
1163
|
+
} else {
|
|
1164
|
+
this._lastCrashAt = Date.now();
|
|
1165
|
+
this._lastCrashError = msg;
|
|
1166
|
+
this.logger.error(msg);
|
|
1167
|
+
this._state = "error";
|
|
1168
|
+
this.events.emitProcessCrashed(
|
|
1169
|
+
this.config.id,
|
|
1170
|
+
code,
|
|
1171
|
+
signal,
|
|
1172
|
+
this._restartCount
|
|
1173
|
+
);
|
|
1174
|
+
const maxRestarts = this.config.maxRestarts ?? DEFAULT_MAX_RESTARTS;
|
|
1175
|
+
if (this.config.autoRestart && this._restartCount < maxRestarts) {
|
|
1176
|
+
this.scheduleRestart();
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
this.childProcess = null;
|
|
1180
|
+
});
|
|
1181
|
+
this.childProcess.on("error", (err) => {
|
|
1182
|
+
this.logger.error(`Process error: ${err.message}`);
|
|
1183
|
+
this._lastCrashError = err.message;
|
|
1184
|
+
this._state = "error";
|
|
1185
|
+
});
|
|
1186
|
+
this._state = "running";
|
|
1187
|
+
this._startedAt = Date.now();
|
|
1188
|
+
} catch (err) {
|
|
1189
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1190
|
+
this._state = "error";
|
|
1191
|
+
this._lastCrashError = msg;
|
|
1192
|
+
throw err;
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
async stop() {
|
|
1196
|
+
if (this.restartTimer) {
|
|
1197
|
+
clearTimeout(this.restartTimer);
|
|
1198
|
+
this.restartTimer = void 0;
|
|
1199
|
+
}
|
|
1200
|
+
if (this._state === "stopped") {
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
this._state = "stopping";
|
|
1204
|
+
if (this.childProcess && !this.childProcess.killed) {
|
|
1205
|
+
this.childProcess.kill("SIGTERM");
|
|
1206
|
+
await new Promise((resolve) => {
|
|
1207
|
+
const timeout = setTimeout(() => {
|
|
1208
|
+
if (this.childProcess && !this.childProcess.killed) {
|
|
1209
|
+
this.childProcess.kill("SIGKILL");
|
|
1210
|
+
}
|
|
1211
|
+
resolve();
|
|
1212
|
+
}, GRACEFUL_SHUTDOWN_MS);
|
|
1213
|
+
this.childProcess?.on("exit", () => {
|
|
1214
|
+
clearTimeout(timeout);
|
|
1215
|
+
resolve();
|
|
1216
|
+
});
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
const currentState = this._state;
|
|
1220
|
+
if (currentState !== "stopped") {
|
|
1221
|
+
this._state = "stopped";
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
getStatus() {
|
|
1225
|
+
return {
|
|
1226
|
+
id: this.config.id,
|
|
1227
|
+
label: this.config.label,
|
|
1228
|
+
state: this._state,
|
|
1229
|
+
pid: this.childProcess?.pid,
|
|
1230
|
+
stats: this.childProcess?.pid ? this.getStats() : void 0,
|
|
1231
|
+
lastCrashAt: this._lastCrashAt,
|
|
1232
|
+
lastCrashError: this._lastCrashError,
|
|
1233
|
+
restartCount: this._restartCount,
|
|
1234
|
+
nextRestartAt: this.restartTimer ? this.getNextRestartTime() : void 0
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
scheduleRestart() {
|
|
1238
|
+
this._restartCount++;
|
|
1239
|
+
const delayMs = Math.min(1e3 * Math.pow(2, this._restartCount - 1), MAX_BACKOFF_MS);
|
|
1240
|
+
this.logger.info(`Scheduling restart #${this._restartCount} in ${delayMs}ms`);
|
|
1241
|
+
this.events.emitProcessRestartScheduled(
|
|
1242
|
+
this.config.id,
|
|
1243
|
+
this._restartCount,
|
|
1244
|
+
delayMs
|
|
1245
|
+
);
|
|
1246
|
+
this.restartTimer = setTimeout(async () => {
|
|
1247
|
+
this.restartTimer = void 0;
|
|
1248
|
+
try {
|
|
1249
|
+
await this.start();
|
|
1250
|
+
this.events.emitProcessRestarted(this.config.id, this._restartCount);
|
|
1251
|
+
} catch (err) {
|
|
1252
|
+
this.logger.error(`Restart failed: ${err}`);
|
|
1253
|
+
}
|
|
1254
|
+
}, delayMs);
|
|
1255
|
+
}
|
|
1256
|
+
getStats() {
|
|
1257
|
+
if (!this.childProcess?.pid) return void 0;
|
|
1258
|
+
const uptime = this._startedAt ? Date.now() - this._startedAt : 0;
|
|
1259
|
+
return {
|
|
1260
|
+
pid: this.childProcess.pid,
|
|
1261
|
+
cpu: 0,
|
|
1262
|
+
memory: 0,
|
|
1263
|
+
uptime,
|
|
1264
|
+
restartCount: this._restartCount
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
getNextRestartTime() {
|
|
1268
|
+
if (!this._lastCrashAt) return void 0;
|
|
1269
|
+
const delayMs = Math.min(1e3 * Math.pow(2, this._restartCount - 1), MAX_BACKOFF_MS);
|
|
1270
|
+
return this._lastCrashAt + delayMs;
|
|
1271
|
+
}
|
|
1272
|
+
};
|
|
1273
|
+
|
|
1274
|
+
// src/process/process-manager.ts
|
|
1275
|
+
var ProcessManager = class {
|
|
1276
|
+
constructor(events, loggerFactory) {
|
|
1277
|
+
this.events = events;
|
|
1278
|
+
this.loggerFactory = loggerFactory;
|
|
1279
|
+
}
|
|
1280
|
+
processes = /* @__PURE__ */ new Map();
|
|
1281
|
+
register(config) {
|
|
1282
|
+
if (this.processes.has(config.id)) {
|
|
1283
|
+
throw new Error(`Process already registered: ${config.id}`);
|
|
1284
|
+
}
|
|
1285
|
+
const logger = this.loggerFactory.createLogger(`process:${config.id}`);
|
|
1286
|
+
const managed = new ManagedProcess(config, this.events, logger);
|
|
1287
|
+
this.processes.set(config.id, managed);
|
|
1288
|
+
return managed;
|
|
1289
|
+
}
|
|
1290
|
+
async start(id) {
|
|
1291
|
+
const p = this.get(id);
|
|
1292
|
+
await p.start();
|
|
1293
|
+
}
|
|
1294
|
+
async stop(id) {
|
|
1295
|
+
const p = this.get(id);
|
|
1296
|
+
await p.stop();
|
|
1297
|
+
}
|
|
1298
|
+
async restart(id) {
|
|
1299
|
+
const p = this.get(id);
|
|
1300
|
+
await p.stop();
|
|
1301
|
+
await p.start();
|
|
1302
|
+
}
|
|
1303
|
+
get(id) {
|
|
1304
|
+
const p = this.processes.get(id);
|
|
1305
|
+
if (!p) throw new Error(`Process not found: ${id}`);
|
|
1306
|
+
return p;
|
|
1307
|
+
}
|
|
1308
|
+
listAll() {
|
|
1309
|
+
return [...this.processes.values()].map((p) => p.getStatus());
|
|
1310
|
+
}
|
|
1311
|
+
async shutdownAll() {
|
|
1312
|
+
const running = [...this.processes.values()].filter(
|
|
1313
|
+
(p) => p.getStatus().state === "running"
|
|
1314
|
+
);
|
|
1315
|
+
await Promise.all(running.map((p) => p.stop()));
|
|
1316
|
+
}
|
|
1317
|
+
};
|
|
1318
|
+
|
|
1319
|
+
// src/network/network-quality.ts
|
|
1320
|
+
var NetworkQualityTracker = class _NetworkQualityTracker {
|
|
1321
|
+
devices = /* @__PURE__ */ new Map();
|
|
1322
|
+
static MAX_SAMPLES = 30;
|
|
1323
|
+
// ~5 min at 10s reporting interval
|
|
1324
|
+
reportStreamStats(deviceId, streamId, bitrateKbps, packetLoss) {
|
|
1325
|
+
const device = this.getOrCreateDevice(deviceId);
|
|
1326
|
+
let stream = device.streams.get(streamId);
|
|
1327
|
+
if (!stream) {
|
|
1328
|
+
stream = { nominalBitrateKbps: bitrateKbps, samples: [], peakBitrateKbps: 0, packetLossSamples: [], lastUpdated: 0 };
|
|
1329
|
+
device.streams.set(streamId, stream);
|
|
1330
|
+
}
|
|
1331
|
+
stream.samples.push(bitrateKbps);
|
|
1332
|
+
if (stream.samples.length > _NetworkQualityTracker.MAX_SAMPLES) stream.samples.shift();
|
|
1333
|
+
stream.peakBitrateKbps = Math.max(stream.peakBitrateKbps, bitrateKbps);
|
|
1334
|
+
if (packetLoss !== void 0) {
|
|
1335
|
+
stream.packetLossSamples.push(packetLoss);
|
|
1336
|
+
if (stream.packetLossSamples.length > _NetworkQualityTracker.MAX_SAMPLES) stream.packetLossSamples.shift();
|
|
1337
|
+
}
|
|
1338
|
+
stream.lastUpdated = Date.now();
|
|
1339
|
+
}
|
|
1340
|
+
reportClientStats(deviceId, stats) {
|
|
1341
|
+
const device = this.getOrCreateDevice(deviceId);
|
|
1342
|
+
device.client = { ...stats, lastUpdated: Date.now() };
|
|
1343
|
+
}
|
|
1344
|
+
getDeviceStats(deviceId) {
|
|
1345
|
+
const device = this.devices.get(deviceId);
|
|
1346
|
+
if (!device) return null;
|
|
1347
|
+
const streams = {};
|
|
1348
|
+
for (const [streamId, s] of device.streams) {
|
|
1349
|
+
const avg = (arr) => arr.length === 0 ? 0 : arr.reduce((a, b) => a + b, 0) / arr.length;
|
|
1350
|
+
streams[streamId] = {
|
|
1351
|
+
nominalBitrateKbps: s.nominalBitrateKbps,
|
|
1352
|
+
observedBitrateKbps: Math.round(avg(s.samples)),
|
|
1353
|
+
peakBitrateKbps: s.peakBitrateKbps,
|
|
1354
|
+
packetLossPercent: Math.round(avg(s.packetLossSamples) * 10) / 10,
|
|
1355
|
+
lastUpdated: s.lastUpdated
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
return { deviceId, streams, client: device.client ?? void 0 };
|
|
1359
|
+
}
|
|
1360
|
+
getAllStats() {
|
|
1361
|
+
const results = [];
|
|
1362
|
+
for (const deviceId of this.devices.keys()) {
|
|
1363
|
+
const stats = this.getDeviceStats(deviceId);
|
|
1364
|
+
if (stats) results.push(stats);
|
|
1365
|
+
}
|
|
1366
|
+
return results;
|
|
1367
|
+
}
|
|
1368
|
+
getOrCreateDevice(deviceId) {
|
|
1369
|
+
let device = this.devices.get(deviceId);
|
|
1370
|
+
if (!device) {
|
|
1371
|
+
device = { streams: /* @__PURE__ */ new Map(), client: null };
|
|
1372
|
+
this.devices.set(deviceId, device);
|
|
1373
|
+
}
|
|
1374
|
+
return device;
|
|
1375
|
+
}
|
|
1376
|
+
};
|
|
1377
|
+
|
|
1378
|
+
// src/repl/repl-engine.ts
|
|
1379
|
+
import * as vm from "vm";
|
|
1380
|
+
import * as util from "util";
|
|
1381
|
+
var EXECUTION_TIMEOUT_MS = 5e3;
|
|
1382
|
+
var COMPLETION_TIMEOUT_MS = 1e3;
|
|
1383
|
+
var ReplEngine = class {
|
|
1384
|
+
constructor(contextProvider) {
|
|
1385
|
+
this.contextProvider = contextProvider;
|
|
1386
|
+
}
|
|
1387
|
+
async execute(code, context) {
|
|
1388
|
+
const start = Date.now();
|
|
1389
|
+
const sandbox = this.buildSandbox(context);
|
|
1390
|
+
try {
|
|
1391
|
+
const vmContext = vm.createContext(sandbox);
|
|
1392
|
+
const script = new vm.Script(code);
|
|
1393
|
+
let result = script.runInContext(vmContext, { timeout: EXECUTION_TIMEOUT_MS });
|
|
1394
|
+
if (result && typeof result.then === "function") {
|
|
1395
|
+
result = await Promise.race([
|
|
1396
|
+
result,
|
|
1397
|
+
new Promise(
|
|
1398
|
+
(_, reject) => setTimeout(() => reject(new Error("Async timeout (5s)")), EXECUTION_TIMEOUT_MS)
|
|
1399
|
+
)
|
|
1400
|
+
]);
|
|
1401
|
+
}
|
|
1402
|
+
const duration = Date.now() - start;
|
|
1403
|
+
if (result === void 0) {
|
|
1404
|
+
return { output: "undefined", type: "void", duration };
|
|
1405
|
+
}
|
|
1406
|
+
return {
|
|
1407
|
+
output: util.inspect(result, {
|
|
1408
|
+
depth: 4,
|
|
1409
|
+
colors: false,
|
|
1410
|
+
maxArrayLength: 50,
|
|
1411
|
+
maxStringLength: 1e3
|
|
1412
|
+
}),
|
|
1413
|
+
type: "value",
|
|
1414
|
+
duration
|
|
1415
|
+
};
|
|
1416
|
+
} catch (err) {
|
|
1417
|
+
const message = err instanceof Error ? err.message : typeof err === "object" && err !== null && "message" in err ? String(err.message) : String(err);
|
|
1418
|
+
return {
|
|
1419
|
+
output: message,
|
|
1420
|
+
type: "error",
|
|
1421
|
+
duration: Date.now() - start
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
async getCompletions(partial, context) {
|
|
1426
|
+
const sandbox = this.buildSandbox(context);
|
|
1427
|
+
const keys = Object.keys(sandbox);
|
|
1428
|
+
if (!partial) return keys;
|
|
1429
|
+
const lastDot = partial.lastIndexOf(".");
|
|
1430
|
+
if (lastDot === -1) {
|
|
1431
|
+
return keys.filter((k) => k.startsWith(partial));
|
|
1432
|
+
}
|
|
1433
|
+
const objPath = partial.substring(0, lastDot);
|
|
1434
|
+
const propPrefix = partial.substring(lastDot + 1);
|
|
1435
|
+
try {
|
|
1436
|
+
const vmContext = vm.createContext(sandbox);
|
|
1437
|
+
const obj = vm.runInContext(objPath, vmContext, { timeout: COMPLETION_TIMEOUT_MS });
|
|
1438
|
+
if (obj && typeof obj === "object") {
|
|
1439
|
+
const proto = Object.getPrototypeOf(obj);
|
|
1440
|
+
const objKeys = Object.getOwnPropertyNames(obj).concat(Object.getOwnPropertyNames(proto ?? {})).filter((k) => k.startsWith(propPrefix));
|
|
1441
|
+
return [...new Set(objKeys)].map((k) => `${objPath}.${k}`);
|
|
1442
|
+
}
|
|
1443
|
+
} catch {
|
|
1444
|
+
}
|
|
1445
|
+
return [];
|
|
1446
|
+
}
|
|
1447
|
+
buildSandbox(context) {
|
|
1448
|
+
const base = {
|
|
1449
|
+
console: {
|
|
1450
|
+
log: (...args) => util.inspect(args),
|
|
1451
|
+
warn: (...args) => util.inspect(args)
|
|
1452
|
+
},
|
|
1453
|
+
JSON,
|
|
1454
|
+
Math,
|
|
1455
|
+
Date,
|
|
1456
|
+
Array,
|
|
1457
|
+
Object,
|
|
1458
|
+
String,
|
|
1459
|
+
Number,
|
|
1460
|
+
Boolean,
|
|
1461
|
+
Map,
|
|
1462
|
+
Set,
|
|
1463
|
+
Promise,
|
|
1464
|
+
Buffer,
|
|
1465
|
+
// Blocked globals
|
|
1466
|
+
setTimeout: void 0,
|
|
1467
|
+
setInterval: void 0,
|
|
1468
|
+
fetch: void 0,
|
|
1469
|
+
require: void 0,
|
|
1470
|
+
process: void 0,
|
|
1471
|
+
// Merge user-provided variables
|
|
1472
|
+
...context.variables
|
|
1473
|
+
};
|
|
1474
|
+
switch (context.scope.type) {
|
|
1475
|
+
case "system":
|
|
1476
|
+
return { ...base, ...this.contextProvider.getSystemSandbox() };
|
|
1477
|
+
case "device":
|
|
1478
|
+
return { ...base, ...this.contextProvider.getDeviceSandbox(context.scope.deviceId) };
|
|
1479
|
+
case "provider":
|
|
1480
|
+
return { ...base, ...this.contextProvider.getProviderSandbox(context.scope.providerId) };
|
|
1481
|
+
case "addon":
|
|
1482
|
+
return { ...base, ...this.contextProvider.getAddonSandbox(context.scope.addonId) };
|
|
1483
|
+
default:
|
|
1484
|
+
return base;
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
};
|
|
1488
|
+
|
|
1489
|
+
// src/agent/agent-registry.ts
|
|
1490
|
+
var AgentRegistry = class {
|
|
1491
|
+
constructor(events, heartbeatTimeoutMs = 3e4, heartbeatCheckIntervalMs = 1e4) {
|
|
1492
|
+
this.events = events;
|
|
1493
|
+
this.heartbeatTimeoutMs = heartbeatTimeoutMs;
|
|
1494
|
+
this.heartbeatCheckIntervalMs = heartbeatCheckIntervalMs;
|
|
1495
|
+
}
|
|
1496
|
+
agents = /* @__PURE__ */ new Map();
|
|
1497
|
+
heartbeatIntervals = /* @__PURE__ */ new Map();
|
|
1498
|
+
/** Register a new agent */
|
|
1499
|
+
registerAgent(info) {
|
|
1500
|
+
const entry = {
|
|
1501
|
+
info,
|
|
1502
|
+
state: "online",
|
|
1503
|
+
connectedSince: Date.now(),
|
|
1504
|
+
lastHeartbeat: Date.now(),
|
|
1505
|
+
activeTaskCount: 0,
|
|
1506
|
+
completedTaskCount: 0,
|
|
1507
|
+
failedTaskCount: 0
|
|
1508
|
+
};
|
|
1509
|
+
this.agents.set(info.id, entry);
|
|
1510
|
+
this.startHeartbeatCheck(info.id);
|
|
1511
|
+
this.events.emitAgentRegistered(info.id, [...info.capabilities]);
|
|
1512
|
+
}
|
|
1513
|
+
/** Remove agent */
|
|
1514
|
+
unregisterAgent(id) {
|
|
1515
|
+
const interval = this.heartbeatIntervals.get(id);
|
|
1516
|
+
if (interval) {
|
|
1517
|
+
clearInterval(interval);
|
|
1518
|
+
this.heartbeatIntervals.delete(id);
|
|
1519
|
+
}
|
|
1520
|
+
this.agents.delete(id);
|
|
1521
|
+
this.events.emitAgentUnregistered(id);
|
|
1522
|
+
}
|
|
1523
|
+
/** Update heartbeat timestamp */
|
|
1524
|
+
heartbeat(id) {
|
|
1525
|
+
const entry = this.agents.get(id);
|
|
1526
|
+
if (!entry) return;
|
|
1527
|
+
entry.lastHeartbeat = Date.now();
|
|
1528
|
+
if (entry.state === "offline") {
|
|
1529
|
+
entry.state = "online";
|
|
1530
|
+
this.events.emitAgentOnline(id);
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
/** Find agents with a specific capability */
|
|
1534
|
+
getAgentsWithCapability(capability) {
|
|
1535
|
+
return [...this.agents.values()].filter(
|
|
1536
|
+
(a) => a.state === "online" && a.info.capabilities.includes(capability)
|
|
1537
|
+
);
|
|
1538
|
+
}
|
|
1539
|
+
/** Get best agent for a task (least loaded) */
|
|
1540
|
+
selectAgent(capability, preferredId) {
|
|
1541
|
+
const capable = this.getAgentsWithCapability(capability);
|
|
1542
|
+
if (capable.length === 0) return null;
|
|
1543
|
+
if (preferredId) {
|
|
1544
|
+
const preferred = capable.find((a) => a.info.id === preferredId);
|
|
1545
|
+
if (preferred) return preferred;
|
|
1546
|
+
}
|
|
1547
|
+
const sorted = [...capable].sort((a, b) => a.activeTaskCount - b.activeTaskCount);
|
|
1548
|
+
return sorted[0] ?? null;
|
|
1549
|
+
}
|
|
1550
|
+
/** List all agents with status */
|
|
1551
|
+
listAgents() {
|
|
1552
|
+
return [...this.agents.values()].map((entry) => ({
|
|
1553
|
+
id: entry.info.id,
|
|
1554
|
+
name: entry.info.name,
|
|
1555
|
+
state: entry.state,
|
|
1556
|
+
capabilities: [...entry.info.capabilities],
|
|
1557
|
+
lastHeartbeat: entry.lastHeartbeat,
|
|
1558
|
+
connectedSince: entry.connectedSince,
|
|
1559
|
+
resources: entry.info.resources,
|
|
1560
|
+
activeTaskCount: entry.activeTaskCount,
|
|
1561
|
+
completedTaskCount: entry.completedTaskCount,
|
|
1562
|
+
failedTaskCount: entry.failedTaskCount
|
|
1563
|
+
}));
|
|
1564
|
+
}
|
|
1565
|
+
/** Get single agent */
|
|
1566
|
+
getAgent(id) {
|
|
1567
|
+
return this.agents.get(id) ?? null;
|
|
1568
|
+
}
|
|
1569
|
+
/** Destroy all heartbeat intervals */
|
|
1570
|
+
destroy() {
|
|
1571
|
+
for (const interval of this.heartbeatIntervals.values()) {
|
|
1572
|
+
clearInterval(interval);
|
|
1573
|
+
}
|
|
1574
|
+
this.heartbeatIntervals.clear();
|
|
1575
|
+
}
|
|
1576
|
+
startHeartbeatCheck(id) {
|
|
1577
|
+
const interval = setInterval(() => {
|
|
1578
|
+
const entry = this.agents.get(id);
|
|
1579
|
+
if (!entry) return;
|
|
1580
|
+
const elapsed = Date.now() - entry.lastHeartbeat;
|
|
1581
|
+
if (elapsed > this.heartbeatTimeoutMs && entry.state === "online") {
|
|
1582
|
+
entry.state = "offline";
|
|
1583
|
+
this.events.emitAgentOffline(id);
|
|
1584
|
+
}
|
|
1585
|
+
}, this.heartbeatCheckIntervalMs);
|
|
1586
|
+
this.heartbeatIntervals.set(id, interval);
|
|
1587
|
+
}
|
|
1588
|
+
};
|
|
1589
|
+
|
|
1590
|
+
// src/agent/task-dispatcher.ts
|
|
1591
|
+
var TaskDispatcher = class {
|
|
1592
|
+
constructor(agentRegistry, events) {
|
|
1593
|
+
this.agentRegistry = agentRegistry;
|
|
1594
|
+
this.events = events;
|
|
1595
|
+
}
|
|
1596
|
+
pendingTasks = /* @__PURE__ */ new Map();
|
|
1597
|
+
localExecutors = /* @__PURE__ */ new Map();
|
|
1598
|
+
/** Dispatch a task to the best available agent */
|
|
1599
|
+
async dispatch(task, options) {
|
|
1600
|
+
const capability = options?.capability ?? task.capability;
|
|
1601
|
+
const agent = this.agentRegistry.selectAgent(capability, options?.preferredAgent);
|
|
1602
|
+
if (!agent && !options?.remoteOnly) {
|
|
1603
|
+
return this.executeLocally(task);
|
|
1604
|
+
}
|
|
1605
|
+
if (!agent) {
|
|
1606
|
+
return {
|
|
1607
|
+
taskId: task.id,
|
|
1608
|
+
agentId: "none",
|
|
1609
|
+
status: "error",
|
|
1610
|
+
error: "No agent available",
|
|
1611
|
+
durationMs: 0
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1614
|
+
return this.sendToAgent(task, agent);
|
|
1615
|
+
}
|
|
1616
|
+
/** Register a local executor for a capability (in-process fallback) */
|
|
1617
|
+
registerLocalExecutor(capability, executor) {
|
|
1618
|
+
this.localExecutors.set(capability, executor);
|
|
1619
|
+
}
|
|
1620
|
+
/** Called when an agent returns a result */
|
|
1621
|
+
handleTaskResult(result) {
|
|
1622
|
+
const pending = this.pendingTasks.get(result.taskId);
|
|
1623
|
+
if (pending) {
|
|
1624
|
+
clearTimeout(pending.timeout);
|
|
1625
|
+
this.pendingTasks.delete(result.taskId);
|
|
1626
|
+
pending.resolve(result);
|
|
1627
|
+
}
|
|
1628
|
+
const agent = this.agentRegistry.getAgent(result.agentId);
|
|
1629
|
+
if (agent) {
|
|
1630
|
+
agent.activeTaskCount--;
|
|
1631
|
+
if (result.status === "success") {
|
|
1632
|
+
agent.completedTaskCount++;
|
|
1633
|
+
} else {
|
|
1634
|
+
agent.failedTaskCount++;
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
async executeLocally(task) {
|
|
1639
|
+
const executor = this.localExecutors.get(task.capability);
|
|
1640
|
+
if (!executor) {
|
|
1641
|
+
return {
|
|
1642
|
+
taskId: task.id,
|
|
1643
|
+
agentId: "local",
|
|
1644
|
+
status: "error",
|
|
1645
|
+
error: `No local executor for ${task.capability}`,
|
|
1646
|
+
durationMs: 0
|
|
1647
|
+
};
|
|
1648
|
+
}
|
|
1649
|
+
const start = Date.now();
|
|
1650
|
+
try {
|
|
1651
|
+
const output = await executor(task.input);
|
|
1652
|
+
return {
|
|
1653
|
+
taskId: task.id,
|
|
1654
|
+
agentId: "local",
|
|
1655
|
+
status: "success",
|
|
1656
|
+
output,
|
|
1657
|
+
durationMs: Date.now() - start
|
|
1658
|
+
};
|
|
1659
|
+
} catch (err) {
|
|
1660
|
+
return {
|
|
1661
|
+
taskId: task.id,
|
|
1662
|
+
agentId: "local",
|
|
1663
|
+
status: "error",
|
|
1664
|
+
error: String(err),
|
|
1665
|
+
durationMs: Date.now() - start
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
sendToAgent(task, agent) {
|
|
1670
|
+
agent.activeTaskCount++;
|
|
1671
|
+
return new Promise((resolve) => {
|
|
1672
|
+
const timeout = setTimeout(() => {
|
|
1673
|
+
this.pendingTasks.delete(task.id);
|
|
1674
|
+
agent.activeTaskCount--;
|
|
1675
|
+
agent.failedTaskCount++;
|
|
1676
|
+
resolve({
|
|
1677
|
+
taskId: task.id,
|
|
1678
|
+
agentId: agent.info.id,
|
|
1679
|
+
status: "timeout",
|
|
1680
|
+
durationMs: task.timeout,
|
|
1681
|
+
error: "Task timed out"
|
|
1682
|
+
});
|
|
1683
|
+
}, task.timeout);
|
|
1684
|
+
this.pendingTasks.set(task.id, { resolve, reject: () => {
|
|
1685
|
+
}, timeout });
|
|
1686
|
+
this.events.emitTaskDispatched(task.id, agent.info.id, task.capability);
|
|
1687
|
+
});
|
|
1688
|
+
}
|
|
1689
|
+
};
|
|
1690
|
+
|
|
1691
|
+
// src/agent/agent-client.ts
|
|
1692
|
+
var RECONNECT_BASE_MS = 1e3;
|
|
1693
|
+
var RECONNECT_MAX_MS = 3e4;
|
|
1694
|
+
var HEARTBEAT_INTERVAL_MS = 1e4;
|
|
1695
|
+
var AgentClient = class {
|
|
1696
|
+
ws = null;
|
|
1697
|
+
reconnectAttempt = 0;
|
|
1698
|
+
reconnectTimer = null;
|
|
1699
|
+
heartbeatTimer = null;
|
|
1700
|
+
destroyed = false;
|
|
1701
|
+
messageHandlers = [];
|
|
1702
|
+
binaryHandlers = [];
|
|
1703
|
+
connectHandlers = [];
|
|
1704
|
+
disconnectHandlers = [];
|
|
1705
|
+
logger;
|
|
1706
|
+
hubUrl;
|
|
1707
|
+
token;
|
|
1708
|
+
registrationInfo;
|
|
1709
|
+
runtimeStatus = {
|
|
1710
|
+
activeCameras: 0,
|
|
1711
|
+
cpuPercent: 0,
|
|
1712
|
+
memoryPercent: 0,
|
|
1713
|
+
fps: {},
|
|
1714
|
+
errors: []
|
|
1715
|
+
};
|
|
1716
|
+
constructor(config) {
|
|
1717
|
+
this.hubUrl = config.hubUrl;
|
|
1718
|
+
this.token = config.token;
|
|
1719
|
+
this.logger = config.logger;
|
|
1720
|
+
this.registrationInfo = config.registrationInfo;
|
|
1721
|
+
}
|
|
1722
|
+
/** Connect to the hub WebSocket */
|
|
1723
|
+
async connect() {
|
|
1724
|
+
return new Promise((resolve, reject) => {
|
|
1725
|
+
this.destroyed = false;
|
|
1726
|
+
this.doConnect(resolve, reject);
|
|
1727
|
+
});
|
|
1728
|
+
}
|
|
1729
|
+
/** Disconnect and stop reconnecting */
|
|
1730
|
+
disconnect() {
|
|
1731
|
+
this.destroyed = true;
|
|
1732
|
+
this.clearTimers();
|
|
1733
|
+
if (this.ws) {
|
|
1734
|
+
this.ws.close();
|
|
1735
|
+
this.ws = null;
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
/** Send a JSON control message to the hub */
|
|
1739
|
+
send(msg) {
|
|
1740
|
+
if (!this.ws || this.ws.readyState !== 1) {
|
|
1741
|
+
this.logger.warn("send() called while not connected");
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
this.ws.send(JSON.stringify(msg));
|
|
1745
|
+
}
|
|
1746
|
+
/** Send a binary frame to the hub */
|
|
1747
|
+
sendBinary(data) {
|
|
1748
|
+
if (!this.ws || this.ws.readyState !== 1) {
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
this.ws.send(data);
|
|
1752
|
+
}
|
|
1753
|
+
/** Register a handler for JSON messages from hub */
|
|
1754
|
+
onMessage(handler) {
|
|
1755
|
+
this.messageHandlers.push(handler);
|
|
1756
|
+
}
|
|
1757
|
+
/** Register a handler for binary frames from hub */
|
|
1758
|
+
onBinaryFrame(handler) {
|
|
1759
|
+
this.binaryHandlers.push(handler);
|
|
1760
|
+
}
|
|
1761
|
+
/** Register a handler for successful connection */
|
|
1762
|
+
onConnect(handler) {
|
|
1763
|
+
this.connectHandlers.push(handler);
|
|
1764
|
+
}
|
|
1765
|
+
/** Register a handler for disconnection */
|
|
1766
|
+
onDisconnect(handler) {
|
|
1767
|
+
this.disconnectHandlers.push(handler);
|
|
1768
|
+
}
|
|
1769
|
+
/** Update the runtime status (used in heartbeat) */
|
|
1770
|
+
updateStatus(status) {
|
|
1771
|
+
this.runtimeStatus = status;
|
|
1772
|
+
}
|
|
1773
|
+
/** Whether currently connected */
|
|
1774
|
+
get connected() {
|
|
1775
|
+
return this.ws?.readyState === 1;
|
|
1776
|
+
}
|
|
1777
|
+
doConnect(onConnect, onError) {
|
|
1778
|
+
if (this.destroyed) return;
|
|
1779
|
+
import("./wrapper-NTBY5HOA.mjs").then(({ default: WebSocket }) => {
|
|
1780
|
+
if (this.destroyed) return;
|
|
1781
|
+
const url = this.token ? `${this.hubUrl}?token=${encodeURIComponent(this.token)}` : this.hubUrl;
|
|
1782
|
+
try {
|
|
1783
|
+
this.ws = new WebSocket(url);
|
|
1784
|
+
} catch (err) {
|
|
1785
|
+
this.scheduleReconnect();
|
|
1786
|
+
if (onError) {
|
|
1787
|
+
onError(err instanceof Error ? err : new Error(String(err)));
|
|
1788
|
+
onError = void 0;
|
|
1789
|
+
}
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
this.ws.once("open", () => {
|
|
1793
|
+
this.reconnectAttempt = 0;
|
|
1794
|
+
this.logger.info(`Connected to hub: ${this.hubUrl}`);
|
|
1795
|
+
this.send({ type: "register", info: this.registrationInfo });
|
|
1796
|
+
this.startHeartbeat();
|
|
1797
|
+
for (const h of this.connectHandlers) h();
|
|
1798
|
+
if (onConnect) {
|
|
1799
|
+
onConnect();
|
|
1800
|
+
onConnect = void 0;
|
|
1801
|
+
}
|
|
1802
|
+
});
|
|
1803
|
+
this.ws.on("message", (data, isBinary) => {
|
|
1804
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
1805
|
+
if (isBinary) {
|
|
1806
|
+
for (const h of this.binaryHandlers) h(buf);
|
|
1807
|
+
} else {
|
|
1808
|
+
try {
|
|
1809
|
+
const msg = JSON.parse(buf.toString());
|
|
1810
|
+
this.handleBuiltinMessage(msg);
|
|
1811
|
+
for (const h of this.messageHandlers) h(msg);
|
|
1812
|
+
} catch {
|
|
1813
|
+
this.logger.warn("Failed to parse message from hub");
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
});
|
|
1817
|
+
this.ws.once("close", () => {
|
|
1818
|
+
this.stopHeartbeat();
|
|
1819
|
+
for (const h of this.disconnectHandlers) h();
|
|
1820
|
+
if (!this.destroyed) {
|
|
1821
|
+
this.logger.warn("Disconnected from hub, scheduling reconnect");
|
|
1822
|
+
this.scheduleReconnect();
|
|
1823
|
+
}
|
|
1824
|
+
});
|
|
1825
|
+
this.ws.once("error", (err) => {
|
|
1826
|
+
this.logger.error("WebSocket error", { message: err.message });
|
|
1827
|
+
if (onError) {
|
|
1828
|
+
onError(err);
|
|
1829
|
+
onError = void 0;
|
|
1830
|
+
}
|
|
1831
|
+
});
|
|
1832
|
+
}).catch((err) => {
|
|
1833
|
+
this.logger.error("Failed to import ws module", { message: String(err) });
|
|
1834
|
+
if (onError) {
|
|
1835
|
+
onError(err instanceof Error ? err : new Error(String(err)));
|
|
1836
|
+
}
|
|
1837
|
+
});
|
|
1838
|
+
}
|
|
1839
|
+
handleBuiltinMessage(msg) {
|
|
1840
|
+
if (msg.type === "ping") {
|
|
1841
|
+
this.send({ type: "pong" });
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
scheduleReconnect() {
|
|
1845
|
+
if (this.destroyed) return;
|
|
1846
|
+
const delay = Math.min(
|
|
1847
|
+
RECONNECT_BASE_MS * Math.pow(2, this.reconnectAttempt),
|
|
1848
|
+
RECONNECT_MAX_MS
|
|
1849
|
+
);
|
|
1850
|
+
this.reconnectAttempt++;
|
|
1851
|
+
this.logger.info(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})`);
|
|
1852
|
+
this.reconnectTimer = setTimeout(() => {
|
|
1853
|
+
this.doConnect();
|
|
1854
|
+
}, delay);
|
|
1855
|
+
}
|
|
1856
|
+
startHeartbeat() {
|
|
1857
|
+
this.stopHeartbeat();
|
|
1858
|
+
this.heartbeatTimer = setInterval(() => {
|
|
1859
|
+
this.send({ type: "heartbeat", status: this.runtimeStatus });
|
|
1860
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
1861
|
+
}
|
|
1862
|
+
stopHeartbeat() {
|
|
1863
|
+
if (this.heartbeatTimer) {
|
|
1864
|
+
clearInterval(this.heartbeatTimer);
|
|
1865
|
+
this.heartbeatTimer = null;
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
clearTimers() {
|
|
1869
|
+
this.stopHeartbeat();
|
|
1870
|
+
if (this.reconnectTimer) {
|
|
1871
|
+
clearTimeout(this.reconnectTimer);
|
|
1872
|
+
this.reconnectTimer = null;
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
};
|
|
1876
|
+
|
|
1877
|
+
// src/agent/agent-task-runner.ts
|
|
1878
|
+
var AgentTaskRunner = class {
|
|
1879
|
+
constructor(agentId, client, logger) {
|
|
1880
|
+
this.agentId = agentId;
|
|
1881
|
+
this.client = client;
|
|
1882
|
+
this.logger = logger.child("TaskRunner");
|
|
1883
|
+
}
|
|
1884
|
+
handlers = /* @__PURE__ */ new Map();
|
|
1885
|
+
runningTasks = /* @__PURE__ */ new Map();
|
|
1886
|
+
logger;
|
|
1887
|
+
/** Register a task handler for a given task type */
|
|
1888
|
+
registerHandler(handler) {
|
|
1889
|
+
if (this.handlers.has(handler.taskType)) {
|
|
1890
|
+
this.logger.warn(`Overwriting handler for task type: ${handler.taskType}`);
|
|
1891
|
+
}
|
|
1892
|
+
this.handlers.set(handler.taskType, handler);
|
|
1893
|
+
this.logger.debug(`Registered handler: ${handler.taskType}`);
|
|
1894
|
+
}
|
|
1895
|
+
/** Unregister a task handler */
|
|
1896
|
+
unregisterHandler(taskType) {
|
|
1897
|
+
this.handlers.delete(taskType);
|
|
1898
|
+
}
|
|
1899
|
+
/** Get all registered task types */
|
|
1900
|
+
getTaskTypes() {
|
|
1901
|
+
return [...this.handlers.keys()];
|
|
1902
|
+
}
|
|
1903
|
+
/** Get a handler by task type */
|
|
1904
|
+
getHandler(taskType) {
|
|
1905
|
+
return this.handlers.get(taskType);
|
|
1906
|
+
}
|
|
1907
|
+
/**
|
|
1908
|
+
* Execute a task dispatched from the hub.
|
|
1909
|
+
* Sends result or error back to hub via the AgentClient.
|
|
1910
|
+
*/
|
|
1911
|
+
async executeTask(taskId, taskType, payload) {
|
|
1912
|
+
const handler = this.handlers.get(taskType);
|
|
1913
|
+
if (!handler) {
|
|
1914
|
+
this.client.send({
|
|
1915
|
+
type: "task.result",
|
|
1916
|
+
taskId,
|
|
1917
|
+
success: false,
|
|
1918
|
+
error: `No handler registered for task type: ${taskType}`
|
|
1919
|
+
});
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1922
|
+
const runningTask = { taskId, taskType, cancelled: false };
|
|
1923
|
+
this.runningTasks.set(taskId, runningTask);
|
|
1924
|
+
const context = {
|
|
1925
|
+
taskId,
|
|
1926
|
+
agentId: this.agentId,
|
|
1927
|
+
logger: this.logger.child(taskType),
|
|
1928
|
+
reportProgress: (progress) => {
|
|
1929
|
+
this.client.send({ type: "task.progress", taskId, progress });
|
|
1930
|
+
},
|
|
1931
|
+
isCancelled: () => runningTask.cancelled
|
|
1932
|
+
};
|
|
1933
|
+
try {
|
|
1934
|
+
const result = await handler.handle(payload, context);
|
|
1935
|
+
this.runningTasks.delete(taskId);
|
|
1936
|
+
if (!runningTask.cancelled) {
|
|
1937
|
+
this.client.send({
|
|
1938
|
+
type: "task.result",
|
|
1939
|
+
taskId,
|
|
1940
|
+
success: true,
|
|
1941
|
+
result
|
|
1942
|
+
});
|
|
1943
|
+
}
|
|
1944
|
+
} catch (err) {
|
|
1945
|
+
this.runningTasks.delete(taskId);
|
|
1946
|
+
this.logger.error(`Task ${taskType} (${taskId}) failed`, {
|
|
1947
|
+
message: err instanceof Error ? err.message : String(err)
|
|
1948
|
+
});
|
|
1949
|
+
this.client.send({
|
|
1950
|
+
type: "task.result",
|
|
1951
|
+
taskId,
|
|
1952
|
+
success: false,
|
|
1953
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1954
|
+
});
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
/** Cancel a running task */
|
|
1958
|
+
async cancelTask(taskId) {
|
|
1959
|
+
const running = this.runningTasks.get(taskId);
|
|
1960
|
+
if (!running) return;
|
|
1961
|
+
running.cancelled = true;
|
|
1962
|
+
const handler = this.handlers.get(running.taskType);
|
|
1963
|
+
if (handler?.cancel) {
|
|
1964
|
+
try {
|
|
1965
|
+
await handler.cancel();
|
|
1966
|
+
} catch (err) {
|
|
1967
|
+
this.logger.error(`Error cancelling task ${taskId}`, {
|
|
1968
|
+
message: err instanceof Error ? err.message : String(err)
|
|
1969
|
+
});
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
this.runningTasks.delete(taskId);
|
|
1973
|
+
}
|
|
1974
|
+
/** Number of currently running tasks */
|
|
1975
|
+
get activeTaskCount() {
|
|
1976
|
+
return this.runningTasks.size;
|
|
1977
|
+
}
|
|
1978
|
+
/** Destroy: cancel all running tasks */
|
|
1979
|
+
async destroy() {
|
|
1980
|
+
const taskIds = [...this.runningTasks.keys()];
|
|
1981
|
+
for (const taskId of taskIds) {
|
|
1982
|
+
await this.cancelTask(taskId);
|
|
1983
|
+
}
|
|
1984
|
+
this.handlers.clear();
|
|
1985
|
+
}
|
|
1986
|
+
};
|
|
1987
|
+
|
|
1988
|
+
// src/agent/pipeline-task-handlers.ts
|
|
1989
|
+
var DecodeTaskHandler = class {
|
|
1990
|
+
taskType = "pipeline.decode";
|
|
1991
|
+
description = "Decode an RTSP stream and produce JPEG frames";
|
|
1992
|
+
async handle(payload, context) {
|
|
1993
|
+
const { logger } = context;
|
|
1994
|
+
const config = payload;
|
|
1995
|
+
if (!config.cameraId || !config.rtspUrl) {
|
|
1996
|
+
throw new Error("pipeline.decode requires cameraId and rtspUrl in payload");
|
|
1997
|
+
}
|
|
1998
|
+
logger.info(`Decode task started: camera=${config.cameraId} url=${config.rtspUrl} fps=${config.fps ?? 1}`);
|
|
1999
|
+
return { status: "started", cameraId: config.cameraId };
|
|
2000
|
+
}
|
|
2001
|
+
async cancel() {
|
|
2002
|
+
}
|
|
2003
|
+
};
|
|
2004
|
+
var DetectTaskHandler = class {
|
|
2005
|
+
taskType = "pipeline.detect";
|
|
2006
|
+
description = "Run object detection on received frames";
|
|
2007
|
+
async handle(payload, context) {
|
|
2008
|
+
const { logger } = context;
|
|
2009
|
+
const config = payload;
|
|
2010
|
+
if (!config.cameraId) {
|
|
2011
|
+
throw new Error("pipeline.detect requires cameraId in payload");
|
|
2012
|
+
}
|
|
2013
|
+
logger.info(`Detect task started: camera=${config.cameraId} model=${config.modelId ?? "default"} runtime=${config.runtime ?? "auto"}`);
|
|
2014
|
+
return { status: "started", cameraId: config.cameraId };
|
|
2015
|
+
}
|
|
2016
|
+
async cancel() {
|
|
2017
|
+
}
|
|
2018
|
+
};
|
|
2019
|
+
var RecordTaskHandler = class {
|
|
2020
|
+
taskType = "pipeline.record";
|
|
2021
|
+
description = "Record an RTSP stream to disk segments";
|
|
2022
|
+
async handle(payload, context) {
|
|
2023
|
+
const { logger } = context;
|
|
2024
|
+
const config = payload;
|
|
2025
|
+
if (!config.cameraId || !config.rtspUrl) {
|
|
2026
|
+
throw new Error("pipeline.record requires cameraId and rtspUrl in payload");
|
|
2027
|
+
}
|
|
2028
|
+
logger.info(`Record task started: camera=${config.cameraId} url=${config.rtspUrl}`);
|
|
2029
|
+
return { status: "started", cameraId: config.cameraId };
|
|
2030
|
+
}
|
|
2031
|
+
async cancel() {
|
|
2032
|
+
}
|
|
2033
|
+
};
|
|
2034
|
+
|
|
2035
|
+
// src/builtins/sqlite-storage/sqlite-storage.provider.ts
|
|
2036
|
+
import Database from "better-sqlite3";
|
|
2037
|
+
import * as fs5 from "fs";
|
|
2038
|
+
import * as path5 from "path";
|
|
2039
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
2040
|
+
var LOCATION_TYPES = {
|
|
2041
|
+
// New location names (from StorageLocationManager)
|
|
2042
|
+
data: "structured",
|
|
2043
|
+
// settings, events, trails — SQL only
|
|
2044
|
+
media: "files",
|
|
2045
|
+
// crops, snapshots, thumbnails — files only
|
|
2046
|
+
recordings: "files",
|
|
2047
|
+
// video segments — files only
|
|
2048
|
+
models: "files",
|
|
2049
|
+
// ONNX/TFLite models — files only
|
|
2050
|
+
cache: "files",
|
|
2051
|
+
// temp files — files only
|
|
2052
|
+
logs: "files",
|
|
2053
|
+
// Winston log files — files only
|
|
2054
|
+
// Legacy location names (backward compat)
|
|
2055
|
+
config: "structured",
|
|
2056
|
+
events: "structured",
|
|
2057
|
+
addon: "both"
|
|
2058
|
+
};
|
|
2059
|
+
var SqliteStructuredStorage = class {
|
|
2060
|
+
constructor(db) {
|
|
2061
|
+
this.db = db;
|
|
2062
|
+
}
|
|
2063
|
+
ensuredTables = /* @__PURE__ */ new Set();
|
|
2064
|
+
ensureTable(collection) {
|
|
2065
|
+
if (this.ensuredTables.has(collection)) return;
|
|
2066
|
+
this.db.exec(
|
|
2067
|
+
`CREATE TABLE IF NOT EXISTS "${collection}" (id TEXT PRIMARY KEY, data TEXT)`
|
|
2068
|
+
);
|
|
2069
|
+
this.ensuredTables.add(collection);
|
|
2070
|
+
}
|
|
2071
|
+
async insert(record) {
|
|
2072
|
+
this.ensureTable(record.collection);
|
|
2073
|
+
const id = record.id || randomUUID2();
|
|
2074
|
+
const newRecord = {
|
|
2075
|
+
collection: record.collection,
|
|
2076
|
+
id,
|
|
2077
|
+
data: record.data
|
|
2078
|
+
};
|
|
2079
|
+
this.db.prepare(`INSERT INTO "${record.collection}" (id, data) VALUES (?, ?)`).run(id, JSON.stringify(newRecord.data));
|
|
2080
|
+
return newRecord;
|
|
2081
|
+
}
|
|
2082
|
+
async query(collection, filter) {
|
|
2083
|
+
this.ensureTable(collection);
|
|
2084
|
+
const { sql, params } = this.buildSelect(collection, filter);
|
|
2085
|
+
const rows = this.db.prepare(sql).all(...params);
|
|
2086
|
+
return rows.map((row) => ({
|
|
2087
|
+
collection,
|
|
2088
|
+
id: row.id,
|
|
2089
|
+
data: JSON.parse(row.data)
|
|
2090
|
+
}));
|
|
2091
|
+
}
|
|
2092
|
+
async update(collection, id, data) {
|
|
2093
|
+
this.ensureTable(collection);
|
|
2094
|
+
this.db.prepare(`UPDATE "${collection}" SET data = ? WHERE id = ?`).run(JSON.stringify(data), id);
|
|
2095
|
+
return { collection, id, data };
|
|
2096
|
+
}
|
|
2097
|
+
async delete(collection, id) {
|
|
2098
|
+
this.ensureTable(collection);
|
|
2099
|
+
this.db.prepare(`DELETE FROM "${collection}" WHERE id = ?`).run(id);
|
|
2100
|
+
}
|
|
2101
|
+
async count(collection, filter) {
|
|
2102
|
+
this.ensureTable(collection);
|
|
2103
|
+
const { sql, params } = this.buildCount(collection, filter);
|
|
2104
|
+
const row = this.db.prepare(sql).get(...params);
|
|
2105
|
+
return row.cnt;
|
|
2106
|
+
}
|
|
2107
|
+
buildWhereClause(filter) {
|
|
2108
|
+
if (!filter) return { clause: "", params: [] };
|
|
2109
|
+
const conditions = [];
|
|
2110
|
+
const params = [];
|
|
2111
|
+
if (filter.where) {
|
|
2112
|
+
for (const [field, value] of Object.entries(filter.where)) {
|
|
2113
|
+
if (field === "id") {
|
|
2114
|
+
conditions.push("id = ?");
|
|
2115
|
+
params.push(value);
|
|
2116
|
+
} else {
|
|
2117
|
+
conditions.push(`json_extract(data, '$.${field}') = ?`);
|
|
2118
|
+
params.push(value);
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
if (filter.whereIn) {
|
|
2123
|
+
for (const [field, values] of Object.entries(filter.whereIn)) {
|
|
2124
|
+
const placeholders = values.map(() => "?").join(", ");
|
|
2125
|
+
if (field === "id") {
|
|
2126
|
+
conditions.push(`id IN (${placeholders})`);
|
|
2127
|
+
} else {
|
|
2128
|
+
conditions.push(`json_extract(data, '$.${field}') IN (${placeholders})`);
|
|
2129
|
+
}
|
|
2130
|
+
params.push(...values);
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
if (filter.whereBetween) {
|
|
2134
|
+
for (const [field, [low, high]] of Object.entries(filter.whereBetween)) {
|
|
2135
|
+
if (field === "id") {
|
|
2136
|
+
conditions.push("id BETWEEN ? AND ?");
|
|
2137
|
+
} else {
|
|
2138
|
+
conditions.push(`json_extract(data, '$.${field}') BETWEEN ? AND ?`);
|
|
2139
|
+
}
|
|
2140
|
+
params.push(low, high);
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
const clause = conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
|
|
2144
|
+
return { clause, params };
|
|
2145
|
+
}
|
|
2146
|
+
buildSelect(collection, filter) {
|
|
2147
|
+
const { clause, params } = this.buildWhereClause(filter);
|
|
2148
|
+
let sql = `SELECT id, data FROM "${collection}"${clause}`;
|
|
2149
|
+
if (filter?.orderBy) {
|
|
2150
|
+
const dir = filter.orderBy.direction === "desc" ? "DESC" : "ASC";
|
|
2151
|
+
if (filter.orderBy.field === "id") {
|
|
2152
|
+
sql += ` ORDER BY id ${dir}`;
|
|
2153
|
+
} else {
|
|
2154
|
+
sql += ` ORDER BY json_extract(data, '$.${filter.orderBy.field}') ${dir}`;
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
if (filter?.limit !== void 0) {
|
|
2158
|
+
sql += ` LIMIT ?`;
|
|
2159
|
+
params.push(filter.limit);
|
|
2160
|
+
}
|
|
2161
|
+
if (filter?.offset !== void 0) {
|
|
2162
|
+
sql += ` OFFSET ?`;
|
|
2163
|
+
params.push(filter.offset);
|
|
2164
|
+
}
|
|
2165
|
+
return { sql, params };
|
|
2166
|
+
}
|
|
2167
|
+
buildCount(collection, filter) {
|
|
2168
|
+
const { clause, params } = this.buildWhereClause(filter);
|
|
2169
|
+
return { sql: `SELECT COUNT(*) as cnt FROM "${collection}"${clause}`, params };
|
|
2170
|
+
}
|
|
2171
|
+
};
|
|
2172
|
+
var FileSystemStorage = class {
|
|
2173
|
+
constructor(basePath) {
|
|
2174
|
+
this.basePath = basePath;
|
|
2175
|
+
}
|
|
2176
|
+
async readFile(filePath) {
|
|
2177
|
+
const fullPath = path5.join(this.basePath, filePath);
|
|
2178
|
+
return fs5.promises.readFile(fullPath);
|
|
2179
|
+
}
|
|
2180
|
+
async writeFile(filePath, data) {
|
|
2181
|
+
const fullPath = path5.join(this.basePath, filePath);
|
|
2182
|
+
fs5.mkdirSync(path5.dirname(fullPath), { recursive: true });
|
|
2183
|
+
await fs5.promises.writeFile(fullPath, data);
|
|
2184
|
+
}
|
|
2185
|
+
async deleteFile(filePath) {
|
|
2186
|
+
const fullPath = path5.join(this.basePath, filePath);
|
|
2187
|
+
await fs5.promises.unlink(fullPath);
|
|
2188
|
+
}
|
|
2189
|
+
async listFiles(prefix) {
|
|
2190
|
+
const searchDir = prefix ? path5.join(this.basePath, prefix) : this.basePath;
|
|
2191
|
+
try {
|
|
2192
|
+
const entries = await fs5.promises.readdir(searchDir, { recursive: true });
|
|
2193
|
+
const files = [];
|
|
2194
|
+
for (const entry of entries) {
|
|
2195
|
+
const entryStr = String(entry);
|
|
2196
|
+
const relative = prefix ? path5.join(prefix, entryStr) : entryStr;
|
|
2197
|
+
const fullPath = path5.join(this.basePath, relative);
|
|
2198
|
+
try {
|
|
2199
|
+
const stat = await fs5.promises.stat(fullPath);
|
|
2200
|
+
if (stat.isFile()) {
|
|
2201
|
+
files.push(relative);
|
|
2202
|
+
}
|
|
2203
|
+
} catch {
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
return files;
|
|
2207
|
+
} catch {
|
|
2208
|
+
return [];
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
async getFileUrl(_path) {
|
|
2212
|
+
return null;
|
|
2213
|
+
}
|
|
2214
|
+
async exists(filePath) {
|
|
2215
|
+
const fullPath = path5.join(this.basePath, filePath);
|
|
2216
|
+
try {
|
|
2217
|
+
await fs5.promises.access(fullPath, fs5.constants.F_OK);
|
|
2218
|
+
return true;
|
|
2219
|
+
} catch {
|
|
2220
|
+
return false;
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
};
|
|
2224
|
+
var SqliteStorageProvider = class {
|
|
2225
|
+
mainDb = null;
|
|
2226
|
+
sharedStructured = null;
|
|
2227
|
+
locations = /* @__PURE__ */ new Map();
|
|
2228
|
+
async initialize() {
|
|
2229
|
+
}
|
|
2230
|
+
/**
|
|
2231
|
+
* Configure all storage locations.
|
|
2232
|
+
* ONE single SQLite database (camstack.db) is used for ALL structured storage.
|
|
2233
|
+
* File-based locations use the filesystem at their configured path.
|
|
2234
|
+
*/
|
|
2235
|
+
async configure(config) {
|
|
2236
|
+
const dataPath = config.locations["data"] ?? config.locations["config"] ?? Object.values(config.locations)[0];
|
|
2237
|
+
if (!dataPath) throw new Error("No data path configured for SQLite storage");
|
|
2238
|
+
fs5.mkdirSync(dataPath, { recursive: true });
|
|
2239
|
+
const dbPath = path5.join(dataPath, "camstack.db");
|
|
2240
|
+
this.mainDb = new Database(dbPath);
|
|
2241
|
+
this.mainDb.pragma("journal_mode = WAL");
|
|
2242
|
+
this.sharedStructured = new SqliteStructuredStorage(this.mainDb);
|
|
2243
|
+
for (const [name, dirPath] of Object.entries(config.locations)) {
|
|
2244
|
+
const locationName = name;
|
|
2245
|
+
const locationType = LOCATION_TYPES[name] ?? "files";
|
|
2246
|
+
fs5.mkdirSync(dirPath, { recursive: true });
|
|
2247
|
+
const location = {};
|
|
2248
|
+
if (locationType === "structured" || locationType === "both") {
|
|
2249
|
+
location.structured = this.sharedStructured;
|
|
2250
|
+
}
|
|
2251
|
+
if (locationType === "files" || locationType === "both") {
|
|
2252
|
+
location.files = new FileSystemStorage(dirPath);
|
|
2253
|
+
}
|
|
2254
|
+
this.locations.set(locationName, location);
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
getLocation(name) {
|
|
2258
|
+
const location = this.locations.get(name);
|
|
2259
|
+
if (!location) {
|
|
2260
|
+
throw new Error(`Storage location "${name}" not found`);
|
|
2261
|
+
}
|
|
2262
|
+
return location;
|
|
2263
|
+
}
|
|
2264
|
+
async shutdown() {
|
|
2265
|
+
if (this.mainDb) {
|
|
2266
|
+
this.mainDb.close();
|
|
2267
|
+
this.mainDb = null;
|
|
2268
|
+
this.sharedStructured = null;
|
|
2269
|
+
}
|
|
2270
|
+
this.locations.clear();
|
|
2271
|
+
}
|
|
2272
|
+
async export(_locationName) {
|
|
2273
|
+
throw new Error("Export not yet implemented");
|
|
2274
|
+
}
|
|
2275
|
+
async import(_locationName, _data) {
|
|
2276
|
+
throw new Error("Import not yet implemented");
|
|
2277
|
+
}
|
|
2278
|
+
};
|
|
2279
|
+
|
|
2280
|
+
// src/builtins/sqlite-storage/sqlite-storage.addon.ts
|
|
2281
|
+
var SqliteStorageAddon = class {
|
|
2282
|
+
manifest = {
|
|
2283
|
+
id: "sqlite-storage",
|
|
2284
|
+
name: "SQLite Storage",
|
|
2285
|
+
version: "1.0.0",
|
|
2286
|
+
capabilities: ["storage"]
|
|
2287
|
+
};
|
|
2288
|
+
provider = null;
|
|
2289
|
+
async initialize(context) {
|
|
2290
|
+
const storageConfig = {
|
|
2291
|
+
locations: { ...context.locationPaths }
|
|
2292
|
+
};
|
|
2293
|
+
this.provider = new SqliteStorageProvider();
|
|
2294
|
+
await this.provider.configure(storageConfig);
|
|
2295
|
+
context.logger.info("SQLite storage initialized");
|
|
2296
|
+
}
|
|
2297
|
+
async shutdown() {
|
|
2298
|
+
await this.provider?.shutdown();
|
|
2299
|
+
}
|
|
2300
|
+
getProvider() {
|
|
2301
|
+
if (!this.provider) throw new Error("SQLite storage not initialized");
|
|
2302
|
+
return this.provider;
|
|
2303
|
+
}
|
|
2304
|
+
getCapabilityProvider(name) {
|
|
2305
|
+
if (name === "storage" && this.provider) {
|
|
2306
|
+
return this.provider;
|
|
2307
|
+
}
|
|
2308
|
+
return null;
|
|
2309
|
+
}
|
|
2310
|
+
getConfigSchema() {
|
|
2311
|
+
return {
|
|
2312
|
+
sections: []
|
|
2313
|
+
};
|
|
2314
|
+
}
|
|
2315
|
+
getConfig() {
|
|
2316
|
+
return {};
|
|
2317
|
+
}
|
|
2318
|
+
async onConfigChange(_config) {
|
|
2319
|
+
}
|
|
2320
|
+
};
|
|
2321
|
+
|
|
2322
|
+
// src/builtins/winston-logging/winston-destination.ts
|
|
2323
|
+
import * as winston from "winston";
|
|
2324
|
+
import DailyRotateFile from "winston-daily-rotate-file";
|
|
2325
|
+
import * as path6 from "path";
|
|
2326
|
+
function formatScope(scope) {
|
|
2327
|
+
if (scope.length === 0) return "";
|
|
2328
|
+
const [first, ...rest] = scope;
|
|
2329
|
+
const restFormatted = rest.map((s) => `[${s}]`).join("");
|
|
2330
|
+
return `(${first})${restFormatted}`;
|
|
2331
|
+
}
|
|
2332
|
+
var WinstonDestination = class {
|
|
2333
|
+
logger = null;
|
|
2334
|
+
async initialize(config) {
|
|
2335
|
+
const {
|
|
2336
|
+
level = "info",
|
|
2337
|
+
retentionDays = 30,
|
|
2338
|
+
logsDir = path6.join("./data", "logs")
|
|
2339
|
+
} = config ?? {};
|
|
2340
|
+
const consoleFormat = winston.format.combine(
|
|
2341
|
+
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
|
|
2342
|
+
winston.format.colorize(),
|
|
2343
|
+
winston.format.printf(({ timestamp, level: lvl, message, scope }) => {
|
|
2344
|
+
const scopeStr = scope ? ` ${scope}` : "";
|
|
2345
|
+
return `${timestamp} [${lvl}]${scopeStr} - ${message}`;
|
|
2346
|
+
})
|
|
2347
|
+
);
|
|
2348
|
+
const fileFormat = winston.format.combine(
|
|
2349
|
+
winston.format.timestamp(),
|
|
2350
|
+
winston.format.json()
|
|
2351
|
+
);
|
|
2352
|
+
this.logger = winston.createLogger({
|
|
2353
|
+
level,
|
|
2354
|
+
transports: [
|
|
2355
|
+
new winston.transports.Console({
|
|
2356
|
+
format: consoleFormat
|
|
2357
|
+
}),
|
|
2358
|
+
new DailyRotateFile({
|
|
2359
|
+
dirname: logsDir,
|
|
2360
|
+
filename: "camstack-%DATE%.log",
|
|
2361
|
+
datePattern: "YYYY-MM-DD",
|
|
2362
|
+
maxFiles: `${retentionDays}d`,
|
|
2363
|
+
format: fileFormat
|
|
2364
|
+
})
|
|
2365
|
+
]
|
|
2366
|
+
});
|
|
2367
|
+
}
|
|
2368
|
+
write(entry) {
|
|
2369
|
+
if (!this.logger) return;
|
|
2370
|
+
const scope = formatScope(entry.scope);
|
|
2371
|
+
const meta = entry.meta ?? {};
|
|
2372
|
+
this.logger.log({
|
|
2373
|
+
level: entry.level,
|
|
2374
|
+
message: entry.message,
|
|
2375
|
+
scope,
|
|
2376
|
+
timestamp: entry.timestamp.toISOString(),
|
|
2377
|
+
...meta
|
|
2378
|
+
});
|
|
2379
|
+
}
|
|
2380
|
+
async query(_filter) {
|
|
2381
|
+
return [];
|
|
2382
|
+
}
|
|
2383
|
+
async shutdown() {
|
|
2384
|
+
if (!this.logger) return;
|
|
2385
|
+
await new Promise((resolve) => {
|
|
2386
|
+
this.logger.on("finish", resolve);
|
|
2387
|
+
this.logger.end();
|
|
2388
|
+
});
|
|
2389
|
+
this.logger = null;
|
|
2390
|
+
}
|
|
2391
|
+
};
|
|
2392
|
+
|
|
2393
|
+
// src/builtins/winston-logging/winston-logging.addon.ts
|
|
2394
|
+
var WinstonLoggingAddon = class {
|
|
2395
|
+
manifest = {
|
|
2396
|
+
id: "winston-logging",
|
|
2397
|
+
name: "Winston Logging",
|
|
2398
|
+
version: "1.0.0",
|
|
2399
|
+
capabilities: ["log-destination"]
|
|
2400
|
+
};
|
|
2401
|
+
destination = null;
|
|
2402
|
+
currentConfig = {
|
|
2403
|
+
level: "info",
|
|
2404
|
+
retentionDays: 30
|
|
2405
|
+
};
|
|
2406
|
+
async initialize(context) {
|
|
2407
|
+
this.currentConfig = {
|
|
2408
|
+
level: context.addonConfig.level ?? this.currentConfig.level,
|
|
2409
|
+
retentionDays: context.addonConfig.retentionDays ?? this.currentConfig.retentionDays
|
|
2410
|
+
};
|
|
2411
|
+
const logsDir = context.locationPaths.logs;
|
|
2412
|
+
this.destination = new WinstonDestination();
|
|
2413
|
+
await this.destination.initialize({ ...this.currentConfig, logsDir });
|
|
2414
|
+
context.logger.info("Winston logging initialized");
|
|
2415
|
+
}
|
|
2416
|
+
async shutdown() {
|
|
2417
|
+
await this.destination?.shutdown();
|
|
2418
|
+
}
|
|
2419
|
+
getDestination() {
|
|
2420
|
+
if (!this.destination) throw new Error("Winston not initialized");
|
|
2421
|
+
return this.destination;
|
|
2422
|
+
}
|
|
2423
|
+
getCapabilityProvider(name) {
|
|
2424
|
+
if (name === "log-destination" && this.destination) {
|
|
2425
|
+
return this.destination;
|
|
2426
|
+
}
|
|
2427
|
+
return null;
|
|
2428
|
+
}
|
|
2429
|
+
getConfigSchema() {
|
|
2430
|
+
return {
|
|
2431
|
+
sections: [
|
|
2432
|
+
{
|
|
2433
|
+
id: "logging",
|
|
2434
|
+
title: "Logging Settings",
|
|
2435
|
+
columns: 2,
|
|
2436
|
+
fields: [
|
|
2437
|
+
{
|
|
2438
|
+
type: "select",
|
|
2439
|
+
key: "level",
|
|
2440
|
+
label: "Log Level",
|
|
2441
|
+
description: "Minimum severity of messages that will be written to log files",
|
|
2442
|
+
options: [
|
|
2443
|
+
{ value: "error", label: "Error", description: "Only critical errors" },
|
|
2444
|
+
{ value: "warn", label: "Warning", description: "Errors and warnings" },
|
|
2445
|
+
{ value: "info", label: "Info", description: "General operational messages (recommended)" },
|
|
2446
|
+
{ value: "debug", label: "Debug", description: "Verbose output for debugging" }
|
|
2447
|
+
]
|
|
2448
|
+
},
|
|
2449
|
+
{
|
|
2450
|
+
type: "number",
|
|
2451
|
+
key: "retentionDays",
|
|
2452
|
+
label: "Log Retention",
|
|
2453
|
+
description: "Number of days to keep log files before automatic deletion",
|
|
2454
|
+
min: 1,
|
|
2455
|
+
max: 365,
|
|
2456
|
+
step: 1,
|
|
2457
|
+
unit: "days"
|
|
2458
|
+
}
|
|
2459
|
+
]
|
|
2460
|
+
}
|
|
2461
|
+
]
|
|
2462
|
+
};
|
|
2463
|
+
}
|
|
2464
|
+
getConfig() {
|
|
2465
|
+
return { ...this.currentConfig };
|
|
2466
|
+
}
|
|
2467
|
+
async onConfigChange(config) {
|
|
2468
|
+
this.currentConfig = {
|
|
2469
|
+
level: config.level ?? this.currentConfig.level,
|
|
2470
|
+
retentionDays: config.retentionDays ?? this.currentConfig.retentionDays
|
|
2471
|
+
};
|
|
2472
|
+
}
|
|
2473
|
+
};
|
|
2474
|
+
|
|
2475
|
+
// src/builtins/local-backup/local-backup.ts
|
|
2476
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
2477
|
+
var LocalBackupService = class {
|
|
2478
|
+
constructor(config, logger, eventBus, storage) {
|
|
2479
|
+
this.config = config;
|
|
2480
|
+
this.logger = logger;
|
|
2481
|
+
this.eventBus = eventBus;
|
|
2482
|
+
this.storage = storage;
|
|
2483
|
+
}
|
|
2484
|
+
manifests = [];
|
|
2485
|
+
/** Create a backup of specified locations */
|
|
2486
|
+
async backup(options) {
|
|
2487
|
+
const id = randomUUID3();
|
|
2488
|
+
const timestamp = Date.now();
|
|
2489
|
+
const locations = options?.locations ?? ["config", "events", "logs"];
|
|
2490
|
+
this.logger.info(`Starting backup ${id} (${locations.join(", ")})`);
|
|
2491
|
+
const manifest = {
|
|
2492
|
+
id,
|
|
2493
|
+
timestamp,
|
|
2494
|
+
label: options?.label,
|
|
2495
|
+
locations,
|
|
2496
|
+
sizeMB: 0,
|
|
2497
|
+
path: `${this.config.backupDir}/${id}`
|
|
2498
|
+
};
|
|
2499
|
+
const updated = [...this.manifests, manifest];
|
|
2500
|
+
this.manifests = updated;
|
|
2501
|
+
await this.pruneOldBackups();
|
|
2502
|
+
this.eventBus.emit({
|
|
2503
|
+
id: randomUUID3(),
|
|
2504
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
2505
|
+
source: { type: "addon", id: "local-backup" },
|
|
2506
|
+
category: "backup.completed",
|
|
2507
|
+
data: { backupId: id, locations: [...locations], sizeMB: manifest.sizeMB }
|
|
2508
|
+
});
|
|
2509
|
+
this.logger.info(`Backup ${id} completed`);
|
|
2510
|
+
return manifest;
|
|
2511
|
+
}
|
|
2512
|
+
/** Restore from a backup */
|
|
2513
|
+
async restore(backupId) {
|
|
2514
|
+
const manifest = this.manifests.find((m) => m.id === backupId);
|
|
2515
|
+
if (!manifest) throw new Error(`Backup ${backupId} not found`);
|
|
2516
|
+
this.logger.info(`Restoring from backup ${backupId}`);
|
|
2517
|
+
this.eventBus.emit({
|
|
2518
|
+
id: randomUUID3(),
|
|
2519
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
2520
|
+
source: { type: "addon", id: "local-backup" },
|
|
2521
|
+
category: "backup.restored",
|
|
2522
|
+
data: { backupId }
|
|
2523
|
+
});
|
|
2524
|
+
}
|
|
2525
|
+
/** List all backups sorted by timestamp descending */
|
|
2526
|
+
list() {
|
|
2527
|
+
return [...this.manifests].sort((a, b) => b.timestamp - a.timestamp);
|
|
2528
|
+
}
|
|
2529
|
+
/** Delete a specific backup */
|
|
2530
|
+
async delete(backupId) {
|
|
2531
|
+
this.manifests = this.manifests.filter((m) => m.id !== backupId);
|
|
2532
|
+
}
|
|
2533
|
+
async pruneOldBackups() {
|
|
2534
|
+
const sorted = [...this.manifests].sort((a, b) => a.timestamp - b.timestamp);
|
|
2535
|
+
while (sorted.length > this.config.retentionCount) {
|
|
2536
|
+
const oldest = sorted.shift();
|
|
2537
|
+
if (oldest) {
|
|
2538
|
+
this.logger.info(`Pruning old backup ${oldest.id}`);
|
|
2539
|
+
this.manifests = this.manifests.filter((m) => m.id !== oldest.id);
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
};
|
|
2544
|
+
|
|
2545
|
+
// src/builtins/local-backup/local-backup.addon.ts
|
|
2546
|
+
import * as path7 from "path";
|
|
2547
|
+
var LocalBackupAddon = class {
|
|
2548
|
+
manifest = {
|
|
2549
|
+
id: "local-backup",
|
|
2550
|
+
name: "Local Backup",
|
|
2551
|
+
version: "1.0.0",
|
|
2552
|
+
capabilities: ["backup"]
|
|
2553
|
+
};
|
|
2554
|
+
service = null;
|
|
2555
|
+
currentConfig = {
|
|
2556
|
+
retentionCount: 7
|
|
2557
|
+
};
|
|
2558
|
+
async initialize(context) {
|
|
2559
|
+
this.currentConfig = {
|
|
2560
|
+
retentionCount: context.addonConfig.retentionCount ?? this.currentConfig.retentionCount
|
|
2561
|
+
};
|
|
2562
|
+
const backupConfig = {
|
|
2563
|
+
backupDir: path7.join(context.locationPaths.data, "backups"),
|
|
2564
|
+
retentionCount: this.currentConfig.retentionCount
|
|
2565
|
+
};
|
|
2566
|
+
this.service = new LocalBackupService(
|
|
2567
|
+
backupConfig,
|
|
2568
|
+
context.logger,
|
|
2569
|
+
context.eventBus,
|
|
2570
|
+
context.storage
|
|
2571
|
+
);
|
|
2572
|
+
context.logger.info("Local Backup initialized");
|
|
2573
|
+
}
|
|
2574
|
+
async shutdown() {
|
|
2575
|
+
this.service = null;
|
|
2576
|
+
}
|
|
2577
|
+
getService() {
|
|
2578
|
+
if (!this.service) throw new Error("Local Backup not initialized");
|
|
2579
|
+
return this.service;
|
|
2580
|
+
}
|
|
2581
|
+
getCapabilityProvider(name) {
|
|
2582
|
+
if (name === "backup" && this.service) {
|
|
2583
|
+
return this.service;
|
|
2584
|
+
}
|
|
2585
|
+
return null;
|
|
2586
|
+
}
|
|
2587
|
+
getConfigSchema() {
|
|
2588
|
+
return {
|
|
2589
|
+
sections: [
|
|
2590
|
+
{
|
|
2591
|
+
id: "backup",
|
|
2592
|
+
title: "Backup Settings",
|
|
2593
|
+
fields: [
|
|
2594
|
+
{
|
|
2595
|
+
type: "number",
|
|
2596
|
+
key: "retentionCount",
|
|
2597
|
+
label: "Backups to Keep",
|
|
2598
|
+
description: "Maximum number of backup archives to keep; oldest are deleted first",
|
|
2599
|
+
min: 1,
|
|
2600
|
+
max: 100,
|
|
2601
|
+
step: 1,
|
|
2602
|
+
unit: "backups"
|
|
2603
|
+
}
|
|
2604
|
+
]
|
|
2605
|
+
}
|
|
2606
|
+
]
|
|
2607
|
+
};
|
|
2608
|
+
}
|
|
2609
|
+
getConfig() {
|
|
2610
|
+
return { ...this.currentConfig };
|
|
2611
|
+
}
|
|
2612
|
+
async onConfigChange(config) {
|
|
2613
|
+
this.currentConfig = {
|
|
2614
|
+
retentionCount: config.retentionCount ?? this.currentConfig.retentionCount
|
|
2615
|
+
};
|
|
2616
|
+
}
|
|
2617
|
+
};
|
|
2618
|
+
|
|
2619
|
+
// src/builtins/admin-ui/addon.ts
|
|
2620
|
+
import path8 from "path";
|
|
2621
|
+
function resolveAdminUiDistDir() {
|
|
2622
|
+
try {
|
|
2623
|
+
const adminUiPkg = __require.resolve("@camstack/addon-admin-ui/package.json");
|
|
2624
|
+
return path8.join(path8.dirname(adminUiPkg), "dist");
|
|
2625
|
+
} catch {
|
|
2626
|
+
return path8.resolve(__dirname, "..", "..", "..", "..", "addon-admin-ui", "dist");
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
var AdminUIAddon = class {
|
|
2630
|
+
id = "admin-ui";
|
|
2631
|
+
manifest = {
|
|
2632
|
+
id: "admin-ui",
|
|
2633
|
+
name: "CamStack Admin UI",
|
|
2634
|
+
version: "0.1.0",
|
|
2635
|
+
description: "Web-based administration interface for CamStack",
|
|
2636
|
+
packageName: "@camstack/addon-admin-ui",
|
|
2637
|
+
capabilities: [{ name: "admin-ui", mode: "singleton" }]
|
|
2638
|
+
};
|
|
2639
|
+
async initialize(_ctx) {
|
|
2640
|
+
}
|
|
2641
|
+
async shutdown() {
|
|
2642
|
+
}
|
|
2643
|
+
getCapabilityProvider(name) {
|
|
2644
|
+
if (name === "admin-ui") {
|
|
2645
|
+
const provider = {
|
|
2646
|
+
getStaticDir: () => resolveAdminUiDistDir(),
|
|
2647
|
+
getVersion: () => this.manifest.version
|
|
2648
|
+
};
|
|
2649
|
+
return provider;
|
|
2650
|
+
}
|
|
2651
|
+
return null;
|
|
2652
|
+
}
|
|
2653
|
+
};
|
|
2654
|
+
|
|
2655
|
+
// src/lifecycle/lifecycle-state-machine.ts
|
|
2656
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
2657
|
+
var VALID_TRANSITIONS = {
|
|
2658
|
+
stopped: ["starting", "disabled"],
|
|
2659
|
+
starting: ["running", "error", "stopping"],
|
|
2660
|
+
running: ["stopping", "error"],
|
|
2661
|
+
stopping: ["stopped", "error"],
|
|
2662
|
+
error: ["starting", "stopped", "disabled"],
|
|
2663
|
+
disabled: ["stopped"]
|
|
2664
|
+
};
|
|
2665
|
+
var LifecycleStateMachine = class {
|
|
2666
|
+
constructor(elementId, elementType, eventBus, logger) {
|
|
2667
|
+
this.elementId = elementId;
|
|
2668
|
+
this.elementType = elementType;
|
|
2669
|
+
this.eventBus = eventBus;
|
|
2670
|
+
this.logger = logger;
|
|
2671
|
+
}
|
|
2672
|
+
_state = "stopped";
|
|
2673
|
+
_error;
|
|
2674
|
+
_startedAt;
|
|
2675
|
+
_stoppedAt;
|
|
2676
|
+
_restartCount = 0;
|
|
2677
|
+
_hasStartedOnce = false;
|
|
2678
|
+
get state() {
|
|
2679
|
+
return this._state;
|
|
2680
|
+
}
|
|
2681
|
+
getStatus() {
|
|
2682
|
+
return {
|
|
2683
|
+
state: this._state,
|
|
2684
|
+
error: this._error,
|
|
2685
|
+
startedAt: this._startedAt,
|
|
2686
|
+
stoppedAt: this._stoppedAt,
|
|
2687
|
+
restartCount: this._restartCount,
|
|
2688
|
+
uptime: this._state === "running" && this._startedAt ? Date.now() - this._startedAt : 0
|
|
2689
|
+
};
|
|
2690
|
+
}
|
|
2691
|
+
transition(to, error) {
|
|
2692
|
+
const from = this._state;
|
|
2693
|
+
if (!this.isValidTransition(from, to)) {
|
|
2694
|
+
this.logger.warn(`Invalid state transition: ${from} \u2192 ${to}`);
|
|
2695
|
+
return false;
|
|
2696
|
+
}
|
|
2697
|
+
this._state = to;
|
|
2698
|
+
this._error = error;
|
|
2699
|
+
if (to === "running") {
|
|
2700
|
+
if (this._hasStartedOnce) {
|
|
2701
|
+
this._restartCount++;
|
|
2702
|
+
}
|
|
2703
|
+
this._hasStartedOnce = true;
|
|
2704
|
+
this._startedAt = Date.now();
|
|
2705
|
+
this._stoppedAt = void 0;
|
|
2706
|
+
}
|
|
2707
|
+
if (to === "stopped" || to === "error" || to === "disabled") {
|
|
2708
|
+
this._stoppedAt = Date.now();
|
|
2709
|
+
}
|
|
2710
|
+
this.logger.info(`State: ${from} \u2192 ${to}${error ? ` (${error})` : ""}`);
|
|
2711
|
+
this.eventBus.emit({
|
|
2712
|
+
id: randomUUID4(),
|
|
2713
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
2714
|
+
source: { type: this.elementType, id: this.elementId },
|
|
2715
|
+
category: `${this.elementType}.state.${to}`,
|
|
2716
|
+
data: { from, to, error, elementId: this.elementId }
|
|
2717
|
+
});
|
|
2718
|
+
return true;
|
|
2719
|
+
}
|
|
2720
|
+
incrementRestartCount() {
|
|
2721
|
+
this._restartCount++;
|
|
2722
|
+
}
|
|
2723
|
+
isValidTransition(from, to) {
|
|
2724
|
+
return VALID_TRANSITIONS[from]?.includes(to) ?? false;
|
|
2725
|
+
}
|
|
2726
|
+
};
|
|
2727
|
+
|
|
2728
|
+
// src/feature/feature-manager.ts
|
|
2729
|
+
var FeatureManager = class {
|
|
2730
|
+
constructor(configReader) {
|
|
2731
|
+
this.configReader = configReader;
|
|
2732
|
+
}
|
|
2733
|
+
isEnabled(flag) {
|
|
2734
|
+
return this.configReader.features[flag];
|
|
2735
|
+
}
|
|
2736
|
+
getManifest() {
|
|
2737
|
+
return { ...this.configReader.features };
|
|
2738
|
+
}
|
|
2739
|
+
};
|
|
2740
|
+
|
|
2741
|
+
// src/config/config-manager.ts
|
|
2742
|
+
import * as fs6 from "fs";
|
|
2743
|
+
import * as yaml from "js-yaml";
|
|
2744
|
+
|
|
2745
|
+
// src/config/config-schema.ts
|
|
2746
|
+
import { z } from "zod";
|
|
2747
|
+
var bootstrapSchema = z.object({
|
|
2748
|
+
/** Server mode: 'hub' (full server) or 'agent' (worker node) */
|
|
2749
|
+
mode: z.enum(["hub", "agent"]).default("hub"),
|
|
2750
|
+
server: z.object({
|
|
2751
|
+
port: z.number().default(4443),
|
|
2752
|
+
host: z.string().default("0.0.0.0"),
|
|
2753
|
+
dataPath: z.string().default("./data")
|
|
2754
|
+
}).default({}),
|
|
2755
|
+
auth: z.object({
|
|
2756
|
+
jwtSecret: z.string().nullable().default(null),
|
|
2757
|
+
adminUsername: z.string().default("admin"),
|
|
2758
|
+
adminPassword: z.string().default(process.env.ADMIN_PASSWORD ?? "changeme")
|
|
2759
|
+
}).default({}),
|
|
2760
|
+
/** Hub connection config — only used when mode='agent' */
|
|
2761
|
+
hub: z.object({
|
|
2762
|
+
url: z.string().default("ws://localhost:4443/agent"),
|
|
2763
|
+
token: z.string().default("")
|
|
2764
|
+
}).default({}),
|
|
2765
|
+
/** Agent-specific config — only used when mode='agent' */
|
|
2766
|
+
agent: z.object({
|
|
2767
|
+
name: z.string().default(""),
|
|
2768
|
+
/** Port for the agent status page (minimal HTML) */
|
|
2769
|
+
statusPort: z.number().default(4444)
|
|
2770
|
+
}).default({})
|
|
2771
|
+
});
|
|
2772
|
+
var RUNTIME_DEFAULTS = {
|
|
2773
|
+
"features.streaming": true,
|
|
2774
|
+
"features.notifications": true,
|
|
2775
|
+
"features.objectDetection": false,
|
|
2776
|
+
"features.remoteAccess": true,
|
|
2777
|
+
"features.agentCluster": false,
|
|
2778
|
+
"features.smartHome": true,
|
|
2779
|
+
"features.recordings": true,
|
|
2780
|
+
"features.backup": true,
|
|
2781
|
+
"features.repl": true,
|
|
2782
|
+
"retention.detectionEventsDays": 30,
|
|
2783
|
+
"retention.audioLevelsDays": 7,
|
|
2784
|
+
"logging.level": "info",
|
|
2785
|
+
"logging.retentionDays": 30,
|
|
2786
|
+
"eventBus.ringBufferSize": 1e4,
|
|
2787
|
+
"storage.provider": "sqlite-storage",
|
|
2788
|
+
"storage.locations": {
|
|
2789
|
+
data: "./data/data",
|
|
2790
|
+
media: "./data/media",
|
|
2791
|
+
recordings: "./data/recordings",
|
|
2792
|
+
cache: "/tmp/camstack-cache",
|
|
2793
|
+
logs: "./data/logs",
|
|
2794
|
+
models: "./data/models"
|
|
2795
|
+
},
|
|
2796
|
+
"addons.enabled": [
|
|
2797
|
+
"sqlite-storage",
|
|
2798
|
+
"winston-logging",
|
|
2799
|
+
"go2rtc",
|
|
2800
|
+
"recording-engine",
|
|
2801
|
+
"local-backup",
|
|
2802
|
+
"cloudflare-tunnel",
|
|
2803
|
+
"cloudflare-turn"
|
|
2804
|
+
],
|
|
2805
|
+
"providers": []
|
|
2806
|
+
};
|
|
2807
|
+
|
|
2808
|
+
// src/config/config-manager.ts
|
|
2809
|
+
var ENV_VAR_MAP = {
|
|
2810
|
+
CAMSTACK_PORT: "server.port",
|
|
2811
|
+
CAMSTACK_HOST: "server.host",
|
|
2812
|
+
CAMSTACK_DATA_PATH: "server.dataPath",
|
|
2813
|
+
CAMSTACK_JWT_SECRET: "auth.jwtSecret",
|
|
2814
|
+
CAMSTACK_ADMIN_USER: "auth.adminUsername",
|
|
2815
|
+
CAMSTACK_ADMIN_PASS: "auth.adminPassword"
|
|
2816
|
+
};
|
|
2817
|
+
var ConfigManager = class {
|
|
2818
|
+
constructor(configPath) {
|
|
2819
|
+
this.configPath = configPath;
|
|
2820
|
+
const rawYaml = this.loadYaml();
|
|
2821
|
+
const merged = this.applyEnvOverrides(rawYaml);
|
|
2822
|
+
this.bootstrapConfig = bootstrapSchema.parse(merged);
|
|
2823
|
+
this.warnDefaultCredentials();
|
|
2824
|
+
}
|
|
2825
|
+
// Non-readonly so update() can sync the in-memory view after a write.
|
|
2826
|
+
bootstrapConfig;
|
|
2827
|
+
settingsStore = null;
|
|
2828
|
+
/** Called by main.ts after the SQLite DB is ready (Phase 2). */
|
|
2829
|
+
setSettingsStore(store) {
|
|
2830
|
+
this.settingsStore = store;
|
|
2831
|
+
}
|
|
2832
|
+
/**
|
|
2833
|
+
* Get a config value by dot-notation path.
|
|
2834
|
+
* Priority: bootstrap config -> SQL system_settings -> RUNTIME_DEFAULTS fallback.
|
|
2835
|
+
*/
|
|
2836
|
+
get(path9) {
|
|
2837
|
+
const bootstrapValue = this.getFromBootstrap(path9);
|
|
2838
|
+
if (bootstrapValue !== void 0) {
|
|
2839
|
+
return bootstrapValue;
|
|
2840
|
+
}
|
|
2841
|
+
if (this.settingsStore !== null) {
|
|
2842
|
+
const sqlValue = this.settingsStore.getSystem(path9);
|
|
2843
|
+
if (sqlValue !== void 0) {
|
|
2844
|
+
return sqlValue;
|
|
2845
|
+
}
|
|
2846
|
+
const sqlNested = this.getNestedFromSystemSettings(path9);
|
|
2847
|
+
if (sqlNested !== void 0) {
|
|
2848
|
+
return sqlNested;
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
if (path9 in RUNTIME_DEFAULTS) {
|
|
2852
|
+
return RUNTIME_DEFAULTS[path9];
|
|
2853
|
+
}
|
|
2854
|
+
const nested = this.getFromRuntimeDefaults(path9);
|
|
2855
|
+
if (nested !== void 0) {
|
|
2856
|
+
return nested;
|
|
2857
|
+
}
|
|
2858
|
+
return void 0;
|
|
2859
|
+
}
|
|
2860
|
+
/**
|
|
2861
|
+
* Write a value to SQL system_settings.
|
|
2862
|
+
* Throws if the settings store is not yet wired.
|
|
2863
|
+
*/
|
|
2864
|
+
set(key, value) {
|
|
2865
|
+
if (this.settingsStore === null) {
|
|
2866
|
+
throw new Error("[ConfigManager] SettingsStore not initialized -- call setSettingsStore() first");
|
|
2867
|
+
}
|
|
2868
|
+
this.settingsStore.setSystem(key, value);
|
|
2869
|
+
}
|
|
2870
|
+
/**
|
|
2871
|
+
* Bulk-read all system_settings keys that belong to a logical section.
|
|
2872
|
+
* A "section" is the first segment of a dot-notation key (e.g. 'features', 'logging').
|
|
2873
|
+
*/
|
|
2874
|
+
getSection(section) {
|
|
2875
|
+
if (this.settingsStore !== null) {
|
|
2876
|
+
const nested = this.getNestedFromSystemSettings(section);
|
|
2877
|
+
if (nested !== void 0) return nested;
|
|
2878
|
+
}
|
|
2879
|
+
return this.getFromRuntimeDefaults(section) ?? {};
|
|
2880
|
+
}
|
|
2881
|
+
/**
|
|
2882
|
+
* Bulk-write a section of runtime settings to SQL system_settings.
|
|
2883
|
+
* Each entry in `data` is stored as `section.key`.
|
|
2884
|
+
*/
|
|
2885
|
+
setSection(section, data) {
|
|
2886
|
+
if (this.settingsStore === null) {
|
|
2887
|
+
throw new Error("[ConfigManager] SettingsStore not initialized -- call setSettingsStore() first");
|
|
2888
|
+
}
|
|
2889
|
+
for (const [key, value] of Object.entries(data)) {
|
|
2890
|
+
this.settingsStore.setSystem(`${section}.${key}`, value);
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
// ---------------------------------------------------------------------------
|
|
2894
|
+
// Addon / Provider / Device scoped config
|
|
2895
|
+
// ---------------------------------------------------------------------------
|
|
2896
|
+
/** Read all config for an addon from addon_settings. */
|
|
2897
|
+
getAddonConfig(addonId) {
|
|
2898
|
+
if (this.settingsStore !== null) {
|
|
2899
|
+
return this.settingsStore.getAllAddon(addonId);
|
|
2900
|
+
}
|
|
2901
|
+
return this.getFromBootstrap(`addons.${addonId}`) ?? {};
|
|
2902
|
+
}
|
|
2903
|
+
/** Write (bulk-replace) config for an addon to addon_settings. */
|
|
2904
|
+
setAddonConfig(addonId, config) {
|
|
2905
|
+
if (this.settingsStore === null) {
|
|
2906
|
+
throw new Error("[ConfigManager] SettingsStore not initialized -- call setSettingsStore() first");
|
|
2907
|
+
}
|
|
2908
|
+
this.settingsStore.setAllAddon(addonId, config);
|
|
2909
|
+
}
|
|
2910
|
+
/** Read all config for a provider from provider_settings. */
|
|
2911
|
+
getProviderConfig(providerId) {
|
|
2912
|
+
if (this.settingsStore !== null) {
|
|
2913
|
+
return this.settingsStore.getAllProvider(providerId);
|
|
2914
|
+
}
|
|
2915
|
+
return {};
|
|
2916
|
+
}
|
|
2917
|
+
/** Write (upsert) a single key for a provider to provider_settings. */
|
|
2918
|
+
setProviderConfig(providerId, key, value) {
|
|
2919
|
+
if (this.settingsStore === null) {
|
|
2920
|
+
throw new Error("[ConfigManager] SettingsStore not initialized -- call setSettingsStore() first");
|
|
2921
|
+
}
|
|
2922
|
+
this.settingsStore.setProvider(providerId, key, value);
|
|
2923
|
+
}
|
|
2924
|
+
/** Read all config for a device from device_settings. */
|
|
2925
|
+
getDeviceConfig(deviceId) {
|
|
2926
|
+
if (this.settingsStore !== null) {
|
|
2927
|
+
return this.settingsStore.getAllDevice(deviceId);
|
|
2928
|
+
}
|
|
2929
|
+
return {};
|
|
2930
|
+
}
|
|
2931
|
+
/** Write (upsert) a single key for a device to device_settings. */
|
|
2932
|
+
setDeviceConfig(deviceId, key, value) {
|
|
2933
|
+
if (this.settingsStore === null) {
|
|
2934
|
+
throw new Error("[ConfigManager] SettingsStore not initialized -- call setSettingsStore() first");
|
|
2935
|
+
}
|
|
2936
|
+
this.settingsStore.setDevice(deviceId, key, value);
|
|
2937
|
+
}
|
|
2938
|
+
/** Get a value from the parsed bootstrap config */
|
|
2939
|
+
getBootstrap(path9) {
|
|
2940
|
+
return this.getFromBootstrap(path9);
|
|
2941
|
+
}
|
|
2942
|
+
/** Features accessor -- reads from SQL when available, falls back to RUNTIME_DEFAULTS */
|
|
2943
|
+
get features() {
|
|
2944
|
+
const g = (key) => this.get(`features.${key}`) ?? RUNTIME_DEFAULTS[`features.${key}`];
|
|
2945
|
+
return {
|
|
2946
|
+
streaming: g("streaming"),
|
|
2947
|
+
notifications: g("notifications"),
|
|
2948
|
+
objectDetection: g("objectDetection"),
|
|
2949
|
+
remoteAccess: g("remoteAccess"),
|
|
2950
|
+
agentCluster: g("agentCluster"),
|
|
2951
|
+
smartHome: g("smartHome"),
|
|
2952
|
+
recordings: g("recordings"),
|
|
2953
|
+
backup: g("backup"),
|
|
2954
|
+
repl: g("repl")
|
|
2955
|
+
};
|
|
2956
|
+
}
|
|
2957
|
+
/**
|
|
2958
|
+
* Returns a merged view of bootstrap config + runtime defaults for backward compat.
|
|
2959
|
+
*/
|
|
2960
|
+
get raw() {
|
|
2961
|
+
const features = {
|
|
2962
|
+
streaming: RUNTIME_DEFAULTS["features.streaming"],
|
|
2963
|
+
notifications: RUNTIME_DEFAULTS["features.notifications"],
|
|
2964
|
+
objectDetection: RUNTIME_DEFAULTS["features.objectDetection"],
|
|
2965
|
+
remoteAccess: RUNTIME_DEFAULTS["features.remoteAccess"],
|
|
2966
|
+
agentCluster: RUNTIME_DEFAULTS["features.agentCluster"],
|
|
2967
|
+
smartHome: RUNTIME_DEFAULTS["features.smartHome"],
|
|
2968
|
+
recordings: RUNTIME_DEFAULTS["features.recordings"],
|
|
2969
|
+
backup: RUNTIME_DEFAULTS["features.backup"],
|
|
2970
|
+
repl: RUNTIME_DEFAULTS["features.repl"]
|
|
2971
|
+
};
|
|
2972
|
+
return {
|
|
2973
|
+
...this.bootstrapConfig,
|
|
2974
|
+
features,
|
|
2975
|
+
storage: RUNTIME_DEFAULTS["storage.locations"] !== void 0 ? {
|
|
2976
|
+
provider: RUNTIME_DEFAULTS["storage.provider"],
|
|
2977
|
+
locations: RUNTIME_DEFAULTS["storage.locations"]
|
|
2978
|
+
} : { provider: "sqlite-storage", locations: {} },
|
|
2979
|
+
logging: {
|
|
2980
|
+
level: RUNTIME_DEFAULTS["logging.level"],
|
|
2981
|
+
retentionDays: RUNTIME_DEFAULTS["logging.retentionDays"]
|
|
2982
|
+
},
|
|
2983
|
+
eventBus: {
|
|
2984
|
+
ringBufferSize: RUNTIME_DEFAULTS["eventBus.ringBufferSize"]
|
|
2985
|
+
},
|
|
2986
|
+
addons: {
|
|
2987
|
+
enabled: RUNTIME_DEFAULTS["addons.enabled"]
|
|
2988
|
+
},
|
|
2989
|
+
retention: {
|
|
2990
|
+
detectionEventsDays: RUNTIME_DEFAULTS["retention.detectionEventsDays"],
|
|
2991
|
+
audioLevelsDays: RUNTIME_DEFAULTS["retention.audioLevelsDays"]
|
|
2992
|
+
},
|
|
2993
|
+
providers: RUNTIME_DEFAULTS["providers"]
|
|
2994
|
+
};
|
|
2995
|
+
}
|
|
2996
|
+
/**
|
|
2997
|
+
* Atomically update one top-level section of config.yaml and sync in-memory.
|
|
2998
|
+
* Only bootstrap sections (server, auth) are persisted. Runtime settings should
|
|
2999
|
+
* go to SQL (Plan B).
|
|
3000
|
+
*/
|
|
3001
|
+
update(section, data) {
|
|
3002
|
+
let raw = {};
|
|
3003
|
+
if (fs6.existsSync(this.configPath)) {
|
|
3004
|
+
raw = yaml.load(fs6.readFileSync(this.configPath, "utf-8")) ?? {};
|
|
3005
|
+
}
|
|
3006
|
+
const existing = raw[section] ?? {};
|
|
3007
|
+
raw[section] = { ...existing, ...data };
|
|
3008
|
+
const bootstrapSections = {};
|
|
3009
|
+
if (raw.server) bootstrapSections.server = raw.server;
|
|
3010
|
+
if (raw.auth) bootstrapSections.auth = raw.auth;
|
|
3011
|
+
const validation = bootstrapSchema.safeParse(bootstrapSections);
|
|
3012
|
+
if (!validation.success) {
|
|
3013
|
+
throw new Error(`[ConfigManager] Invalid config update for section "${section}": ${validation.error.message}`);
|
|
3014
|
+
}
|
|
3015
|
+
const tmpPath = `${this.configPath}.tmp`;
|
|
3016
|
+
fs6.writeFileSync(tmpPath, yaml.dump(raw, { lineWidth: 120, indent: 2, quotingType: '"' }), "utf-8");
|
|
3017
|
+
fs6.renameSync(tmpPath, this.configPath);
|
|
3018
|
+
this.bootstrapConfig = validation.data;
|
|
3019
|
+
}
|
|
3020
|
+
/**
|
|
3021
|
+
* Deep-set a value in a nested plain object using a dot-notation path.
|
|
3022
|
+
* Returns a new object (immutable).
|
|
3023
|
+
*/
|
|
3024
|
+
setNested(obj, path9, value) {
|
|
3025
|
+
const [head, ...rest] = path9.split(".");
|
|
3026
|
+
if (!head) return obj;
|
|
3027
|
+
if (rest.length === 0) {
|
|
3028
|
+
return { ...obj, [head]: value };
|
|
3029
|
+
}
|
|
3030
|
+
const child = obj[head] ?? {};
|
|
3031
|
+
return { ...obj, [head]: this.setNested(child, rest.join("."), value) };
|
|
3032
|
+
}
|
|
3033
|
+
/**
|
|
3034
|
+
* Apply env var overrides onto the raw YAML object.
|
|
3035
|
+
* Only bootstrap-level env vars are applied.
|
|
3036
|
+
*/
|
|
3037
|
+
applyEnvOverrides(raw) {
|
|
3038
|
+
let result = { ...raw };
|
|
3039
|
+
for (const [envKey, configPath] of Object.entries(ENV_VAR_MAP)) {
|
|
3040
|
+
const envValue = process.env[envKey];
|
|
3041
|
+
if (envValue === void 0 || envValue === "") continue;
|
|
3042
|
+
const coerced = configPath === "server.port" ? Number(envValue) : envValue;
|
|
3043
|
+
result = this.setNested(result, configPath, coerced);
|
|
3044
|
+
console.log(`[ConfigManager] Env override applied: ${envKey} \u2192 ${configPath}`);
|
|
3045
|
+
}
|
|
3046
|
+
return result;
|
|
3047
|
+
}
|
|
3048
|
+
loadYaml() {
|
|
3049
|
+
if (!fs6.existsSync(this.configPath)) {
|
|
3050
|
+
console.warn(
|
|
3051
|
+
`[ConfigManager] Config file not found at: ${this.configPath}
|
|
3052
|
+
\u2192 Using built-in defaults. Set CONFIG_PATH env var or create the file.
|
|
3053
|
+
\u2192 Example path from project root: ./server/backend/data/config.yaml`
|
|
3054
|
+
);
|
|
3055
|
+
return {};
|
|
3056
|
+
}
|
|
3057
|
+
const content = fs6.readFileSync(this.configPath, "utf-8");
|
|
3058
|
+
const parsed = yaml.load(content) ?? {};
|
|
3059
|
+
console.log(`[ConfigManager] Loaded config from: ${this.configPath}`);
|
|
3060
|
+
return parsed;
|
|
3061
|
+
}
|
|
3062
|
+
warnDefaultCredentials() {
|
|
3063
|
+
if (this.bootstrapConfig.auth.adminPassword === "changeme") {
|
|
3064
|
+
console.warn(
|
|
3065
|
+
`[ConfigManager] Warning: Using default admin password "changeme". Set auth.adminPassword in your config.yaml or the ADMIN_PASSWORD env var.`
|
|
3066
|
+
);
|
|
3067
|
+
}
|
|
3068
|
+
}
|
|
3069
|
+
getFromBootstrap(path9) {
|
|
3070
|
+
const keys = path9.split(".");
|
|
3071
|
+
let current = this.bootstrapConfig;
|
|
3072
|
+
for (const key of keys) {
|
|
3073
|
+
if (current === null || current === void 0 || typeof current !== "object") {
|
|
3074
|
+
return void 0;
|
|
3075
|
+
}
|
|
3076
|
+
current = current[key];
|
|
3077
|
+
}
|
|
3078
|
+
return current;
|
|
3079
|
+
}
|
|
3080
|
+
getFromRuntimeDefaults(path9) {
|
|
3081
|
+
const prefix = path9 + ".";
|
|
3082
|
+
const result = {};
|
|
3083
|
+
let found = false;
|
|
3084
|
+
for (const [key, value] of Object.entries(RUNTIME_DEFAULTS)) {
|
|
3085
|
+
if (key.startsWith(prefix)) {
|
|
3086
|
+
const subKey = key.slice(prefix.length);
|
|
3087
|
+
result[subKey] = value;
|
|
3088
|
+
found = true;
|
|
3089
|
+
}
|
|
3090
|
+
}
|
|
3091
|
+
return found ? result : void 0;
|
|
3092
|
+
}
|
|
3093
|
+
/**
|
|
3094
|
+
* Perform a prefix-based nested lookup against SQL system_settings.
|
|
3095
|
+
* e.g. path='features' matches keys 'features.streaming', 'features.notifications', etc.
|
|
3096
|
+
* Returns an object keyed by the sub-key, or undefined if nothing is found.
|
|
3097
|
+
*/
|
|
3098
|
+
getNestedFromSystemSettings(path9) {
|
|
3099
|
+
if (this.settingsStore === null) return void 0;
|
|
3100
|
+
const all = this.settingsStore.getAllSystem();
|
|
3101
|
+
const prefix = path9 + ".";
|
|
3102
|
+
const result = {};
|
|
3103
|
+
let found = false;
|
|
3104
|
+
for (const [key, value] of Object.entries(all)) {
|
|
3105
|
+
if (key.startsWith(prefix)) {
|
|
3106
|
+
result[key.slice(prefix.length)] = value;
|
|
3107
|
+
found = true;
|
|
3108
|
+
}
|
|
3109
|
+
}
|
|
3110
|
+
return found ? result : void 0;
|
|
3111
|
+
}
|
|
3112
|
+
};
|
|
3113
|
+
|
|
3114
|
+
// src/events/system-event-bus.ts
|
|
3115
|
+
var EventRingBuffer = class {
|
|
3116
|
+
constructor(capacity) {
|
|
3117
|
+
this.capacity = capacity;
|
|
3118
|
+
this.buffer = new Array(capacity);
|
|
3119
|
+
}
|
|
3120
|
+
buffer;
|
|
3121
|
+
head = 0;
|
|
3122
|
+
count = 0;
|
|
3123
|
+
push(event) {
|
|
3124
|
+
this.buffer[this.head] = event;
|
|
3125
|
+
this.head = (this.head + 1) % this.capacity;
|
|
3126
|
+
if (this.count < this.capacity) {
|
|
3127
|
+
this.count++;
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
getAll() {
|
|
3131
|
+
const result = [];
|
|
3132
|
+
for (let i = 0; i < this.count; i++) {
|
|
3133
|
+
const index = (this.head - 1 - i + this.capacity) % this.capacity;
|
|
3134
|
+
result.push(this.buffer[index]);
|
|
3135
|
+
}
|
|
3136
|
+
return result;
|
|
3137
|
+
}
|
|
3138
|
+
query(filter, limit) {
|
|
3139
|
+
let result = [...this.getAll()];
|
|
3140
|
+
if (filter) {
|
|
3141
|
+
result = result.filter((event) => matchesFilter(event, filter));
|
|
3142
|
+
}
|
|
3143
|
+
if (limit !== void 0 && limit > 0) {
|
|
3144
|
+
return result.slice(0, limit);
|
|
3145
|
+
}
|
|
3146
|
+
return result;
|
|
3147
|
+
}
|
|
3148
|
+
};
|
|
3149
|
+
function matchesCategory(eventCategory, filterCategory) {
|
|
3150
|
+
if (filterCategory.endsWith(".*")) {
|
|
3151
|
+
const prefix = filterCategory.slice(0, -2);
|
|
3152
|
+
return eventCategory.startsWith(prefix + ".") || eventCategory === prefix;
|
|
3153
|
+
}
|
|
3154
|
+
return eventCategory === filterCategory;
|
|
3155
|
+
}
|
|
3156
|
+
function matchesFilter(event, filter) {
|
|
3157
|
+
if (filter.category && !matchesCategory(event.category, filter.category)) {
|
|
3158
|
+
return false;
|
|
3159
|
+
}
|
|
3160
|
+
if (filter.source) {
|
|
3161
|
+
if (event.source.type !== filter.source.type || event.source.id !== filter.source.id) {
|
|
3162
|
+
return false;
|
|
3163
|
+
}
|
|
3164
|
+
}
|
|
3165
|
+
if (filter.since && event.timestamp < filter.since) {
|
|
3166
|
+
return false;
|
|
3167
|
+
}
|
|
3168
|
+
return true;
|
|
3169
|
+
}
|
|
3170
|
+
var SystemEventBus = class {
|
|
3171
|
+
ringBuffer;
|
|
3172
|
+
subscribers = [];
|
|
3173
|
+
constructor(bufferSize = 1e4) {
|
|
3174
|
+
this.ringBuffer = new EventRingBuffer(bufferSize);
|
|
3175
|
+
}
|
|
3176
|
+
emit(event) {
|
|
3177
|
+
this.ringBuffer.push(event);
|
|
3178
|
+
for (const subscriber of this.subscribers) {
|
|
3179
|
+
if (matchesFilter(event, subscriber.filter)) {
|
|
3180
|
+
subscriber.callback(event);
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
}
|
|
3184
|
+
subscribe(filter, handler) {
|
|
3185
|
+
const subscriber = { filter, callback: handler };
|
|
3186
|
+
this.subscribers.push(subscriber);
|
|
3187
|
+
return () => {
|
|
3188
|
+
const index = this.subscribers.indexOf(subscriber);
|
|
3189
|
+
if (index !== -1) {
|
|
3190
|
+
this.subscribers.splice(index, 1);
|
|
3191
|
+
}
|
|
3192
|
+
};
|
|
3193
|
+
}
|
|
3194
|
+
getRecent(filter, limit) {
|
|
3195
|
+
return this.ringBuffer.query(filter, limit);
|
|
3196
|
+
}
|
|
3197
|
+
};
|
|
3198
|
+
|
|
3199
|
+
// src/logging/log-ring-buffer.ts
|
|
3200
|
+
var LogRingBuffer = class {
|
|
3201
|
+
constructor(capacity = 1e4) {
|
|
3202
|
+
this.capacity = capacity;
|
|
3203
|
+
this.buffer = new Array(capacity);
|
|
3204
|
+
}
|
|
3205
|
+
buffer;
|
|
3206
|
+
head = 0;
|
|
3207
|
+
count = 0;
|
|
3208
|
+
push(entry) {
|
|
3209
|
+
this.buffer[this.head] = entry;
|
|
3210
|
+
this.head = (this.head + 1) % this.capacity;
|
|
3211
|
+
if (this.count < this.capacity) {
|
|
3212
|
+
this.count++;
|
|
3213
|
+
}
|
|
3214
|
+
}
|
|
3215
|
+
getAll() {
|
|
3216
|
+
const result = [];
|
|
3217
|
+
for (let i = 0; i < this.count; i++) {
|
|
3218
|
+
const index = (this.head - 1 - i + this.capacity) % this.capacity;
|
|
3219
|
+
result.push(this.buffer[index]);
|
|
3220
|
+
}
|
|
3221
|
+
return result;
|
|
3222
|
+
}
|
|
3223
|
+
query(filter) {
|
|
3224
|
+
const all = this.getAll();
|
|
3225
|
+
let result = all;
|
|
3226
|
+
if (filter.scope && filter.scope.length > 0) {
|
|
3227
|
+
result = result.filter((entry) => {
|
|
3228
|
+
for (let i = 0; i < filter.scope.length; i++) {
|
|
3229
|
+
if (entry.scope[i] !== filter.scope[i]) return false;
|
|
3230
|
+
}
|
|
3231
|
+
return true;
|
|
3232
|
+
});
|
|
3233
|
+
}
|
|
3234
|
+
if (filter.level) {
|
|
3235
|
+
result = result.filter((entry) => entry.level === filter.level);
|
|
3236
|
+
}
|
|
3237
|
+
if (filter.since) {
|
|
3238
|
+
result = result.filter((entry) => entry.timestamp >= filter.since);
|
|
3239
|
+
}
|
|
3240
|
+
if (filter.until) {
|
|
3241
|
+
result = result.filter((entry) => entry.timestamp <= filter.until);
|
|
3242
|
+
}
|
|
3243
|
+
if (filter.limit !== void 0 && filter.limit > 0) {
|
|
3244
|
+
result = result.slice(0, filter.limit);
|
|
3245
|
+
}
|
|
3246
|
+
return result;
|
|
3247
|
+
}
|
|
3248
|
+
};
|
|
3249
|
+
|
|
3250
|
+
// src/logging/scoped-logger.ts
|
|
3251
|
+
var ScopedLogger = class _ScopedLogger {
|
|
3252
|
+
constructor(scope, writeFn) {
|
|
3253
|
+
this.scope = scope;
|
|
3254
|
+
this.writeFn = writeFn;
|
|
3255
|
+
}
|
|
3256
|
+
debug(message, meta) {
|
|
3257
|
+
this.write("debug", message, meta);
|
|
3258
|
+
}
|
|
3259
|
+
info(message, meta) {
|
|
3260
|
+
this.write("info", message, meta);
|
|
3261
|
+
}
|
|
3262
|
+
warn(message, meta) {
|
|
3263
|
+
this.write("warn", message, meta);
|
|
3264
|
+
}
|
|
3265
|
+
error(message, meta) {
|
|
3266
|
+
this.write("error", message, meta);
|
|
3267
|
+
}
|
|
3268
|
+
child(childScope) {
|
|
3269
|
+
return new _ScopedLogger([...this.scope, childScope], this.writeFn);
|
|
3270
|
+
}
|
|
3271
|
+
write(level, message, meta) {
|
|
3272
|
+
const entry = {
|
|
3273
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
3274
|
+
level,
|
|
3275
|
+
scope: this.scope,
|
|
3276
|
+
message,
|
|
3277
|
+
...meta !== void 0 ? { meta } : {}
|
|
3278
|
+
};
|
|
3279
|
+
this.writeFn(entry);
|
|
3280
|
+
}
|
|
3281
|
+
};
|
|
3282
|
+
|
|
3283
|
+
// src/logging/log-manager.ts
|
|
3284
|
+
var LogManager = class {
|
|
3285
|
+
ringBuffer;
|
|
3286
|
+
destinations = [];
|
|
3287
|
+
constructor(bufferSize = 1e4) {
|
|
3288
|
+
this.ringBuffer = new LogRingBuffer(bufferSize);
|
|
3289
|
+
}
|
|
3290
|
+
createLogger(scope) {
|
|
3291
|
+
return new ScopedLogger([scope], (entry) => {
|
|
3292
|
+
this.ringBuffer.push(entry);
|
|
3293
|
+
for (const dest of this.destinations) {
|
|
3294
|
+
dest.write(entry);
|
|
3295
|
+
}
|
|
3296
|
+
});
|
|
3297
|
+
}
|
|
3298
|
+
addDestination(dest) {
|
|
3299
|
+
this.destinations.push(dest);
|
|
3300
|
+
}
|
|
3301
|
+
removeDestination(dest) {
|
|
3302
|
+
const index = this.destinations.indexOf(dest);
|
|
3303
|
+
if (index !== -1) {
|
|
3304
|
+
this.destinations.splice(index, 1);
|
|
3305
|
+
}
|
|
3306
|
+
}
|
|
3307
|
+
query(filter) {
|
|
3308
|
+
return this.ringBuffer.query(filter);
|
|
3309
|
+
}
|
|
3310
|
+
};
|
|
3311
|
+
|
|
3312
|
+
// src/storage/storage-manager.ts
|
|
3313
|
+
var StorageManager = class {
|
|
3314
|
+
provider = null;
|
|
3315
|
+
locationManager = null;
|
|
3316
|
+
setProvider(provider) {
|
|
3317
|
+
this.provider = provider;
|
|
3318
|
+
}
|
|
3319
|
+
getProvider() {
|
|
3320
|
+
if (!this.provider) {
|
|
3321
|
+
throw new Error("No storage provider configured");
|
|
3322
|
+
}
|
|
3323
|
+
return this.provider;
|
|
3324
|
+
}
|
|
3325
|
+
/**
|
|
3326
|
+
* Set the StorageLocationManager (called from main.ts during Phase 2 boot,
|
|
3327
|
+
* before NestJS lifecycle hooks run).
|
|
3328
|
+
*/
|
|
3329
|
+
setLocationManager(manager) {
|
|
3330
|
+
this.locationManager = manager;
|
|
3331
|
+
}
|
|
3332
|
+
/**
|
|
3333
|
+
* Get the StorageLocationManager (for file path resolution).
|
|
3334
|
+
* Available after Phase 2 boot (main.ts calls setLocationManager).
|
|
3335
|
+
*/
|
|
3336
|
+
getLocationManager() {
|
|
3337
|
+
if (!this.locationManager) {
|
|
3338
|
+
throw new Error("StorageLocationManager not initialized -- ensure Phase 2 boot ran before accessing this");
|
|
3339
|
+
}
|
|
3340
|
+
return this.locationManager;
|
|
3341
|
+
}
|
|
3342
|
+
/**
|
|
3343
|
+
* Initialize the StorageLocationManager with a dataPath and set it on this service.
|
|
3344
|
+
* Called during 3-phase boot from main.ts (Phase 2).
|
|
3345
|
+
*/
|
|
3346
|
+
async initializeLocations(dataPath) {
|
|
3347
|
+
const { StorageLocationManager: StorageLocationManager2 } = await import("./storage-location-manager-F4YZMHGM.mjs");
|
|
3348
|
+
const manager = new StorageLocationManager2(dataPath);
|
|
3349
|
+
await manager.initializeDefaults();
|
|
3350
|
+
this.locationManager = manager;
|
|
3351
|
+
}
|
|
3352
|
+
/**
|
|
3353
|
+
* Return the base filesystem path for a named storage location.
|
|
3354
|
+
* Convenience wrapper around locationManager.getBackend(name).basePath.
|
|
3355
|
+
* Available after Phase 2 boot (main.ts calls setLocationManager).
|
|
3356
|
+
*/
|
|
3357
|
+
getLocationPath(name) {
|
|
3358
|
+
return this.getLocationManager().getBackend(name).basePath;
|
|
3359
|
+
}
|
|
3360
|
+
/**
|
|
3361
|
+
* Get a storage location, optionally namespaced.
|
|
3362
|
+
* Without namespace: returns the raw location (e.g., 'config' -> config DB)
|
|
3363
|
+
* With namespace: returns a scoped view where all collections/paths are prefixed
|
|
3364
|
+
* e.g., getLocation('addon', 'providers/frigate-1') -> collections prefixed with 'providers/frigate-1/'
|
|
3365
|
+
*/
|
|
3366
|
+
getLocation(name, namespace) {
|
|
3367
|
+
const LEGACY_MAP = {
|
|
3368
|
+
config: "data",
|
|
3369
|
+
// old 'config' -> new 'data' (SQLite DB location)
|
|
3370
|
+
events: "data",
|
|
3371
|
+
// old 'events' -> new 'data'
|
|
3372
|
+
addon: "data"
|
|
3373
|
+
// old 'addon' -> new 'data'
|
|
3374
|
+
};
|
|
3375
|
+
const mappedName = LEGACY_MAP[name] ?? name;
|
|
3376
|
+
const location = this.getProvider().getLocation(mappedName);
|
|
3377
|
+
if (!namespace) return location;
|
|
3378
|
+
return this.createNamespacedLocation(location, namespace);
|
|
3379
|
+
}
|
|
3380
|
+
createNamespacedLocation(location, namespace) {
|
|
3381
|
+
const prefix = namespace.endsWith("/") ? namespace : `${namespace}/`;
|
|
3382
|
+
return {
|
|
3383
|
+
structured: location.structured ? {
|
|
3384
|
+
async query(collection, filter) {
|
|
3385
|
+
return location.structured.query(`${prefix}${collection}`, filter);
|
|
3386
|
+
},
|
|
3387
|
+
async insert(record) {
|
|
3388
|
+
return location.structured.insert({ ...record, collection: `${prefix}${record.collection}` });
|
|
3389
|
+
},
|
|
3390
|
+
async update(collection, id, data) {
|
|
3391
|
+
return location.structured.update(`${prefix}${collection}`, id, data);
|
|
3392
|
+
},
|
|
3393
|
+
async delete(collection, id) {
|
|
3394
|
+
return location.structured.delete(`${prefix}${collection}`, id);
|
|
3395
|
+
},
|
|
3396
|
+
async count(collection, filter) {
|
|
3397
|
+
return location.structured.count(`${prefix}${collection}`, filter);
|
|
3398
|
+
}
|
|
3399
|
+
} : void 0,
|
|
3400
|
+
files: location.files ? {
|
|
3401
|
+
async readFile(path9) {
|
|
3402
|
+
return location.files.readFile(`${prefix}${path9}`);
|
|
3403
|
+
},
|
|
3404
|
+
async writeFile(path9, data) {
|
|
3405
|
+
return location.files.writeFile(`${prefix}${path9}`, data);
|
|
3406
|
+
},
|
|
3407
|
+
async deleteFile(path9) {
|
|
3408
|
+
return location.files.deleteFile(`${prefix}${path9}`);
|
|
3409
|
+
},
|
|
3410
|
+
async listFiles(filePrefix) {
|
|
3411
|
+
return location.files.listFiles(`${prefix}${filePrefix ?? ""}`);
|
|
3412
|
+
},
|
|
3413
|
+
async getFileUrl(path9) {
|
|
3414
|
+
return location.files.getFileUrl(`${prefix}${path9}`);
|
|
3415
|
+
},
|
|
3416
|
+
async exists(path9) {
|
|
3417
|
+
return location.files.exists(`${prefix}${path9}`);
|
|
3418
|
+
}
|
|
3419
|
+
} : void 0
|
|
3420
|
+
};
|
|
3421
|
+
}
|
|
3422
|
+
};
|
|
3423
|
+
|
|
3424
|
+
// src/storage/settings-store.ts
|
|
3425
|
+
import Database2 from "better-sqlite3";
|
|
3426
|
+
|
|
3427
|
+
// src/storage/sql-schema.ts
|
|
3428
|
+
var CORE_TABLE_DDL = [
|
|
3429
|
+
// Settings tables
|
|
3430
|
+
`CREATE TABLE IF NOT EXISTS system_settings (
|
|
3431
|
+
key TEXT PRIMARY KEY,
|
|
3432
|
+
value JSON NOT NULL,
|
|
3433
|
+
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
3434
|
+
)`,
|
|
3435
|
+
`CREATE TABLE IF NOT EXISTS addon_settings (
|
|
3436
|
+
addon_id TEXT NOT NULL,
|
|
3437
|
+
key TEXT NOT NULL,
|
|
3438
|
+
value JSON NOT NULL,
|
|
3439
|
+
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
3440
|
+
PRIMARY KEY (addon_id, key)
|
|
3441
|
+
)`,
|
|
3442
|
+
`CREATE TABLE IF NOT EXISTS provider_settings (
|
|
3443
|
+
provider_id TEXT NOT NULL,
|
|
3444
|
+
key TEXT NOT NULL,
|
|
3445
|
+
value JSON NOT NULL,
|
|
3446
|
+
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
3447
|
+
PRIMARY KEY (provider_id, key)
|
|
3448
|
+
)`,
|
|
3449
|
+
`CREATE TABLE IF NOT EXISTS device_settings (
|
|
3450
|
+
device_id TEXT NOT NULL,
|
|
3451
|
+
key TEXT NOT NULL,
|
|
3452
|
+
value JSON NOT NULL,
|
|
3453
|
+
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
3454
|
+
PRIMARY KEY (device_id, key)
|
|
3455
|
+
)`,
|
|
3456
|
+
// Detection events
|
|
3457
|
+
`CREATE TABLE IF NOT EXISTS detection_events (
|
|
3458
|
+
id TEXT PRIMARY KEY,
|
|
3459
|
+
timestamp INTEGER NOT NULL,
|
|
3460
|
+
device_id TEXT NOT NULL,
|
|
3461
|
+
class_name TEXT NOT NULL,
|
|
3462
|
+
score REAL NOT NULL,
|
|
3463
|
+
severity TEXT NOT NULL,
|
|
3464
|
+
track_id TEXT,
|
|
3465
|
+
zones JSON,
|
|
3466
|
+
recognition JSON,
|
|
3467
|
+
media_files JSON,
|
|
3468
|
+
data JSON
|
|
3469
|
+
)`,
|
|
3470
|
+
`CREATE INDEX IF NOT EXISTS idx_det_device_ts ON detection_events(device_id, timestamp)`,
|
|
3471
|
+
`CREATE INDEX IF NOT EXISTS idx_det_class_ts ON detection_events(class_name, timestamp)`,
|
|
3472
|
+
// Audio levels
|
|
3473
|
+
`CREATE TABLE IF NOT EXISTS audio_levels (
|
|
3474
|
+
id TEXT PRIMARY KEY,
|
|
3475
|
+
timestamp INTEGER NOT NULL,
|
|
3476
|
+
device_id TEXT NOT NULL,
|
|
3477
|
+
dbfs REAL NOT NULL,
|
|
3478
|
+
rms REAL NOT NULL,
|
|
3479
|
+
state TEXT NOT NULL
|
|
3480
|
+
)`,
|
|
3481
|
+
`CREATE INDEX IF NOT EXISTS idx_audio_device_ts ON audio_levels(device_id, timestamp)`,
|
|
3482
|
+
// Track trails
|
|
3483
|
+
`CREATE TABLE IF NOT EXISTS track_trails (
|
|
3484
|
+
track_id TEXT PRIMARY KEY,
|
|
3485
|
+
device_id TEXT NOT NULL,
|
|
3486
|
+
class_name TEXT NOT NULL,
|
|
3487
|
+
first_seen INTEGER NOT NULL,
|
|
3488
|
+
last_seen INTEGER NOT NULL,
|
|
3489
|
+
positions JSON NOT NULL,
|
|
3490
|
+
snapshots JSON,
|
|
3491
|
+
total_distance REAL,
|
|
3492
|
+
zones_visited JSON
|
|
3493
|
+
)`,
|
|
3494
|
+
`CREATE INDEX IF NOT EXISTS idx_trails_device_ts ON track_trails(device_id, first_seen)`
|
|
3495
|
+
];
|
|
3496
|
+
function addonTableToDdl(schema) {
|
|
3497
|
+
const pks = schema.columns.filter((c) => c.primaryKey).map((c) => c.name);
|
|
3498
|
+
const colDefs = schema.columns.map((c) => {
|
|
3499
|
+
const parts = [c.name, c.type];
|
|
3500
|
+
if (c.notNull) parts.push("NOT NULL");
|
|
3501
|
+
return parts.join(" ");
|
|
3502
|
+
});
|
|
3503
|
+
let ddl = `CREATE TABLE IF NOT EXISTS ${schema.name} (
|
|
3504
|
+
${colDefs.join(",\n ")}`;
|
|
3505
|
+
if (pks.length > 0) {
|
|
3506
|
+
ddl += `,
|
|
3507
|
+
PRIMARY KEY (${pks.join(", ")})`;
|
|
3508
|
+
}
|
|
3509
|
+
ddl += "\n)";
|
|
3510
|
+
const stmts = [ddl];
|
|
3511
|
+
for (const idx of schema.indexes ?? []) {
|
|
3512
|
+
const unique = idx.unique ? "UNIQUE " : "";
|
|
3513
|
+
stmts.push(`CREATE ${unique}INDEX IF NOT EXISTS ${idx.name} ON ${schema.name}(${idx.columns.join(", ")})`);
|
|
3514
|
+
}
|
|
3515
|
+
return stmts;
|
|
3516
|
+
}
|
|
3517
|
+
|
|
3518
|
+
// src/storage/settings-store.ts
|
|
3519
|
+
var SettingsStore = class {
|
|
3520
|
+
db;
|
|
3521
|
+
constructor(dbPath) {
|
|
3522
|
+
this.db = new Database2(dbPath);
|
|
3523
|
+
this.db.pragma("journal_mode = WAL");
|
|
3524
|
+
this.db.pragma("foreign_keys = ON");
|
|
3525
|
+
this.initTables();
|
|
3526
|
+
}
|
|
3527
|
+
// ---------------------------------------------------------------------------
|
|
3528
|
+
// System settings
|
|
3529
|
+
// ---------------------------------------------------------------------------
|
|
3530
|
+
getSystem(key) {
|
|
3531
|
+
const row = this.db.prepare("SELECT value FROM system_settings WHERE key = ?").get(key);
|
|
3532
|
+
if (row === void 0) return void 0;
|
|
3533
|
+
return JSON.parse(row.value);
|
|
3534
|
+
}
|
|
3535
|
+
setSystem(key, value) {
|
|
3536
|
+
this.db.prepare(
|
|
3537
|
+
`INSERT INTO system_settings (key, value, updated_at) VALUES (?, json(?), unixepoch())
|
|
3538
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
|
|
3539
|
+
).run(key, JSON.stringify(value));
|
|
3540
|
+
}
|
|
3541
|
+
getAllSystem() {
|
|
3542
|
+
const rows = this.db.prepare("SELECT key, value FROM system_settings").all();
|
|
3543
|
+
return Object.fromEntries(rows.map((r) => [r.key, JSON.parse(r.value)]));
|
|
3544
|
+
}
|
|
3545
|
+
// ---------------------------------------------------------------------------
|
|
3546
|
+
// Addon settings
|
|
3547
|
+
// ---------------------------------------------------------------------------
|
|
3548
|
+
getAddon(addonId, key) {
|
|
3549
|
+
const row = this.db.prepare(
|
|
3550
|
+
"SELECT value FROM addon_settings WHERE addon_id = ? AND key = ?"
|
|
3551
|
+
).get(addonId, key);
|
|
3552
|
+
if (row === void 0) return void 0;
|
|
3553
|
+
return JSON.parse(row.value);
|
|
3554
|
+
}
|
|
3555
|
+
setAddon(addonId, key, value) {
|
|
3556
|
+
this.db.prepare(
|
|
3557
|
+
`INSERT INTO addon_settings (addon_id, key, value, updated_at) VALUES (?, ?, json(?), unixepoch())
|
|
3558
|
+
ON CONFLICT(addon_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
|
|
3559
|
+
).run(addonId, key, JSON.stringify(value));
|
|
3560
|
+
}
|
|
3561
|
+
getAllAddon(addonId) {
|
|
3562
|
+
const rows = this.db.prepare(
|
|
3563
|
+
"SELECT key, value FROM addon_settings WHERE addon_id = ?"
|
|
3564
|
+
).all(addonId);
|
|
3565
|
+
return Object.fromEntries(rows.map((r) => [r.key, JSON.parse(r.value)]));
|
|
3566
|
+
}
|
|
3567
|
+
/** Bulk-replace all keys for an addon (within a transaction). */
|
|
3568
|
+
setAllAddon(addonId, config) {
|
|
3569
|
+
const deleteStmt = this.db.prepare("DELETE FROM addon_settings WHERE addon_id = ?");
|
|
3570
|
+
const insertStmt = this.db.prepare(
|
|
3571
|
+
`INSERT INTO addon_settings (addon_id, key, value, updated_at) VALUES (?, ?, json(?), unixepoch())`
|
|
3572
|
+
);
|
|
3573
|
+
this.db.transaction(() => {
|
|
3574
|
+
deleteStmt.run(addonId);
|
|
3575
|
+
for (const [key, value] of Object.entries(config)) {
|
|
3576
|
+
insertStmt.run(addonId, key, JSON.stringify(value));
|
|
3577
|
+
}
|
|
3578
|
+
})();
|
|
3579
|
+
}
|
|
3580
|
+
// ---------------------------------------------------------------------------
|
|
3581
|
+
// Provider settings
|
|
3582
|
+
// ---------------------------------------------------------------------------
|
|
3583
|
+
getProvider(providerId, key) {
|
|
3584
|
+
const row = this.db.prepare(
|
|
3585
|
+
"SELECT value FROM provider_settings WHERE provider_id = ? AND key = ?"
|
|
3586
|
+
).get(providerId, key);
|
|
3587
|
+
if (row === void 0) return void 0;
|
|
3588
|
+
return JSON.parse(row.value);
|
|
3589
|
+
}
|
|
3590
|
+
setProvider(providerId, key, value) {
|
|
3591
|
+
this.db.prepare(
|
|
3592
|
+
`INSERT INTO provider_settings (provider_id, key, value, updated_at) VALUES (?, ?, json(?), unixepoch())
|
|
3593
|
+
ON CONFLICT(provider_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
|
|
3594
|
+
).run(providerId, key, JSON.stringify(value));
|
|
3595
|
+
}
|
|
3596
|
+
getAllProvider(providerId) {
|
|
3597
|
+
const rows = this.db.prepare(
|
|
3598
|
+
"SELECT key, value FROM provider_settings WHERE provider_id = ?"
|
|
3599
|
+
).all(providerId);
|
|
3600
|
+
return Object.fromEntries(rows.map((r) => [r.key, JSON.parse(r.value)]));
|
|
3601
|
+
}
|
|
3602
|
+
// ---------------------------------------------------------------------------
|
|
3603
|
+
// Device settings
|
|
3604
|
+
// ---------------------------------------------------------------------------
|
|
3605
|
+
getDevice(deviceId, key) {
|
|
3606
|
+
const row = this.db.prepare(
|
|
3607
|
+
"SELECT value FROM device_settings WHERE device_id = ? AND key = ?"
|
|
3608
|
+
).get(deviceId, key);
|
|
3609
|
+
if (row === void 0) return void 0;
|
|
3610
|
+
return JSON.parse(row.value);
|
|
3611
|
+
}
|
|
3612
|
+
setDevice(deviceId, key, value) {
|
|
3613
|
+
this.db.prepare(
|
|
3614
|
+
`INSERT INTO device_settings (device_id, key, value, updated_at) VALUES (?, ?, json(?), unixepoch())
|
|
3615
|
+
ON CONFLICT(device_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
|
|
3616
|
+
).run(deviceId, key, JSON.stringify(value));
|
|
3617
|
+
}
|
|
3618
|
+
getAllDevice(deviceId) {
|
|
3619
|
+
const rows = this.db.prepare(
|
|
3620
|
+
"SELECT key, value FROM device_settings WHERE device_id = ?"
|
|
3621
|
+
).all(deviceId);
|
|
3622
|
+
return Object.fromEntries(rows.map((r) => [r.key, JSON.parse(r.value)]));
|
|
3623
|
+
}
|
|
3624
|
+
// ---------------------------------------------------------------------------
|
|
3625
|
+
// Lifecycle
|
|
3626
|
+
// ---------------------------------------------------------------------------
|
|
3627
|
+
/** Close the SQLite connection (call on shutdown). */
|
|
3628
|
+
close() {
|
|
3629
|
+
this.db.close();
|
|
3630
|
+
}
|
|
3631
|
+
/** Check if system_settings is empty (used for first-boot seeding). */
|
|
3632
|
+
isSystemSettingsEmpty() {
|
|
3633
|
+
const row = this.db.prepare("SELECT COUNT(*) AS cnt FROM system_settings").get();
|
|
3634
|
+
return (row?.cnt ?? 0) === 0;
|
|
3635
|
+
}
|
|
3636
|
+
/** Seed system_settings with RUNTIME_DEFAULTS (only on first boot). */
|
|
3637
|
+
seedDefaults() {
|
|
3638
|
+
const insert = this.db.prepare(
|
|
3639
|
+
`INSERT OR IGNORE INTO system_settings (key, value, updated_at) VALUES (?, json(?), unixepoch())`
|
|
3640
|
+
);
|
|
3641
|
+
this.db.transaction(() => {
|
|
3642
|
+
for (const [key, value] of Object.entries(RUNTIME_DEFAULTS)) {
|
|
3643
|
+
insert.run(key, JSON.stringify(value));
|
|
3644
|
+
}
|
|
3645
|
+
})();
|
|
3646
|
+
}
|
|
3647
|
+
// ---------------------------------------------------------------------------
|
|
3648
|
+
// Private helpers
|
|
3649
|
+
// ---------------------------------------------------------------------------
|
|
3650
|
+
initTables() {
|
|
3651
|
+
this.db.transaction(() => {
|
|
3652
|
+
for (const stmt of CORE_TABLE_DDL) {
|
|
3653
|
+
this.db.prepare(stmt).run();
|
|
3654
|
+
}
|
|
3655
|
+
})();
|
|
3656
|
+
}
|
|
3657
|
+
};
|
|
3658
|
+
|
|
3659
|
+
// src/auth/auth-manager.ts
|
|
3660
|
+
import * as jwt from "jsonwebtoken";
|
|
3661
|
+
import * as bcrypt from "bcryptjs";
|
|
3662
|
+
import * as crypto from "crypto";
|
|
3663
|
+
var AuthManager = class {
|
|
3664
|
+
constructor(config) {
|
|
3665
|
+
this.config = config;
|
|
3666
|
+
const configured = this.config.get("auth.jwtSecret");
|
|
3667
|
+
if (configured) {
|
|
3668
|
+
this.jwtSecret = configured;
|
|
3669
|
+
} else {
|
|
3670
|
+
const secret = crypto.randomBytes(32).toString("hex");
|
|
3671
|
+
this.config.update("auth", { jwtSecret: secret });
|
|
3672
|
+
console.log("[AuthManager] Generated JWT secret and saved to config.yaml (auth.jwtSecret)");
|
|
3673
|
+
this.jwtSecret = secret;
|
|
3674
|
+
}
|
|
3675
|
+
}
|
|
3676
|
+
jwtSecret;
|
|
3677
|
+
scopedTokenManager = null;
|
|
3678
|
+
signToken(payload) {
|
|
3679
|
+
return jwt.sign({ ...payload }, this.jwtSecret, { expiresIn: "24h" });
|
|
3680
|
+
}
|
|
3681
|
+
verifyToken(token) {
|
|
3682
|
+
return jwt.verify(token, this.jwtSecret);
|
|
3683
|
+
}
|
|
3684
|
+
async hashPassword(password) {
|
|
3685
|
+
return bcrypt.hash(password, 10);
|
|
3686
|
+
}
|
|
3687
|
+
async comparePassword(password, hash2) {
|
|
3688
|
+
return bcrypt.compare(password, hash2);
|
|
3689
|
+
}
|
|
3690
|
+
generateApiKey() {
|
|
3691
|
+
const token = crypto.randomBytes(32).toString("hex");
|
|
3692
|
+
const hash2 = crypto.createHash("sha256").update(token).digest("hex");
|
|
3693
|
+
const prefix = token.slice(0, 8);
|
|
3694
|
+
return { token, hash: hash2, prefix };
|
|
3695
|
+
}
|
|
3696
|
+
validateApiKey(token, storedHash) {
|
|
3697
|
+
const hash2 = crypto.createHash("sha256").update(token).digest("hex");
|
|
3698
|
+
return hash2 === storedHash;
|
|
3699
|
+
}
|
|
3700
|
+
/**
|
|
3701
|
+
* Set the scoped token manager for the auth chain.
|
|
3702
|
+
*/
|
|
3703
|
+
setScopedTokenManager(manager) {
|
|
3704
|
+
this.scopedTokenManager = manager;
|
|
3705
|
+
}
|
|
3706
|
+
/**
|
|
3707
|
+
* Validate a scoped token string.
|
|
3708
|
+
* Returns the token record if valid, null otherwise.
|
|
3709
|
+
*/
|
|
3710
|
+
async validateScopedToken(rawToken) {
|
|
3711
|
+
if (!this.scopedTokenManager) {
|
|
3712
|
+
return null;
|
|
3713
|
+
}
|
|
3714
|
+
return this.scopedTokenManager.validate(rawToken);
|
|
3715
|
+
}
|
|
3716
|
+
/**
|
|
3717
|
+
* Check whether a scoped token grants access to a given addon/route/capability.
|
|
3718
|
+
*/
|
|
3719
|
+
matchesScopedTokenScope(token, addonId, routePath, capability) {
|
|
3720
|
+
if (!this.scopedTokenManager) {
|
|
3721
|
+
return false;
|
|
3722
|
+
}
|
|
3723
|
+
return this.scopedTokenManager.matchesScope(token, addonId, routePath, capability);
|
|
3724
|
+
}
|
|
3725
|
+
};
|
|
3726
|
+
|
|
3727
|
+
// src/auth/api-key-manager.ts
|
|
3728
|
+
import * as crypto2 from "crypto";
|
|
3729
|
+
var API_KEYS_COLLECTION = "api_keys";
|
|
3730
|
+
var ApiKeyManager = class {
|
|
3731
|
+
constructor(storageAccess, auth) {
|
|
3732
|
+
this.storageAccess = storageAccess;
|
|
3733
|
+
this.auth = auth;
|
|
3734
|
+
}
|
|
3735
|
+
get structured() {
|
|
3736
|
+
return this.storageAccess.getStructuredStorage();
|
|
3737
|
+
}
|
|
3738
|
+
async create(input) {
|
|
3739
|
+
const { token, hash: hash2, prefix } = this.auth.generateApiKey();
|
|
3740
|
+
const now = Date.now();
|
|
3741
|
+
const record = {
|
|
3742
|
+
id: crypto2.randomUUID(),
|
|
3743
|
+
label: input.label,
|
|
3744
|
+
role: input.role,
|
|
3745
|
+
allowedProviders: input.allowedProviders ?? "*",
|
|
3746
|
+
allowedDevices: input.allowedDevices ?? {},
|
|
3747
|
+
tokenHash: hash2,
|
|
3748
|
+
tokenPrefix: prefix,
|
|
3749
|
+
createdAt: now
|
|
3750
|
+
};
|
|
3751
|
+
await this.structured.insert({
|
|
3752
|
+
collection: API_KEYS_COLLECTION,
|
|
3753
|
+
id: record.id,
|
|
3754
|
+
data: record
|
|
3755
|
+
});
|
|
3756
|
+
return { record, token };
|
|
3757
|
+
}
|
|
3758
|
+
async validateToken(token) {
|
|
3759
|
+
const allKeys = await this.structured.query(API_KEYS_COLLECTION);
|
|
3760
|
+
for (const entry of allKeys) {
|
|
3761
|
+
const record = entry.data;
|
|
3762
|
+
if (this.auth.validateApiKey(token, record.tokenHash)) {
|
|
3763
|
+
const updatedData = {
|
|
3764
|
+
...record,
|
|
3765
|
+
lastUsedAt: Date.now()
|
|
3766
|
+
};
|
|
3767
|
+
await this.structured.update(API_KEYS_COLLECTION, record.id, updatedData);
|
|
3768
|
+
return { ...record, lastUsedAt: updatedData.lastUsedAt };
|
|
3769
|
+
}
|
|
3770
|
+
}
|
|
3771
|
+
return null;
|
|
3772
|
+
}
|
|
3773
|
+
async listAll() {
|
|
3774
|
+
const results = await this.structured.query(API_KEYS_COLLECTION);
|
|
3775
|
+
return results.map((r) => {
|
|
3776
|
+
const { tokenHash, ...rest } = r.data;
|
|
3777
|
+
return rest;
|
|
3778
|
+
});
|
|
3779
|
+
}
|
|
3780
|
+
async revoke(id) {
|
|
3781
|
+
await this.structured.delete(API_KEYS_COLLECTION, id);
|
|
3782
|
+
}
|
|
3783
|
+
async findById(id) {
|
|
3784
|
+
const results = await this.structured.query(API_KEYS_COLLECTION, {
|
|
3785
|
+
where: { id }
|
|
3786
|
+
});
|
|
3787
|
+
if (results.length === 0) {
|
|
3788
|
+
return null;
|
|
3789
|
+
}
|
|
3790
|
+
return results[0].data;
|
|
3791
|
+
}
|
|
3792
|
+
};
|
|
3793
|
+
|
|
3794
|
+
// src/auth/user-manager.ts
|
|
3795
|
+
import * as crypto3 from "crypto";
|
|
3796
|
+
var USERS_COLLECTION = "users";
|
|
3797
|
+
var UserManager = class {
|
|
3798
|
+
constructor(storageAccess, auth, config) {
|
|
3799
|
+
this.storageAccess = storageAccess;
|
|
3800
|
+
this.auth = auth;
|
|
3801
|
+
this.config = config;
|
|
3802
|
+
}
|
|
3803
|
+
get structured() {
|
|
3804
|
+
return this.storageAccess.getStructuredStorage();
|
|
3805
|
+
}
|
|
3806
|
+
async create(input) {
|
|
3807
|
+
const existing = await this.findByUsername(input.username);
|
|
3808
|
+
if (existing) {
|
|
3809
|
+
throw new Error(`User with username "${input.username}" already exists`);
|
|
3810
|
+
}
|
|
3811
|
+
const passwordHash = await this.auth.hashPassword(input.password);
|
|
3812
|
+
const now = Date.now();
|
|
3813
|
+
const record = {
|
|
3814
|
+
id: crypto3.randomUUID(),
|
|
3815
|
+
username: input.username,
|
|
3816
|
+
passwordHash,
|
|
3817
|
+
role: input.role,
|
|
3818
|
+
allowedProviders: input.allowedProviders ?? "*",
|
|
3819
|
+
allowedDevices: input.allowedDevices ?? {},
|
|
3820
|
+
createdAt: now,
|
|
3821
|
+
updatedAt: now
|
|
3822
|
+
};
|
|
3823
|
+
await this.structured.insert({
|
|
3824
|
+
collection: USERS_COLLECTION,
|
|
3825
|
+
id: record.id,
|
|
3826
|
+
data: record
|
|
3827
|
+
});
|
|
3828
|
+
return record;
|
|
3829
|
+
}
|
|
3830
|
+
async findByUsername(username) {
|
|
3831
|
+
const results = await this.structured.query(USERS_COLLECTION, {
|
|
3832
|
+
where: { username }
|
|
3833
|
+
});
|
|
3834
|
+
if (results.length === 0) {
|
|
3835
|
+
return null;
|
|
3836
|
+
}
|
|
3837
|
+
return results[0].data;
|
|
3838
|
+
}
|
|
3839
|
+
async findById(id) {
|
|
3840
|
+
const results = await this.structured.query(USERS_COLLECTION, {
|
|
3841
|
+
where: { id }
|
|
3842
|
+
});
|
|
3843
|
+
if (results.length === 0) {
|
|
3844
|
+
return null;
|
|
3845
|
+
}
|
|
3846
|
+
return results[0].data;
|
|
3847
|
+
}
|
|
3848
|
+
async validateCredentials(username, password) {
|
|
3849
|
+
const user = await this.findByUsername(username);
|
|
3850
|
+
if (!user) {
|
|
3851
|
+
return null;
|
|
3852
|
+
}
|
|
3853
|
+
const valid = await this.auth.comparePassword(password, user.passwordHash);
|
|
3854
|
+
return valid ? user : null;
|
|
3855
|
+
}
|
|
3856
|
+
async listAll() {
|
|
3857
|
+
const results = await this.structured.query(USERS_COLLECTION);
|
|
3858
|
+
return results.map((r) => {
|
|
3859
|
+
const { passwordHash, ...rest } = r.data;
|
|
3860
|
+
return rest;
|
|
3861
|
+
});
|
|
3862
|
+
}
|
|
3863
|
+
async update(id, data) {
|
|
3864
|
+
const existing = await this.findById(id);
|
|
3865
|
+
if (!existing) {
|
|
3866
|
+
throw new Error(`User with id "${id}" not found`);
|
|
3867
|
+
}
|
|
3868
|
+
const updatedData = {
|
|
3869
|
+
...existing,
|
|
3870
|
+
...data,
|
|
3871
|
+
updatedAt: Date.now()
|
|
3872
|
+
};
|
|
3873
|
+
await this.structured.update(USERS_COLLECTION, id, updatedData);
|
|
3874
|
+
}
|
|
3875
|
+
async delete(id) {
|
|
3876
|
+
await this.structured.delete(USERS_COLLECTION, id);
|
|
3877
|
+
}
|
|
3878
|
+
async resetPassword(id, newPassword) {
|
|
3879
|
+
const existing = await this.findById(id);
|
|
3880
|
+
if (!existing) {
|
|
3881
|
+
throw new Error(`User with id "${id}" not found`);
|
|
3882
|
+
}
|
|
3883
|
+
const passwordHash = await this.auth.hashPassword(newPassword);
|
|
3884
|
+
const updatedData = {
|
|
3885
|
+
...existing,
|
|
3886
|
+
passwordHash,
|
|
3887
|
+
updatedAt: Date.now()
|
|
3888
|
+
};
|
|
3889
|
+
await this.structured.update(USERS_COLLECTION, id, updatedData);
|
|
3890
|
+
}
|
|
3891
|
+
async ensureAdminExists() {
|
|
3892
|
+
const adminUsername = this.config.get("auth.adminUsername");
|
|
3893
|
+
const adminPassword = this.config.get("auth.adminPassword");
|
|
3894
|
+
if (!adminUsername || !adminPassword) {
|
|
3895
|
+
return;
|
|
3896
|
+
}
|
|
3897
|
+
const existing = await this.findByUsername(adminUsername);
|
|
3898
|
+
if (existing) {
|
|
3899
|
+
return;
|
|
3900
|
+
}
|
|
3901
|
+
await this.create({
|
|
3902
|
+
username: adminUsername,
|
|
3903
|
+
password: adminPassword,
|
|
3904
|
+
role: "super_admin",
|
|
3905
|
+
allowedProviders: "*",
|
|
3906
|
+
allowedDevices: {}
|
|
3907
|
+
});
|
|
3908
|
+
}
|
|
3909
|
+
};
|
|
3910
|
+
|
|
3911
|
+
// src/auth/scoped-token-manager.ts
|
|
3912
|
+
import * as crypto4 from "crypto";
|
|
3913
|
+
var TOKENS_COLLECTION = "scoped_tokens";
|
|
3914
|
+
var TOKEN_PREFIX = "cst_";
|
|
3915
|
+
var ScopedTokenManager = class {
|
|
3916
|
+
constructor(storage) {
|
|
3917
|
+
this.storage = storage;
|
|
3918
|
+
}
|
|
3919
|
+
/**
|
|
3920
|
+
* Create a new scoped token. Returns the raw token string (shown once)
|
|
3921
|
+
* and the stored record (with hash, not the raw token).
|
|
3922
|
+
*/
|
|
3923
|
+
async create(userId, name, scopes, expiresAt) {
|
|
3924
|
+
const rawHex = crypto4.randomBytes(32).toString("hex");
|
|
3925
|
+
const rawToken = `${TOKEN_PREFIX}${rawHex}`;
|
|
3926
|
+
const tokenHash = crypto4.createHash("sha256").update(rawToken).digest("hex");
|
|
3927
|
+
const tokenPrefix = rawToken.slice(0, 12);
|
|
3928
|
+
const record = {
|
|
3929
|
+
id: crypto4.randomUUID(),
|
|
3930
|
+
userId,
|
|
3931
|
+
name,
|
|
3932
|
+
tokenHash,
|
|
3933
|
+
tokenPrefix,
|
|
3934
|
+
scopes: scopes.map((s) => ({ ...s })),
|
|
3935
|
+
expiresAt,
|
|
3936
|
+
lastUsedAt: void 0,
|
|
3937
|
+
createdAt: Date.now()
|
|
3938
|
+
};
|
|
3939
|
+
await this.storage.insert({
|
|
3940
|
+
collection: TOKENS_COLLECTION,
|
|
3941
|
+
id: record.id,
|
|
3942
|
+
data: record
|
|
3943
|
+
});
|
|
3944
|
+
return { token: rawToken, record };
|
|
3945
|
+
}
|
|
3946
|
+
/**
|
|
3947
|
+
* Validate a raw token string. Returns the token record if valid, null otherwise.
|
|
3948
|
+
*/
|
|
3949
|
+
async validate(rawToken) {
|
|
3950
|
+
if (!rawToken.startsWith(TOKEN_PREFIX)) {
|
|
3951
|
+
return null;
|
|
3952
|
+
}
|
|
3953
|
+
const tokenHash = crypto4.createHash("sha256").update(rawToken).digest("hex");
|
|
3954
|
+
const results = await this.storage.query(TOKENS_COLLECTION, {
|
|
3955
|
+
where: { tokenHash }
|
|
3956
|
+
});
|
|
3957
|
+
if (results.length === 0) {
|
|
3958
|
+
return null;
|
|
3959
|
+
}
|
|
3960
|
+
const record = results[0].data;
|
|
3961
|
+
if (record.expiresAt !== void 0 && record.expiresAt !== null && Date.now() > record.expiresAt) {
|
|
3962
|
+
return null;
|
|
3963
|
+
}
|
|
3964
|
+
this.updateLastUsed(record.id).catch(() => {
|
|
3965
|
+
});
|
|
3966
|
+
return record;
|
|
3967
|
+
}
|
|
3968
|
+
/**
|
|
3969
|
+
* Check whether a token's scopes grant access to the given addon, route, or capability.
|
|
3970
|
+
*/
|
|
3971
|
+
matchesScope(token, addonId, routePath, capability) {
|
|
3972
|
+
for (const scope of token.scopes) {
|
|
3973
|
+
switch (scope.type) {
|
|
3974
|
+
case "addon":
|
|
3975
|
+
if (addonId && scope.target === addonId) return true;
|
|
3976
|
+
break;
|
|
3977
|
+
case "route-prefix":
|
|
3978
|
+
if (routePath && routePath.startsWith(scope.target)) return true;
|
|
3979
|
+
break;
|
|
3980
|
+
case "capability":
|
|
3981
|
+
if (capability && scope.target === capability) return true;
|
|
3982
|
+
break;
|
|
3983
|
+
}
|
|
3984
|
+
}
|
|
3985
|
+
return false;
|
|
3986
|
+
}
|
|
3987
|
+
/**
|
|
3988
|
+
* Revoke a token by ID.
|
|
3989
|
+
*/
|
|
3990
|
+
async revoke(tokenId) {
|
|
3991
|
+
await this.storage.delete(TOKENS_COLLECTION, tokenId);
|
|
3992
|
+
}
|
|
3993
|
+
/**
|
|
3994
|
+
* List all tokens for a user (without exposing the raw token).
|
|
3995
|
+
*/
|
|
3996
|
+
async listForUser(userId) {
|
|
3997
|
+
const results = await this.storage.query(TOKENS_COLLECTION, {
|
|
3998
|
+
where: { userId }
|
|
3999
|
+
});
|
|
4000
|
+
return results.map((r) => r.data);
|
|
4001
|
+
}
|
|
4002
|
+
/**
|
|
4003
|
+
* Update the lastUsedAt timestamp for a token.
|
|
4004
|
+
*/
|
|
4005
|
+
async updateLastUsed(tokenId) {
|
|
4006
|
+
const results = await this.storage.query(TOKENS_COLLECTION, {
|
|
4007
|
+
where: { id: tokenId }
|
|
4008
|
+
});
|
|
4009
|
+
if (results.length === 0) return;
|
|
4010
|
+
const existing = results[0].data;
|
|
4011
|
+
await this.storage.update(TOKENS_COLLECTION, tokenId, {
|
|
4012
|
+
...existing,
|
|
4013
|
+
lastUsedAt: Date.now()
|
|
4014
|
+
});
|
|
4015
|
+
}
|
|
4016
|
+
};
|
|
4017
|
+
|
|
4018
|
+
// src/notification/notification-service.ts
|
|
4019
|
+
var NotificationService = class {
|
|
4020
|
+
constructor(logger) {
|
|
4021
|
+
this.logger = logger;
|
|
4022
|
+
}
|
|
4023
|
+
localOutputs = /* @__PURE__ */ new Map();
|
|
4024
|
+
routing = /* @__PURE__ */ new Map();
|
|
4025
|
+
rateLimits = /* @__PURE__ */ new Map();
|
|
4026
|
+
lastSent = /* @__PURE__ */ new Map();
|
|
4027
|
+
registry = null;
|
|
4028
|
+
/** Set the registry for live output lookup. Called once during boot. */
|
|
4029
|
+
setRegistry(registry) {
|
|
4030
|
+
this.registry = registry;
|
|
4031
|
+
}
|
|
4032
|
+
/** Resolve all outputs — prefers registry, falls back to local map */
|
|
4033
|
+
get outputs() {
|
|
4034
|
+
if (this.registry) {
|
|
4035
|
+
const collection = this.registry.getCollection("notification-output");
|
|
4036
|
+
const map = /* @__PURE__ */ new Map();
|
|
4037
|
+
for (const output of collection) {
|
|
4038
|
+
map.set(output.id, output);
|
|
4039
|
+
}
|
|
4040
|
+
return map;
|
|
4041
|
+
}
|
|
4042
|
+
return this.localOutputs;
|
|
4043
|
+
}
|
|
4044
|
+
/** @deprecated Use registry-based resolution. Kept for backward compat only. */
|
|
4045
|
+
addOutput(output) {
|
|
4046
|
+
this.localOutputs.set(output.id, output);
|
|
4047
|
+
this.logger.info(`Notification output added: ${output.name} (${output.id})`);
|
|
4048
|
+
}
|
|
4049
|
+
/** @deprecated Use registry-based resolution. Kept for backward compat only. */
|
|
4050
|
+
removeOutput(id) {
|
|
4051
|
+
this.localOutputs.delete(id);
|
|
4052
|
+
this.logger.info(`Notification output removed: ${id}`);
|
|
4053
|
+
}
|
|
4054
|
+
setRouting(category, outputIds) {
|
|
4055
|
+
this.routing.set(category, [...outputIds]);
|
|
4056
|
+
}
|
|
4057
|
+
setRateLimit(category, minIntervalMs) {
|
|
4058
|
+
this.rateLimits.set(category, minIntervalMs);
|
|
4059
|
+
}
|
|
4060
|
+
async notify(notification) {
|
|
4061
|
+
const { category, deviceId } = notification;
|
|
4062
|
+
const rateLimitKey = deviceId ? `${category}:${deviceId}` : category;
|
|
4063
|
+
const minInterval = this.rateLimits.get(category) ?? 0;
|
|
4064
|
+
if (minInterval > 0) {
|
|
4065
|
+
const last = this.lastSent.get(rateLimitKey) ?? 0;
|
|
4066
|
+
if (notification.timestamp - last < minInterval) {
|
|
4067
|
+
this.logger.debug(`Rate limited: ${rateLimitKey}`);
|
|
4068
|
+
return;
|
|
4069
|
+
}
|
|
4070
|
+
}
|
|
4071
|
+
const targetIds = this.routing.get(category) ?? this.routing.get("*") ?? [];
|
|
4072
|
+
if (targetIds.length === 0) {
|
|
4073
|
+
this.logger.debug(`No routing configured for category "${category}"`);
|
|
4074
|
+
return;
|
|
4075
|
+
}
|
|
4076
|
+
const currentOutputs = this.outputs;
|
|
4077
|
+
this.lastSent.set(rateLimitKey, notification.timestamp);
|
|
4078
|
+
await Promise.allSettled(
|
|
4079
|
+
targetIds.map((id) => currentOutputs.get(id)).filter((output) => output !== void 0).map(async (output) => {
|
|
4080
|
+
try {
|
|
4081
|
+
await output.send(notification);
|
|
4082
|
+
} catch (err) {
|
|
4083
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4084
|
+
this.logger.error(`Notification output "${output.id}" failed: ${msg}`);
|
|
4085
|
+
}
|
|
4086
|
+
})
|
|
4087
|
+
);
|
|
4088
|
+
return void 0;
|
|
4089
|
+
}
|
|
4090
|
+
getOutputs() {
|
|
4091
|
+
return Array.from(this.outputs.values()).map(({ id, name, icon }) => ({ id, name, icon }));
|
|
4092
|
+
}
|
|
4093
|
+
getRouting() {
|
|
4094
|
+
return this.routing;
|
|
4095
|
+
}
|
|
4096
|
+
getOutput(id) {
|
|
4097
|
+
return this.outputs.get(id);
|
|
4098
|
+
}
|
|
4099
|
+
};
|
|
4100
|
+
|
|
4101
|
+
// src/notification/toast-service.ts
|
|
4102
|
+
var ToastService = class {
|
|
4103
|
+
listeners = /* @__PURE__ */ new Map();
|
|
4104
|
+
/**
|
|
4105
|
+
* Subscribe to toast events for a specific user.
|
|
4106
|
+
* Returns an unsubscribe function.
|
|
4107
|
+
*/
|
|
4108
|
+
subscribe(connectionId, userId, callback) {
|
|
4109
|
+
this.listeners.set(connectionId, { userId, callback });
|
|
4110
|
+
return () => {
|
|
4111
|
+
this.listeners.delete(connectionId);
|
|
4112
|
+
};
|
|
4113
|
+
}
|
|
4114
|
+
/**
|
|
4115
|
+
* Broadcast a toast to all connected clients.
|
|
4116
|
+
*/
|
|
4117
|
+
broadcast(toast) {
|
|
4118
|
+
for (const entry of this.listeners.values()) {
|
|
4119
|
+
entry.callback(toast);
|
|
4120
|
+
}
|
|
4121
|
+
}
|
|
4122
|
+
/**
|
|
4123
|
+
* Send a toast to a specific user's connections only.
|
|
4124
|
+
*/
|
|
4125
|
+
sendToUser(userId, toast) {
|
|
4126
|
+
for (const entry of this.listeners.values()) {
|
|
4127
|
+
if (entry.userId === userId) {
|
|
4128
|
+
entry.callback(toast);
|
|
4129
|
+
}
|
|
4130
|
+
}
|
|
4131
|
+
}
|
|
4132
|
+
};
|
|
4133
|
+
|
|
4134
|
+
// src/addon-routes/addon-route-registry.ts
|
|
4135
|
+
var AddonRouteRegistry = class {
|
|
4136
|
+
routes = /* @__PURE__ */ new Map();
|
|
4137
|
+
/**
|
|
4138
|
+
* Register all routes from an addon's route provider.
|
|
4139
|
+
*/
|
|
4140
|
+
registerRoutes(addonId, provider) {
|
|
4141
|
+
const addonRoutes = provider.getRoutes();
|
|
4142
|
+
const registered = addonRoutes.map((route) => {
|
|
4143
|
+
const normalizedPath = route.path.startsWith("/") ? route.path : `/${route.path}`;
|
|
4144
|
+
return {
|
|
4145
|
+
addonId,
|
|
4146
|
+
route,
|
|
4147
|
+
fullPath: `/addon/${addonId}${normalizedPath}`
|
|
4148
|
+
};
|
|
4149
|
+
});
|
|
4150
|
+
this.routes.set(addonId, registered);
|
|
4151
|
+
}
|
|
4152
|
+
/**
|
|
4153
|
+
* Unregister all routes for an addon.
|
|
4154
|
+
*/
|
|
4155
|
+
unregisterRoutes(addonId) {
|
|
4156
|
+
this.routes.delete(addonId);
|
|
4157
|
+
}
|
|
4158
|
+
/**
|
|
4159
|
+
* Match an incoming request method + path to a registered route.
|
|
4160
|
+
* Supports simple path parameters (e.g., /items/:id).
|
|
4161
|
+
*/
|
|
4162
|
+
matchRoute(method, path9) {
|
|
4163
|
+
const normalizedMethod = method.toUpperCase();
|
|
4164
|
+
for (const registeredRoutes of this.routes.values()) {
|
|
4165
|
+
for (const entry of registeredRoutes) {
|
|
4166
|
+
if (entry.route.method !== normalizedMethod) continue;
|
|
4167
|
+
const params = matchPath(entry.fullPath, path9);
|
|
4168
|
+
if (params !== null) {
|
|
4169
|
+
return {
|
|
4170
|
+
route: entry.route,
|
|
4171
|
+
addonId: entry.addonId,
|
|
4172
|
+
params
|
|
4173
|
+
};
|
|
4174
|
+
}
|
|
4175
|
+
}
|
|
4176
|
+
}
|
|
4177
|
+
return null;
|
|
4178
|
+
}
|
|
4179
|
+
/**
|
|
4180
|
+
* List all registered routes across all addons.
|
|
4181
|
+
*/
|
|
4182
|
+
listRoutes() {
|
|
4183
|
+
const result = [];
|
|
4184
|
+
for (const registeredRoutes of this.routes.values()) {
|
|
4185
|
+
for (const entry of registeredRoutes) {
|
|
4186
|
+
result.push({
|
|
4187
|
+
addonId: entry.addonId,
|
|
4188
|
+
method: entry.route.method,
|
|
4189
|
+
path: entry.fullPath,
|
|
4190
|
+
access: entry.route.access,
|
|
4191
|
+
description: entry.route.description
|
|
4192
|
+
});
|
|
4193
|
+
}
|
|
4194
|
+
}
|
|
4195
|
+
return result;
|
|
4196
|
+
}
|
|
4197
|
+
};
|
|
4198
|
+
function matchPath(pattern, path9) {
|
|
4199
|
+
const patternParts = pattern.split("/").filter(Boolean);
|
|
4200
|
+
const pathParts = path9.split("/").filter(Boolean);
|
|
4201
|
+
if (patternParts.length !== pathParts.length) {
|
|
4202
|
+
return null;
|
|
4203
|
+
}
|
|
4204
|
+
const params = {};
|
|
4205
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
4206
|
+
const patternPart = patternParts[i];
|
|
4207
|
+
const pathPart = pathParts[i];
|
|
4208
|
+
if (patternPart.startsWith(":")) {
|
|
4209
|
+
params[patternPart.slice(1)] = pathPart;
|
|
4210
|
+
} else if (patternPart !== pathPart) {
|
|
4211
|
+
return null;
|
|
4212
|
+
}
|
|
4213
|
+
}
|
|
4214
|
+
return params;
|
|
4215
|
+
}
|
|
4216
|
+
|
|
4217
|
+
// src/device/device-registry.ts
|
|
4218
|
+
import { randomUUID as randomUUID8 } from "crypto";
|
|
4219
|
+
var DeviceRegistry = class {
|
|
4220
|
+
constructor(eventBus, loggingService) {
|
|
4221
|
+
this.eventBus = eventBus;
|
|
4222
|
+
this.logger = loggingService.createLogger("device-registry");
|
|
4223
|
+
}
|
|
4224
|
+
devices = /* @__PURE__ */ new Map();
|
|
4225
|
+
logger;
|
|
4226
|
+
registerDevice(device) {
|
|
4227
|
+
this.devices.set(device.id, device);
|
|
4228
|
+
this.logger.info(`Device registered: ${device.id} (${device.name})`);
|
|
4229
|
+
this.eventBus.emit({
|
|
4230
|
+
id: randomUUID8(),
|
|
4231
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
4232
|
+
source: { type: "core", id: "device-registry" },
|
|
4233
|
+
category: "device.registered",
|
|
4234
|
+
data: { deviceId: device.id, name: device.name, providerId: device.providerId }
|
|
4235
|
+
});
|
|
4236
|
+
}
|
|
4237
|
+
unregisterDevice(id) {
|
|
4238
|
+
const device = this.devices.get(id);
|
|
4239
|
+
if (!device) {
|
|
4240
|
+
return;
|
|
4241
|
+
}
|
|
4242
|
+
this.devices.delete(id);
|
|
4243
|
+
this.logger.info(`Device unregistered: ${id}`);
|
|
4244
|
+
this.eventBus.emit({
|
|
4245
|
+
id: randomUUID8(),
|
|
4246
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
4247
|
+
source: { type: "core", id: "device-registry" },
|
|
4248
|
+
category: "device.unregistered",
|
|
4249
|
+
data: { deviceId: id }
|
|
4250
|
+
});
|
|
4251
|
+
}
|
|
4252
|
+
getDevice(id) {
|
|
4253
|
+
return this.devices.get(id) ?? null;
|
|
4254
|
+
}
|
|
4255
|
+
listDevices() {
|
|
4256
|
+
return Array.from(this.devices.values());
|
|
4257
|
+
}
|
|
4258
|
+
getDevicesByProvider(providerId) {
|
|
4259
|
+
return Array.from(this.devices.values()).filter(
|
|
4260
|
+
(device) => device.providerId === providerId
|
|
4261
|
+
);
|
|
4262
|
+
}
|
|
4263
|
+
getDevicesWithCapability(cap) {
|
|
4264
|
+
return Array.from(this.devices.values()).filter(
|
|
4265
|
+
(device) => device.capabilities.includes(cap)
|
|
4266
|
+
);
|
|
4267
|
+
}
|
|
4268
|
+
registerProviderDevices(providerId, devices) {
|
|
4269
|
+
for (const device of devices) {
|
|
4270
|
+
this.registerDevice(device);
|
|
4271
|
+
}
|
|
4272
|
+
this.logger.info(`Bulk registered ${devices.length} devices for provider ${providerId}`);
|
|
4273
|
+
}
|
|
4274
|
+
unregisterProviderDevices(providerId) {
|
|
4275
|
+
const providerDevices = this.getDevicesByProvider(providerId);
|
|
4276
|
+
for (const device of providerDevices) {
|
|
4277
|
+
this.unregisterDevice(device.id);
|
|
4278
|
+
}
|
|
4279
|
+
this.logger.info(`Bulk unregistered ${providerDevices.length} devices for provider ${providerId}`);
|
|
4280
|
+
}
|
|
4281
|
+
};
|
|
4282
|
+
|
|
4283
|
+
// src/device/capability-resolver.ts
|
|
4284
|
+
var CapabilityResolver = class {
|
|
4285
|
+
constructor(addonRegistry) {
|
|
4286
|
+
this.addonRegistry = addonRegistry;
|
|
4287
|
+
}
|
|
4288
|
+
bindings = /* @__PURE__ */ new Map();
|
|
4289
|
+
resolve(device, cap) {
|
|
4290
|
+
const deviceBindings = this.bindings.get(device.id);
|
|
4291
|
+
const binding = deviceBindings?.[cap];
|
|
4292
|
+
if (binding) {
|
|
4293
|
+
if (binding.source === "disabled") {
|
|
4294
|
+
return null;
|
|
4295
|
+
}
|
|
4296
|
+
if (binding.source === "addon" && binding.addonId) {
|
|
4297
|
+
const addon = this.addonRegistry.getAddon(binding.addonId);
|
|
4298
|
+
if (addon && typeof addon.getCapabilityForDevice === "function") {
|
|
4299
|
+
return addon.getCapabilityForDevice(device, cap, binding.config) ?? null;
|
|
4300
|
+
}
|
|
4301
|
+
return null;
|
|
4302
|
+
}
|
|
4303
|
+
}
|
|
4304
|
+
return device.getCapability(cap);
|
|
4305
|
+
}
|
|
4306
|
+
setBinding(deviceId, cap, binding) {
|
|
4307
|
+
const existing = this.bindings.get(deviceId) ?? {};
|
|
4308
|
+
this.bindings.set(deviceId, { ...existing, [cap]: binding });
|
|
4309
|
+
}
|
|
4310
|
+
removeBinding(deviceId, cap) {
|
|
4311
|
+
const existing = this.bindings.get(deviceId);
|
|
4312
|
+
if (!existing) {
|
|
4313
|
+
return;
|
|
4314
|
+
}
|
|
4315
|
+
const updated = { ...existing };
|
|
4316
|
+
delete updated[cap];
|
|
4317
|
+
this.bindings.set(deviceId, updated);
|
|
4318
|
+
}
|
|
4319
|
+
getBindings(deviceId) {
|
|
4320
|
+
return this.bindings.get(deviceId) ?? {};
|
|
4321
|
+
}
|
|
4322
|
+
getEffectiveCapabilities(device) {
|
|
4323
|
+
const deviceBindings = this.bindings.get(device.id) ?? {};
|
|
4324
|
+
const result = [];
|
|
4325
|
+
for (const cap of device.capabilities) {
|
|
4326
|
+
const binding = deviceBindings[cap];
|
|
4327
|
+
if (!binding || binding.source !== "disabled") {
|
|
4328
|
+
result.push(cap);
|
|
4329
|
+
}
|
|
4330
|
+
}
|
|
4331
|
+
for (const [cap, binding] of Object.entries(deviceBindings)) {
|
|
4332
|
+
if (binding && binding.source === "addon" && !device.capabilities.includes(cap)) {
|
|
4333
|
+
result.push(cap);
|
|
4334
|
+
}
|
|
4335
|
+
}
|
|
4336
|
+
return result;
|
|
4337
|
+
}
|
|
4338
|
+
};
|
|
4339
|
+
|
|
4340
|
+
// src/provider/provider-manager.ts
|
|
4341
|
+
import { randomUUID as randomUUID9 } from "crypto";
|
|
4342
|
+
var ProviderManager = class {
|
|
4343
|
+
constructor(deviceRegistry, eventBus, loggingService) {
|
|
4344
|
+
this.deviceRegistry = deviceRegistry;
|
|
4345
|
+
this.eventBus = eventBus;
|
|
4346
|
+
this.loggingService = loggingService;
|
|
4347
|
+
this.logger = loggingService.createLogger("provider-manager");
|
|
4348
|
+
}
|
|
4349
|
+
providers = /* @__PURE__ */ new Map();
|
|
4350
|
+
logger;
|
|
4351
|
+
registerProvider(provider) {
|
|
4352
|
+
const providerLogger = this.loggingService.createLogger(`provider:${provider.id}`);
|
|
4353
|
+
const lifecycle = new LifecycleStateMachine(
|
|
4354
|
+
provider.id,
|
|
4355
|
+
"provider",
|
|
4356
|
+
this.eventBus,
|
|
4357
|
+
providerLogger
|
|
4358
|
+
);
|
|
4359
|
+
this.providers.set(provider.id, { provider, lifecycle, started: false });
|
|
4360
|
+
this.logger.info(`Provider registered: ${provider.id} (${provider.name})`);
|
|
4361
|
+
}
|
|
4362
|
+
async startProvider(id) {
|
|
4363
|
+
const entry = this.providers.get(id);
|
|
4364
|
+
if (!entry) {
|
|
4365
|
+
throw new Error(`Provider "${id}" is not registered`);
|
|
4366
|
+
}
|
|
4367
|
+
if (entry.lifecycle.state === "disabled") {
|
|
4368
|
+
throw new Error(`Provider "${id}" is disabled`);
|
|
4369
|
+
}
|
|
4370
|
+
entry.lifecycle.transition("starting");
|
|
4371
|
+
try {
|
|
4372
|
+
await entry.provider.start();
|
|
4373
|
+
} catch (err) {
|
|
4374
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4375
|
+
entry.lifecycle.transition("error", message);
|
|
4376
|
+
throw err;
|
|
4377
|
+
}
|
|
4378
|
+
entry.lifecycle.transition("running");
|
|
4379
|
+
const devices = entry.provider.getDevices();
|
|
4380
|
+
this.deviceRegistry.registerProviderDevices(id, devices);
|
|
4381
|
+
const unsubscribe = entry.provider.subscribeLiveEvents((event) => {
|
|
4382
|
+
this.eventBus.emit({
|
|
4383
|
+
id: randomUUID9(),
|
|
4384
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
4385
|
+
source: { type: "provider", id },
|
|
4386
|
+
category: `provider.${event.type}`,
|
|
4387
|
+
data: event.data
|
|
4388
|
+
});
|
|
4389
|
+
});
|
|
4390
|
+
entry.started = true;
|
|
4391
|
+
entry.unsubscribe = unsubscribe;
|
|
4392
|
+
this.eventBus.emit({
|
|
4393
|
+
id: randomUUID9(),
|
|
4394
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
4395
|
+
source: { type: "core", id: "provider-manager" },
|
|
4396
|
+
category: "provider.started",
|
|
4397
|
+
data: { providerId: id }
|
|
4398
|
+
});
|
|
4399
|
+
this.logger.info(`Provider started: ${id}`);
|
|
4400
|
+
}
|
|
4401
|
+
async stopProvider(id) {
|
|
4402
|
+
const entry = this.providers.get(id);
|
|
4403
|
+
if (!entry) {
|
|
4404
|
+
throw new Error(`Provider "${id}" is not registered`);
|
|
4405
|
+
}
|
|
4406
|
+
entry.lifecycle.transition("stopping");
|
|
4407
|
+
if (entry.unsubscribe) {
|
|
4408
|
+
entry.unsubscribe();
|
|
4409
|
+
entry.unsubscribe = void 0;
|
|
4410
|
+
}
|
|
4411
|
+
this.deviceRegistry.unregisterProviderDevices(id);
|
|
4412
|
+
await entry.provider.stop();
|
|
4413
|
+
entry.started = false;
|
|
4414
|
+
entry.lifecycle.transition("stopped");
|
|
4415
|
+
this.eventBus.emit({
|
|
4416
|
+
id: randomUUID9(),
|
|
4417
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
4418
|
+
source: { type: "core", id: "provider-manager" },
|
|
4419
|
+
category: "provider.stopped",
|
|
4420
|
+
data: { providerId: id }
|
|
4421
|
+
});
|
|
4422
|
+
this.logger.info(`Provider stopped: ${id}`);
|
|
4423
|
+
}
|
|
4424
|
+
async disableProvider(id) {
|
|
4425
|
+
const entry = this.providers.get(id);
|
|
4426
|
+
if (!entry) {
|
|
4427
|
+
throw new Error(`Provider "${id}" is not registered`);
|
|
4428
|
+
}
|
|
4429
|
+
if (entry.lifecycle.state === "running" || entry.started) {
|
|
4430
|
+
await this.stopProvider(id);
|
|
4431
|
+
}
|
|
4432
|
+
entry.lifecycle.transition("disabled");
|
|
4433
|
+
this.logger.info(`Provider disabled: ${id}`);
|
|
4434
|
+
}
|
|
4435
|
+
async enableProvider(id) {
|
|
4436
|
+
const entry = this.providers.get(id);
|
|
4437
|
+
if (!entry) {
|
|
4438
|
+
throw new Error(`Provider "${id}" is not registered`);
|
|
4439
|
+
}
|
|
4440
|
+
if (entry.lifecycle.state !== "disabled") {
|
|
4441
|
+
throw new Error(`Provider "${id}" is not disabled`);
|
|
4442
|
+
}
|
|
4443
|
+
entry.lifecycle.transition("stopped");
|
|
4444
|
+
this.logger.info(`Provider enabled: ${id}`);
|
|
4445
|
+
}
|
|
4446
|
+
async restartProvider(id) {
|
|
4447
|
+
await this.stopProvider(id);
|
|
4448
|
+
await this.startProvider(id);
|
|
4449
|
+
}
|
|
4450
|
+
getProvider(id) {
|
|
4451
|
+
const entry = this.providers.get(id);
|
|
4452
|
+
return entry?.provider ?? null;
|
|
4453
|
+
}
|
|
4454
|
+
getProviderStatus(id) {
|
|
4455
|
+
const entry = this.providers.get(id);
|
|
4456
|
+
return entry?.lifecycle.getStatus() ?? null;
|
|
4457
|
+
}
|
|
4458
|
+
listProviders() {
|
|
4459
|
+
return Array.from(this.providers.values()).map((entry) => ({
|
|
4460
|
+
id: entry.provider.id,
|
|
4461
|
+
type: entry.provider.type,
|
|
4462
|
+
name: entry.provider.name,
|
|
4463
|
+
status: entry.provider.getStatus(),
|
|
4464
|
+
started: entry.started,
|
|
4465
|
+
lifecycle: entry.lifecycle.getStatus()
|
|
4466
|
+
}));
|
|
4467
|
+
}
|
|
4468
|
+
async shutdownAll() {
|
|
4469
|
+
const startedIds = Array.from(this.providers.entries()).filter(([, entry]) => entry.started).map(([id]) => id);
|
|
4470
|
+
for (const id of startedIds) {
|
|
4471
|
+
await this.stopProvider(id);
|
|
4472
|
+
}
|
|
4473
|
+
this.logger.info(`All providers shut down (${startedIds.length} stopped)`);
|
|
617
4474
|
}
|
|
618
4475
|
};
|
|
619
4476
|
export {
|
|
620
4477
|
AddonEngineManager,
|
|
621
4478
|
AddonInstaller,
|
|
622
4479
|
AddonLoader,
|
|
4480
|
+
AddonRouteRegistry,
|
|
4481
|
+
AdminUIAddon,
|
|
4482
|
+
AgentClient,
|
|
4483
|
+
AgentRegistry,
|
|
4484
|
+
AgentTaskRunner,
|
|
4485
|
+
ApiKeyManager,
|
|
4486
|
+
AuthManager,
|
|
623
4487
|
BUILTIN_PACKAGES,
|
|
4488
|
+
CORE_TABLE_DDL,
|
|
4489
|
+
CapabilityRegistry,
|
|
4490
|
+
CapabilityResolver,
|
|
4491
|
+
ConfigManager,
|
|
4492
|
+
DecodeTaskHandler,
|
|
4493
|
+
DetectTaskHandler,
|
|
4494
|
+
DeviceRegistry,
|
|
624
4495
|
EventBus,
|
|
4496
|
+
FeatureManager,
|
|
4497
|
+
FileSystemStorage,
|
|
4498
|
+
FsStorageBackend,
|
|
4499
|
+
INFRA_CAPABILITIES,
|
|
4500
|
+
LifecycleStateMachine,
|
|
4501
|
+
LocalBackupAddon,
|
|
4502
|
+
LocalBackupService,
|
|
4503
|
+
LogManager,
|
|
4504
|
+
LogRingBuffer,
|
|
4505
|
+
ManagedProcess,
|
|
4506
|
+
NetworkQualityTracker,
|
|
4507
|
+
NotificationService,
|
|
625
4508
|
PipelineRunner,
|
|
626
4509
|
PipelineValidator,
|
|
4510
|
+
ProcessManager,
|
|
4511
|
+
ProviderManager,
|
|
627
4512
|
PythonEnvManager,
|
|
628
|
-
|
|
4513
|
+
RUNTIME_DEFAULTS,
|
|
4514
|
+
RecordTaskHandler,
|
|
4515
|
+
ReplEngine,
|
|
4516
|
+
ScopedLogger,
|
|
4517
|
+
ScopedTokenManager,
|
|
4518
|
+
SettingsStore,
|
|
4519
|
+
SqliteStorageAddon,
|
|
4520
|
+
SqliteStorageProvider,
|
|
4521
|
+
StorageLocationManager,
|
|
4522
|
+
StorageManager,
|
|
4523
|
+
SystemEventBus,
|
|
4524
|
+
TaskDispatcher,
|
|
4525
|
+
ToastService,
|
|
4526
|
+
UserManager,
|
|
4527
|
+
WinstonDestination,
|
|
4528
|
+
WinstonLoggingAddon,
|
|
4529
|
+
addonTableToDdl,
|
|
4530
|
+
bootstrapSchema,
|
|
4531
|
+
downloadModel,
|
|
4532
|
+
isInfraCapability
|
|
629
4533
|
};
|
|
630
4534
|
//# sourceMappingURL=index.mjs.map
|