350 lines
13 KiB
JavaScript
350 lines
13 KiB
JavaScript
const fs = require("fs");
|
|
const path = require("path");
|
|
const { resolveData } = require("./paths");
|
|
const { registerManagedTool } = require("./tool_registry");
|
|
|
|
class ToolLoader {
|
|
constructor(options = {}) {
|
|
this.registry = options.registry;
|
|
this.installer = options.installer;
|
|
this.settings = options.settings;
|
|
this.lumiAiVersion = options.lumiAiVersion || "0.0.0";
|
|
this.lumiVersion = options.lumiVersion || "0.0.0";
|
|
this.stateFile = options.stateFile || resolveData("tools", "enabled.json");
|
|
this.loaded = new Map();
|
|
this.statuses = new Map();
|
|
}
|
|
|
|
async loadEnabled() {
|
|
return this.reconcile();
|
|
}
|
|
|
|
async reconcile({ reload = false } = {}) {
|
|
const state = this.readState();
|
|
const locals = this.installer.scanLocal();
|
|
const localMap = new Map(locals.map((local) => [local.tool_id, local]));
|
|
for (const local of locals) {
|
|
if (local.valid && state.enabled[local.tool_id] == null && local.metadata.default_enabled === true) {
|
|
state.enabled[local.tool_id] = true;
|
|
this.writeState(state);
|
|
}
|
|
}
|
|
for (const toolId of [...this.loaded.keys()]) {
|
|
if (!localMap.has(toolId) || state.enabled[toolId] !== true) {
|
|
await this.disable(toolId, { persist: false });
|
|
}
|
|
}
|
|
for (const local of locals) {
|
|
if (!local.valid) {
|
|
this.registry.unregisterOwner(local.tool_id);
|
|
this.loaded.delete(local.tool_id);
|
|
this.setStatus(local.tool_id, "unavailable", local.error, { blocking: ["schema_invalid"], optional: [] });
|
|
continue;
|
|
}
|
|
if (state.enabled[local.tool_id] !== true) {
|
|
this.setStatus(local.tool_id, "disabled", "");
|
|
continue;
|
|
}
|
|
const dependencies = this.inspectDependencies(local.metadata, local.dir);
|
|
if (dependencies.blocking.length) {
|
|
await this.disable(local.tool_id, { persist: false });
|
|
this.setStatus(
|
|
local.tool_id,
|
|
"unavailable",
|
|
`Unavailable: ${dependencies.blocking.join("; ")}`,
|
|
dependencies
|
|
);
|
|
continue;
|
|
}
|
|
let signature;
|
|
try {
|
|
signature = sourceSignature(local);
|
|
} catch (error) {
|
|
await this.disable(local.tool_id, { persist: false });
|
|
this.setStatus(local.tool_id, "unavailable", error.message, dependencies);
|
|
continue;
|
|
}
|
|
if (!reload && this.loaded.get(local.tool_id)?.source_signature === signature) continue;
|
|
try { await this.enable(local.tool_id, { persist: false }); }
|
|
catch (error) { this.setStatus(local.tool_id, "unavailable", error.message); }
|
|
}
|
|
return this.diagnostics();
|
|
}
|
|
|
|
async enable(toolId, options = {}) {
|
|
const local = this.installer.local(toolId);
|
|
if (!local?.valid) throw new Error(local?.error || "Installed AI tool metadata is invalid.");
|
|
const dependencies = this.inspectDependencies(local.metadata, local.dir);
|
|
if (dependencies.blocking.length) {
|
|
await this.disable(toolId, { persist: false });
|
|
const message = `Unavailable: ${dependencies.blocking.join("; ")}`;
|
|
this.setStatus(toolId, "unavailable", message, dependencies);
|
|
if (options.persist !== false) this.setEnabled(toolId, true);
|
|
return { loaded: false, unavailable: true, message, dependencies };
|
|
}
|
|
await this.disable(toolId, { persist: false });
|
|
let backend;
|
|
try {
|
|
backend = backendEntrypoint(local.metadata, local.dir);
|
|
} catch (error) {
|
|
this.setStatus(toolId, "unavailable", error.message, dependencies);
|
|
if (options.persist !== false) this.setEnabled(toolId, true);
|
|
return { loaded: false, unavailable: true, message: error.message, dependencies };
|
|
}
|
|
const registered = [];
|
|
let cleanup = null;
|
|
let runtimeDiagnostics = null;
|
|
let availabilityMessage = "";
|
|
if (backend) {
|
|
clearRequireCache(local.dir);
|
|
try {
|
|
const module = require(backend);
|
|
const register = module?.register || module?.init;
|
|
if (typeof register !== "function") throw new Error("Backend entrypoint must export register() or init().");
|
|
const context = {
|
|
metadata: Object.freeze({ ...local.metadata }),
|
|
registerTool: (definition) => {
|
|
const unregister = registerManagedTool(this.registry, local.metadata, definition);
|
|
registered.push({ id: definition.tool_id, unregister });
|
|
return unregister;
|
|
},
|
|
paths: Object.freeze({
|
|
root: local.dir,
|
|
data: path.join(local.dir, "data"),
|
|
config: path.join(local.dir, "config")
|
|
}),
|
|
assetUrl: (relative = "") => `/plugins/lumi_ai/tools/${toolId}/assets/${String(relative).replace(/^\/+/, "")}`
|
|
};
|
|
if (typeof module.checkAvailability === "function") {
|
|
const availability = await module.checkAvailability(context);
|
|
if (availability?.available === false) {
|
|
this.loaded.set(toolId, {
|
|
cleanup: null,
|
|
registered,
|
|
metadata: local.metadata,
|
|
dir: local.dir,
|
|
source_signature: sourceSignature(local)
|
|
});
|
|
this.setStatus(toolId, "unavailable", String(availability.message || "Tool configuration is incomplete."), dependencies);
|
|
if (options.persist !== false) this.setEnabled(toolId, true);
|
|
return { loaded: false, unavailable: true, message: availability.message, dependencies };
|
|
}
|
|
availabilityMessage = String(availability?.message || "");
|
|
}
|
|
const result = await register(context);
|
|
cleanup = typeof result === "function" ? result : typeof result?.stop === "function" ? () => result.stop() : null;
|
|
runtimeDiagnostics = typeof module.diagnostics === "function"
|
|
? () => module.diagnostics(context)
|
|
: null;
|
|
} catch (error) {
|
|
this.registry.unregisterOwner(toolId);
|
|
this.setStatus(toolId, "unavailable", error.message, dependencies);
|
|
if (options.persist !== false) this.setEnabled(toolId, true);
|
|
return { loaded: false, unavailable: true, message: error.message, dependencies };
|
|
}
|
|
}
|
|
this.loaded.set(toolId, {
|
|
cleanup,
|
|
registered,
|
|
metadata: local.metadata,
|
|
dir: local.dir,
|
|
diagnostics: runtimeDiagnostics,
|
|
source_signature: sourceSignature(local)
|
|
});
|
|
this.setStatus(
|
|
toolId,
|
|
"enabled",
|
|
dependencies.optional.length
|
|
? `Enabled with limitations: ${dependencies.optional.join("; ")}`
|
|
: availabilityMessage,
|
|
dependencies
|
|
);
|
|
if (options.persist !== false) this.setEnabled(toolId, true);
|
|
return { loaded: true, registered: registered.map((entry) => entry.id), dependencies };
|
|
}
|
|
|
|
async disable(toolId, options = {}) {
|
|
const loaded = this.loaded.get(toolId);
|
|
if (loaded) {
|
|
try { await loaded.cleanup?.(); } catch {}
|
|
this.registry.unregisterOwner(toolId);
|
|
this.loaded.delete(toolId);
|
|
} else {
|
|
this.registry.unregisterOwner(toolId);
|
|
}
|
|
if (options.persist !== false) this.setEnabled(toolId, false);
|
|
this.setStatus(toolId, "disabled", "");
|
|
return { disabled: true };
|
|
}
|
|
|
|
async stopAll() {
|
|
for (const toolId of [...this.loaded.keys()]) await this.disable(toolId, { persist: false });
|
|
}
|
|
|
|
isEnabled(toolId) {
|
|
return this.readState().enabled[toolId] === true;
|
|
}
|
|
|
|
status(toolId) {
|
|
return this.statuses.get(toolId) || { state: this.isEnabled(toolId) ? "pending" : "disabled", message: "" };
|
|
}
|
|
|
|
diagnostics() {
|
|
return this.installer.scanLocal().map((local) => {
|
|
const enabled = this.isEnabled(local.tool_id);
|
|
const status = this.status(local.tool_id);
|
|
return {
|
|
tool_id: local.tool_id,
|
|
installed: true,
|
|
enabled,
|
|
valid: local.valid,
|
|
state: status.state,
|
|
message: status.message || local.error || "",
|
|
dependencies: status.dependencies || (local.valid
|
|
? this.inspectDependencies(local.metadata, local.dir)
|
|
: { blocking: ["schema_invalid"], optional: [] }),
|
|
registered_tools: [...this.registry.tools.values()]
|
|
.filter((definition) => definition.owning_plugin === local.tool_id)
|
|
.map((definition) => definition.tool_id),
|
|
runtime_details: safeDiagnostics(this.loaded.get(local.tool_id)?.diagnostics)
|
|
};
|
|
});
|
|
}
|
|
|
|
inspectDependencies(metadata, toolDir) {
|
|
const blocking = [];
|
|
const optional = [];
|
|
for (const pluginId of metadata.required_plugins || []) {
|
|
if (/^lumi_ai_.+/i.test(pluginId)) {
|
|
blocking.push(`AI tool plugins cannot depend on AI tool plugin ${pluginId}`);
|
|
} else if (!["core", "lumi_ai"].includes(pluginId) && !fs.existsSync(path.resolve(toolDir, "..", pluginId))) {
|
|
blocking.push(`required plugin ${pluginId} is missing`);
|
|
}
|
|
}
|
|
for (const platform of metadata.required_platforms || []) {
|
|
if (this.settings?.getSetting?.(`platform_${platform}_enabled`, false) !== true) {
|
|
blocking.push(`required platform ${platform} is disabled`);
|
|
}
|
|
}
|
|
for (const dependency of metadata.dependencies || []) {
|
|
try { require.resolve(dependency, { paths: [toolDir] }); }
|
|
catch { optional.push(`optional dependency ${dependency} is missing`); }
|
|
}
|
|
if (metadata.minimum_lumi_ai_version && compareVersions(this.lumiAiVersion, metadata.minimum_lumi_ai_version) < 0) {
|
|
blocking.push(`requires Lumi AI ${metadata.minimum_lumi_ai_version}`);
|
|
}
|
|
if (metadata.minimum_lumi_version && compareVersions(this.lumiVersion, metadata.minimum_lumi_version) < 0) {
|
|
blocking.push(`requires Lumi ${metadata.minimum_lumi_version}`);
|
|
}
|
|
return { blocking, optional };
|
|
}
|
|
|
|
resolveAsset(toolId, relative) {
|
|
const loaded = this.loaded.get(toolId);
|
|
if (!loaded || !this.isEnabled(toolId)) return null;
|
|
const roots = assetRoots(loaded.metadata, loaded.dir);
|
|
for (const root of roots) {
|
|
const candidate = path.resolve(root, String(relative || ""));
|
|
if ((candidate === root || candidate.startsWith(`${root}${path.sep}`)) && fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
|
return candidate;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
setStatus(toolId, state, message, dependencies = null) {
|
|
this.statuses.set(toolId, { state, message: String(message || ""), dependencies });
|
|
}
|
|
|
|
readState() {
|
|
try {
|
|
const value = JSON.parse(fs.readFileSync(this.stateFile, "utf8"));
|
|
return { enabled: value.enabled && typeof value.enabled === "object" ? value.enabled : {} };
|
|
} catch {
|
|
return { enabled: {} };
|
|
}
|
|
}
|
|
|
|
setEnabled(toolId, enabled) {
|
|
const state = this.readState();
|
|
state.enabled[toolId] = Boolean(enabled);
|
|
this.writeState(state);
|
|
}
|
|
|
|
writeState(state) {
|
|
fs.mkdirSync(path.dirname(this.stateFile), { recursive: true });
|
|
const temporary = `${this.stateFile}.${process.pid}.tmp`;
|
|
fs.writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`);
|
|
try { fs.renameSync(temporary, this.stateFile); }
|
|
catch (error) {
|
|
if (!["EEXIST", "EPERM"].includes(error.code)) throw error;
|
|
fs.rmSync(this.stateFile, { force: true });
|
|
fs.renameSync(temporary, this.stateFile);
|
|
}
|
|
}
|
|
}
|
|
|
|
function backendEntrypoint(metadata, toolDir) {
|
|
const configured = typeof metadata.entrypoints === "string"
|
|
? metadata.entrypoints
|
|
: metadata.entrypoints?.backend;
|
|
const relative = configured || (fs.existsSync(path.join(toolDir, "index.js")) ? "index.js" : "");
|
|
if (!relative) return null;
|
|
const target = path.resolve(toolDir, relative);
|
|
if (!target.startsWith(`${path.resolve(toolDir)}${path.sep}`) || !fs.existsSync(target)) {
|
|
throw new Error("Backend entrypoint is missing or unsafe.");
|
|
}
|
|
return target;
|
|
}
|
|
|
|
function assetRoots(metadata, toolDir) {
|
|
const entries = Array.isArray(metadata.frontend_assets)
|
|
? metadata.frontend_assets
|
|
: metadata.frontend_assets ? [metadata.frontend_assets] : ["public"];
|
|
return entries.map((entry) => path.resolve(toolDir, entry))
|
|
.filter((entry) => entry.startsWith(`${path.resolve(toolDir)}${path.sep}`) && fs.existsSync(entry));
|
|
}
|
|
|
|
function clearRequireCache(directory) {
|
|
const prefix = `${path.resolve(directory)}${path.sep}`;
|
|
for (const key of Object.keys(require.cache)) if (key.startsWith(prefix)) delete require.cache[key];
|
|
}
|
|
|
|
function compareVersions(left, right) {
|
|
const parse = (value) => String(value || "0.0.0").split(/[+-]/)[0].split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
const a = parse(left);
|
|
const b = parse(right);
|
|
for (let index = 0; index < Math.max(a.length, b.length, 3); index += 1) {
|
|
if ((a[index] || 0) !== (b[index] || 0)) return (a[index] || 0) > (b[index] || 0) ? 1 : -1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function sourceSignature(local) {
|
|
const entry = backendEntrypoint(local.metadata, local.dir);
|
|
const entryMtime = entry && fs.existsSync(entry) ? fs.statSync(entry).mtimeMs : 0;
|
|
const metadataFile = path.join(local.dir, "tool_info.json");
|
|
const metadataMtime = fs.existsSync(metadataFile) ? fs.statSync(metadataFile).mtimeMs : 0;
|
|
return `${local.metadata.version}:${metadataMtime}:${entryMtime}`;
|
|
}
|
|
|
|
function safeDiagnostics(callback) {
|
|
if (typeof callback !== "function") return null;
|
|
try {
|
|
const value = callback();
|
|
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
} catch (error) {
|
|
return { error: String(error?.message || "Tool diagnostics failed.") };
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
ToolLoader,
|
|
assetRoots,
|
|
backendEntrypoint,
|
|
clearRequireCache,
|
|
compareVersions,
|
|
safeDiagnostics,
|
|
sourceSignature
|
|
};
|