openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
300 lines (299 loc) • 13.3 kB
JavaScript
import { r as STATE_DIR } from "./paths-D2sRr1a_.js";
import { L as markTrustedOtelDiagnosticListener, S as registerDiagnosticTracePropagationBridge, b as waitForDiagnosticEventsDrained, g as onTrustedInternalDiagnosticEvent, s as emitTrustedDiagnosticEventWithPrivateData } from "./diagnostic-events-Cwe92uV3.js";
import { t as createSubsystemLogger } from "./subsystem-Dy2tqXOS.js";
import { t as isPluginJsonValue } from "./host-hook-json-BdcyDerH.js";
import { t as encodeStartupTraceSegment } from "./startup-trace-segment-Cd4cVDJE.js";
import { r as normalizeCronJobPatch, t as normalizeCronJobCreate } from "./normalize-Dd-LvsuI.js";
import { i as recordDiagnosticExporterHealth } from "./diagnostic-stability-CLaOJzMC.js";
import { i as withPluginHttpRouteRegistry } from "./http-registry-BDq8iS_8.js";
import { r as subscribePluginSessionsChanged } from "./gateway-events-niGuJ0eS.js";
import { t as createPluginServiceHealthGeneration } from "./service-health-BvEAb43_.js";
import { t as createPluginRuntimeCapabilityLease } from "./capability-lease-Dybi4lS4.js";
//#region src/plugins/service-cron.ts
function createPluginServiceCronGetter(params) {
let current;
const assertServiceActive = () => {
params.lease.assertActive("cron scheduler");
if (params.isStopping()) throw new Error("Plugin service cron scheduler is stopping");
};
return () => {
assertServiceActive();
const cron = params.getCron();
if (!cron) return;
if (current?.cron === cron) return current.service;
const commitGuard = () => {
assertServiceActive();
if (params.getCron() !== cron) throw new Error("Plugin service cron scheduler was replaced");
};
const service = {
list: async (opts) => {
commitGuard();
const jobs = await cron.list(opts);
commitGuard();
return jobs;
},
add: async (input) => {
commitGuard();
const normalized = normalizeCronJobCreate(input);
if (!normalized) throw new Error("Plugin service cron create input is invalid");
return await cron.add(normalized, { commitGuard });
},
update: async (id, patch) => {
commitGuard();
const normalized = normalizeCronJobPatch(patch);
if (!normalized) throw new Error("Plugin service cron update input is invalid");
return await cron.update(id, normalized, { commitGuard });
},
remove: async (id) => {
commitGuard();
return await cron.remove(id, { commitGuard });
},
removeStaleJobFamily: async (family) => {
commitGuard();
return await cron.removeStaleJobFamily(family, { commitGuard });
}
};
current = {
cron,
service
};
return service;
};
}
//#endregion
//#region src/plugins/services.ts
/** Starts, stops, and inspects plugin service registrations. */
const log = createSubsystemLogger("plugins");
const PLUGIN_SERVICE_REPLACEMENT_STOP_TIMEOUT_MS = 5e3;
var PluginServiceReplacementTimeoutError = class extends Error {};
function createPluginLogger() {
return {
info: (msg) => log.info(msg),
warn: (msg) => log.warn(msg),
error: (msg) => log.error(msg),
debug: (msg) => log.debug(msg)
};
}
function createServiceContext(params) {
const isDiagnosticsExporter = params.service?.pluginId === params.service?.service.id && (params.service?.service.id === "diagnostics-otel" || params.service?.service.id === "diagnostics-prometheus");
const isOtelExporter = isDiagnosticsExporter && params.service.service.id === "diagnostics-otel";
const internalDiagnostics = isDiagnosticsExporter && (params.service?.origin === "bundled" || params.service?.trustedOfficialInstall === true) ? {
emit: (event, privateData) => {
params.lease.assertActive("internal diagnostic emitter");
emitTrustedDiagnosticEventWithPrivateData(event, privateData);
},
onEvent: (listener, filter) => {
params.lease.assertActive("internal diagnostic listener");
const trustedListener = isOtelExporter ? markTrustedOtelDiagnosticListener(listener) : listener;
return params.lease.retain(onTrustedInternalDiagnosticEvent(trustedListener, filter));
},
registerTracePropagationBridge: (bridge) => {
params.lease.assertActive("diagnostic trace propagation bridge");
return params.lease.retain(registerDiagnosticTracePropagationBridge(bridge));
},
reportExporterHealth: (update) => {
if (params.lease.isActive()) recordDiagnosticExporterHealth(params.service.service.id, update);
}
} : void 0;
return {
config: params.config,
workspaceDir: params.workspaceDir,
stateDir: STATE_DIR,
logger: createPluginLogger(),
serviceHealth: params.serviceHealth,
...params.getCron ? { getCron: params.getCron } : {},
...params.gatewayEvents ? { gatewayEvents: params.gatewayEvents } : {},
...params.startupTrace ? { startupTrace: createScopedPluginServiceStartupTrace(params.startupTrace, createPluginServiceTraceName(params.service)) } : {},
...internalDiagnostics ? { internalDiagnostics } : {}
};
}
function createScopedGatewayEvents(params) {
if (!params.broadcast) return {};
const broadcast = params.broadcast;
return { gatewayEvents: {
emit: (event, payload, opts) => {
params.lease.assertActive("gateway event emitter");
if (!/^[a-z][a-z0-9_-]*$/u.test(event)) throw new Error(`invalid plugin gateway event name: ${event}`);
if (!isPluginJsonValue(payload)) throw new Error("plugin gateway event payload must be bounded JSON");
if (opts?.scope !== "operator.read" && opts?.scope !== "operator.write" && opts?.scope !== "operator.admin") throw new Error("plugin gateway event scope must be an operator scope");
broadcast(`plugin.${params.pluginId}.${event}`, payload, opts.scope);
},
onSessionsChanged: (handler) => {
params.lease.assertActive("gateway event subscriber");
return params.lease.retain(subscribePluginSessionsChanged(handler));
}
} };
}
function createPluginServiceTraceName(entry) {
return `sidecars.plugin-services.${encodeStartupTraceSegment(entry.pluginId)}.${encodeStartupTraceSegment(entry.service.id)}`;
}
function createScopedPluginServiceStartupTrace(startupTrace, prefix) {
const scopeName = (name) => `${prefix}.${name.split(".").map((segment) => encodeStartupTraceSegment(segment)).join(".")}`;
return {
measure: (name, run) => startupTrace.measure(scopeName(name), run),
...startupTrace.detail ? { detail: (name, metrics) => startupTrace.detail?.(scopeName(name), metrics) } : {}
};
}
async function startPluginServices(params) {
const healthGeneration = createPluginServiceHealthGeneration(params.registry);
const ownedServices = [];
const runBeforeDeadline = async (run, deadline, label, owner) => {
const operation = Promise.resolve(run());
if (deadline === void 0) return operation;
const remaining = deadline - Date.now();
const timeoutError = () => new PluginServiceReplacementTimeoutError(`${label} timed out after ${PLUGIN_SERVICE_REPLACEMENT_STOP_TIMEOUT_MS}ms${owner ? ` (${owner})` : ""}`);
let timer;
try {
await Promise.race([operation, remaining <= 0 ? Promise.reject(timeoutError()) : new Promise((_, reject) => {
timer = setTimeout(() => reject(timeoutError()), remaining);
timer.unref?.();
})]);
} finally {
clearTimeout(timer);
}
};
const stopService = async (entry, failures, deadline) => {
entry.stopping = true;
try {
if (entry.stop) {
const cleanup = () => {
try {
return entry.cleanup ??= Promise.resolve(withPluginHttpRouteRegistry(params.registry, () => entry.stop?.(), entry.lease));
} catch (error) {
return entry.cleanup = (async () => {
throw error;
})();
}
};
await runBeforeDeadline(cleanup, deadline, "plugin service stop");
}
} catch (err) {
log.warn(`plugin service stop failed (${entry.id}): ${String(err)}`);
failures?.push(deadline === void 0 ? err : new Error(`plugin service stop failed (plugin=${entry.pluginId}, service=${entry.id}): ${err instanceof PluginServiceReplacementTimeoutError ? err.message : `rejected: ${String(err)}`}`, { cause: err }));
} finally {
entry.lease.revoke();
}
};
const stopServices = async (entries, failures, strict, deadline) => {
for (const entry of entries) entry.stopping = true;
const reversed = entries.toReversed();
const diagnosticsExporters = reversed.filter((entry) => entry.diagnosticsExporter);
for (const entry of reversed.filter((candidate) => !candidate.diagnosticsExporter)) await stopService(entry, strict ? failures : void 0, deadline);
if (diagnosticsExporters.length > 0) try {
await runBeforeDeadline(waitForDiagnosticEventsDrained, deadline, "plugin diagnostic event drain", diagnosticsExporters.map((entry) => `plugin=${entry.pluginId}, service=${entry.id}`).join("; "));
} catch (error) {
if (!strict) throw error;
failures.push(error);
}
for (const entry of diagnosticsExporters) await stopService(entry, failures, deadline);
};
let stopRequested = false;
let reloadTail = Promise.resolve();
const handle = {
reload: (config, serviceIds) => {
const reloading = reloadTail.then(async () => {
await startupSettled;
if (stopRequested) throw new Error("Plugin services are stopping");
const selected = ownedServices.filter((entry) => serviceIds.has(entry.id));
const deadline = Date.now() + PLUGIN_SERVICE_REPLACEMENT_STOP_TIMEOUT_MS;
const failures = [];
await stopServices(selected, failures, true, deadline);
if (failures.length > 0) throw new AggregateError(failures, "plugin service reload cleanup failed");
for (const entry of selected) ownedServices.splice(ownedServices.indexOf(entry), 1);
for (const entry of selected) {
if (stopRequested) return;
await startService(entry.registration, config, true);
}
});
reloadTail = reloading.catch(() => {});
return reloading;
},
stop: (options) => {
stopRequested = true;
const strict = options?.strict === true;
const deadline = strict ? options.deadlineAtMs : void 0;
const stopPromise = Promise.resolve().then(async () => {
const failures = [];
try {
const starting = ownedServices.at(-1);
await runBeforeDeadline(() => Promise.all([startupSettled.catch(() => {}), reloadTail]).then(() => {}), deadline, "plugin service startup settlement", starting ? `plugin=${starting.pluginId}, service=${starting.id}` : void 0);
} catch (error) {
failures.push(error);
for (const entry of ownedServices) entry.lease.revoke();
}
await stopServices(ownedServices, failures, strict, deadline);
if (!strict && failures.length === 1) throw failures[0];
if (failures.length > 0) throw new AggregateError(failures, strict ? "plugin service replacement cleanup failed" : "multiple diagnostics exporters failed to stop");
});
stopPromise.then(healthGeneration.retire, healthGeneration.retire);
return stopPromise;
}
};
params.onHandle?.(handle);
const startService = async (entry, config, strict = false) => {
const service = entry.service;
const traceName = createPluginServiceTraceName(entry);
const lease = createPluginRuntimeCapabilityLease("plugin service");
const scopedGatewayEvents = createScopedGatewayEvents({
pluginId: entry.pluginId,
broadcast: params.broadcastPluginEvent,
lease
});
const serviceHealth = healthGeneration.createReporter(entry);
lease.retain(serviceHealth.revoke);
serviceHealth.health.clearFailure();
const serviceContext = createServiceContext({
config,
startupTrace: params.startupTrace,
workspaceDir: params.workspaceDir,
service: entry,
serviceHealth: serviceHealth.health,
gatewayEvents: scopedGatewayEvents.gatewayEvents,
...params.getCronService ? { getCron: createPluginServiceCronGetter({
getCron: params.getCronService,
lease,
isStopping: () => stopRequested || ownedService.stopping
}) } : {},
lease
});
const ownedService = {
id: service.id,
registration: entry,
stopping: false,
pluginId: entry.pluginId,
diagnosticsExporter: serviceContext.internalDiagnostics !== void 0,
stop: service.stop ? () => service.stop?.(serviceContext) : void 0,
lease
};
ownedServices.push(ownedService);
try {
const invokeStart = () => withPluginHttpRouteRegistry(params.registry, () => service.start(serviceContext), lease);
if (params.startupTrace) await params.startupTrace.measure(traceName, invokeStart);
else await invokeStart();
return true;
} catch (err) {
serviceContext.serviceHealth?.reportFailure(err);
const error = err;
log.error(`plugin service failed (${service.id}, plugin=${entry.pluginId}, root=${entry.rootDir ?? "unknown"}): ${error?.message ?? String(err)}`);
await stopService(ownedService, void 0, Date.now() + PLUGIN_SERVICE_REPLACEMENT_STOP_TIMEOUT_MS);
if (strict) throw err;
return false;
}
};
const startupSettled = (async () => {
let failedCount = 0;
for (const entry of params.registry.services) {
if (stopRequested) break;
if (!await startService(entry, params.config)) failedCount += 1;
}
params.startupTrace?.detail?.("sidecars.plugin-services.summary", [
["serviceCount", params.registry.services.length],
["startedCount", ownedServices.length - failedCount],
["failedCount", failedCount]
]);
})();
await startupSettled;
return handle;
}
//#endregion
export { PLUGIN_SERVICE_REPLACEMENT_STOP_TIMEOUT_MS, startPluginServices };