UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

300 lines (299 loc) 13.3 kB
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 };