Spaces:
Build error
Build error
| /** | |
| * PluginLifecycleManager β state-machine controller for plugin status | |
| * transitions and worker process coordination. | |
| * | |
| * Each plugin moves through a well-defined state machine: | |
| * | |
| * ``` | |
| * installed βββ ready βββ disabled | |
| * β β β | |
| * β ββββ errorβ | |
| * β β β | |
| * β upgrade_pending β | |
| * β β β | |
| * β β β | |
| * uninstalled | |
| * ``` | |
| * | |
| * The lifecycle manager: | |
| * | |
| * 1. **Validates transitions** β Only transitions defined in | |
| * `VALID_TRANSITIONS` are allowed; invalid transitions throw. | |
| * | |
| * 2. **Coordinates workers** β When a plugin moves to `ready`, its | |
| * worker process is started. When it moves out of `ready`, the | |
| * worker is stopped gracefully. | |
| * | |
| * 3. **Emits events** β `plugin.loaded`, `plugin.enabled`, | |
| * `plugin.disabled`, `plugin.unloaded`, `plugin.status_changed` | |
| * events are emitted so that other services (job coordinator, | |
| * tool dispatcher, event bus) can react accordingly. | |
| * | |
| * 4. **Persists state** β Status changes are written to the database | |
| * through the plugin registry service. | |
| * | |
| * @see PLUGIN_SPEC.md Β§12 β Process Model | |
| * @see PLUGIN_SPEC.md Β§12.5 β Graceful Shutdown Policy | |
| */ | |
| import { EventEmitter } from "node:events"; | |
| import type { Db } from "@paperclipai/db"; | |
| import type { | |
| PluginStatus, | |
| PluginRecord, | |
| PaperclipPluginManifestV1, | |
| } from "@paperclipai/shared"; | |
| import { pluginRegistryService } from "./plugin-registry.js"; | |
| import { pluginLoader, type PluginLoader } from "./plugin-loader.js"; | |
| import type { PluginWorkerManager, WorkerStartOptions } from "./plugin-worker-manager.js"; | |
| import { badRequest, notFound } from "../errors.js"; | |
| import { logger } from "../middleware/logger.js"; | |
| // --------------------------------------------------------------------------- | |
| // Lifecycle state machine | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * Valid state transitions for the plugin lifecycle. | |
| * | |
| * installed β ready (initial load succeeds) | |
| * installed β error (initial load fails) | |
| * installed β uninstalled (abort installation) | |
| * | |
| * ready β disabled (operator disables plugin) | |
| * ready β error (runtime failure) | |
| * ready β upgrade_pending (upgrade with new capabilities) | |
| * ready β uninstalled (uninstall) | |
| * | |
| * disabled β ready (operator re-enables plugin) | |
| * disabled β uninstalled (uninstall while disabled) | |
| * | |
| * error β ready (retry / recovery) | |
| * error β uninstalled (give up and uninstall) | |
| * | |
| * upgrade_pending β ready (operator approves new capabilities) | |
| * upgrade_pending β error (upgrade worker fails) | |
| * upgrade_pending β uninstalled (reject upgrade and uninstall) | |
| * | |
| * uninstalled β installed (reinstall) | |
| */ | |
| const VALID_TRANSITIONS: Record<string, readonly PluginStatus[]> = { | |
| installed: ["ready", "error", "uninstalled"], | |
| ready: ["ready", "disabled", "error", "upgrade_pending", "uninstalled"], | |
| disabled: ["ready", "uninstalled"], | |
| error: ["ready", "uninstalled"], | |
| upgrade_pending: ["ready", "error", "uninstalled"], | |
| uninstalled: ["installed"], // reinstall | |
| }; | |
| /** | |
| * Check whether a transition from `from` β `to` is valid. | |
| */ | |
| function isValidTransition(from: PluginStatus, to: PluginStatus): boolean { | |
| return VALID_TRANSITIONS[from]?.includes(to) ?? false; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Lifecycle events | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * Events emitted by the PluginLifecycleManager. | |
| * Consumers can subscribe to these for routing-table updates, UI refresh | |
| * notifications, and observability. | |
| */ | |
| export interface PluginLifecycleEvents { | |
| /** Emitted after a plugin is loaded (installed β ready). */ | |
| "plugin.loaded": { pluginId: string; pluginKey: string }; | |
| /** Emitted after a plugin transitions to ready (enabled). */ | |
| "plugin.enabled": { pluginId: string; pluginKey: string }; | |
| /** Emitted after a plugin is disabled (ready β disabled). */ | |
| "plugin.disabled": { pluginId: string; pluginKey: string; reason?: string }; | |
| /** Emitted after a plugin is unloaded (any β uninstalled). */ | |
| "plugin.unloaded": { pluginId: string; pluginKey: string; removeData: boolean }; | |
| /** Emitted on any status change. */ | |
| "plugin.status_changed": { | |
| pluginId: string; | |
| pluginKey: string; | |
| previousStatus: PluginStatus; | |
| newStatus: PluginStatus; | |
| }; | |
| /** Emitted when a plugin enters an error state. */ | |
| "plugin.error": { pluginId: string; pluginKey: string; error: string }; | |
| /** Emitted when a plugin enters upgrade_pending. */ | |
| "plugin.upgrade_pending": { pluginId: string; pluginKey: string }; | |
| /** Emitted when a plugin worker process has been started. */ | |
| "plugin.worker_started": { pluginId: string; pluginKey: string }; | |
| /** Emitted when a plugin worker process has been stopped. */ | |
| "plugin.worker_stopped": { pluginId: string; pluginKey: string }; | |
| } | |
| type LifecycleEventName = keyof PluginLifecycleEvents; | |
| type LifecycleEventPayload<K extends LifecycleEventName> = PluginLifecycleEvents[K]; | |
| // --------------------------------------------------------------------------- | |
| // PluginLifecycleManager | |
| // --------------------------------------------------------------------------- | |
| export interface PluginLifecycleManager { | |
| /** | |
| * Load a newly installed plugin β transitions `installed` β `ready`. | |
| * | |
| * This is called after the registry has persisted the initial install record. | |
| * The caller should have already spawned the worker and performed health | |
| * checks before calling this. If the worker fails, call `markError` instead. | |
| */ | |
| load(pluginId: string): Promise<PluginRecord>; | |
| /** | |
| * Enable a plugin that is in `disabled`, `error`, or `upgrade_pending` state. | |
| * Transitions β `ready`. | |
| */ | |
| enable(pluginId: string): Promise<PluginRecord>; | |
| /** | |
| * Disable a running plugin. | |
| * Transitions `ready` β `disabled`. | |
| */ | |
| disable(pluginId: string, reason?: string): Promise<PluginRecord>; | |
| /** | |
| * Unload (uninstall) a plugin from any active state. | |
| * Transitions β `uninstalled`. | |
| * | |
| * When `removeData` is true, the plugin row and cascaded config are | |
| * hard-deleted. Otherwise a soft-delete sets status to `uninstalled`. | |
| */ | |
| unload(pluginId: string, removeData?: boolean): Promise<PluginRecord | null>; | |
| /** | |
| * Mark a plugin as errored (e.g. worker crash, health-check failure). | |
| * Transitions β `error`. | |
| */ | |
| markError(pluginId: string, error: string): Promise<PluginRecord>; | |
| /** | |
| * Mark a plugin as requiring upgrade approval. | |
| * Transitions `ready` β `upgrade_pending`. | |
| */ | |
| markUpgradePending(pluginId: string): Promise<PluginRecord>; | |
| /** | |
| * Upgrade a plugin to a newer version. | |
| * This is a placeholder that handles the lifecycle state transition. | |
| * The actual package installation is handled by plugin-loader. | |
| * | |
| * If the upgrade adds new capabilities, transitions to `upgrade_pending`. | |
| * Otherwise, transitions to `ready` directly. | |
| */ | |
| upgrade(pluginId: string, version?: string): Promise<PluginRecord>; | |
| /** | |
| * Start the worker process for a plugin that is already in `ready` state. | |
| * | |
| * This is used by the server startup orchestration to start workers for | |
| * plugins that were persisted as `ready`. It requires a `PluginWorkerManager` | |
| * to have been provided at construction time. | |
| * | |
| * @param pluginId - The UUID of the plugin to start | |
| * @param options - Worker start options (entrypoint path, config, etc.) | |
| * @throws if no worker manager is configured or the plugin is not ready | |
| */ | |
| startWorker(pluginId: string, options: WorkerStartOptions): Promise<void>; | |
| /** | |
| * Stop the worker process for a plugin without changing lifecycle state. | |
| * | |
| * This is used during server shutdown to gracefully stop all workers. | |
| * It does not transition the plugin state β plugins remain in their | |
| * current status so they can be restarted on next server boot. | |
| * | |
| * @param pluginId - The UUID of the plugin to stop | |
| */ | |
| stopWorker(pluginId: string): Promise<void>; | |
| /** | |
| * Restart the worker process for a running plugin. | |
| * | |
| * Stops and re-starts the worker process. The plugin remains in `ready` | |
| * state throughout. This is typically called after a config change. | |
| * | |
| * @param pluginId - The UUID of the plugin to restart | |
| * @throws if no worker manager is configured or the plugin is not ready | |
| */ | |
| restartWorker(pluginId: string): Promise<void>; | |
| /** | |
| * Get the current lifecycle state for a plugin. | |
| */ | |
| getStatus(pluginId: string): Promise<PluginStatus | null>; | |
| /** | |
| * Check whether a transition is allowed from the plugin's current state. | |
| */ | |
| canTransition(pluginId: string, to: PluginStatus): Promise<boolean>; | |
| /** | |
| * Subscribe to lifecycle events. | |
| */ | |
| on<K extends LifecycleEventName>( | |
| event: K, | |
| listener: (payload: LifecycleEventPayload<K>) => void, | |
| ): void; | |
| /** | |
| * Unsubscribe from lifecycle events. | |
| */ | |
| off<K extends LifecycleEventName>( | |
| event: K, | |
| listener: (payload: LifecycleEventPayload<K>) => void, | |
| ): void; | |
| /** | |
| * Subscribe to a lifecycle event once. | |
| */ | |
| once<K extends LifecycleEventName>( | |
| event: K, | |
| listener: (payload: LifecycleEventPayload<K>) => void, | |
| ): void; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Factory | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * Options for constructing a PluginLifecycleManager. | |
| */ | |
| export interface PluginLifecycleManagerOptions { | |
| /** Plugin loader instance. Falls back to the default if omitted. */ | |
| loader?: PluginLoader; | |
| /** | |
| * Worker process manager. When provided, lifecycle transitions that bring | |
| * a plugin online (load, enable, upgrade-to-ready) will start the worker | |
| * process, and transitions that take a plugin offline (disable, unload, | |
| * markError) will stop it. | |
| * | |
| * When omitted the lifecycle manager operates in state-only mode β the | |
| * caller is responsible for managing worker processes externally. | |
| */ | |
| workerManager?: PluginWorkerManager; | |
| } | |
| /** | |
| * Create a PluginLifecycleManager. | |
| * | |
| * This service orchestrates plugin state transitions on top of the | |
| * `pluginRegistryService` (which handles raw DB persistence). It enforces | |
| * the lifecycle state machine, emits events for downstream consumers | |
| * (routing tables, UI, observability), and manages worker processes via | |
| * the `PluginWorkerManager` when one is provided. | |
| * | |
| * Usage: | |
| * ```ts | |
| * const lifecycle = pluginLifecycleManager(db, { | |
| * workerManager: createPluginWorkerManager(), | |
| * }); | |
| * lifecycle.on("plugin.enabled", ({ pluginId }) => { ... }); | |
| * await lifecycle.load(pluginId); | |
| * ``` | |
| * | |
| * @see PLUGIN_SPEC.md Β§21.3 β `plugins.status` column | |
| * @see PLUGIN_SPEC.md Β§12 β Process Model | |
| */ | |
| export function pluginLifecycleManager( | |
| db: Db, | |
| options?: PluginLoader | PluginLifecycleManagerOptions, | |
| ): PluginLifecycleManager { | |
| // Support the legacy signature: pluginLifecycleManager(db, loader) | |
| // as well as the new options object form. | |
| let loaderArg: PluginLoader | undefined; | |
| let workerManager: PluginWorkerManager | undefined; | |
| if (options && typeof options === "object" && "discoverAll" in options) { | |
| // Legacy: second arg is a PluginLoader directly | |
| loaderArg = options as PluginLoader; | |
| } else if (options && typeof options === "object") { | |
| const opts = options as PluginLifecycleManagerOptions; | |
| loaderArg = opts.loader; | |
| workerManager = opts.workerManager; | |
| } | |
| const registry = pluginRegistryService(db); | |
| const pluginLoaderInstance = loaderArg ?? pluginLoader(db); | |
| const emitter = new EventEmitter(); | |
| emitter.setMaxListeners(100); // plugins may have many listeners; 100 is a safe upper bound | |
| const log = logger.child({ service: "plugin-lifecycle" }); | |
| // ----------------------------------------------------------------------- | |
| // Internal helpers | |
| // ----------------------------------------------------------------------- | |
| async function requirePlugin(pluginId: string): Promise<PluginRecord> { | |
| const plugin = await registry.getById(pluginId); | |
| if (!plugin) throw notFound(`Plugin not found: ${pluginId}`); | |
| return plugin as PluginRecord; | |
| } | |
| function assertTransition(plugin: PluginRecord, to: PluginStatus): void { | |
| if (!isValidTransition(plugin.status, to)) { | |
| throw badRequest( | |
| `Invalid lifecycle transition: ${plugin.status} β ${to} for plugin ${plugin.pluginKey}`, | |
| ); | |
| } | |
| } | |
| async function transition( | |
| pluginId: string, | |
| to: PluginStatus, | |
| lastError: string | null = null, | |
| existingPlugin?: PluginRecord, | |
| ): Promise<PluginRecord> { | |
| const plugin = existingPlugin ?? await requirePlugin(pluginId); | |
| assertTransition(plugin, to); | |
| const previousStatus = plugin.status; | |
| const updated = await registry.updateStatus(pluginId, { | |
| status: to, | |
| lastError, | |
| }); | |
| if (!updated) throw notFound(`Plugin not found after status update: ${pluginId}`); | |
| const result = updated as PluginRecord; | |
| log.info( | |
| { pluginId, pluginKey: result.pluginKey, from: previousStatus, to }, | |
| `plugin lifecycle: ${previousStatus} β ${to}`, | |
| ); | |
| // Emit the generic status_changed event | |
| emitter.emit("plugin.status_changed", { | |
| pluginId, | |
| pluginKey: result.pluginKey, | |
| previousStatus, | |
| newStatus: to, | |
| }); | |
| return result; | |
| } | |
| function emitDomain( | |
| event: LifecycleEventName, | |
| payload: PluginLifecycleEvents[LifecycleEventName], | |
| ): void { | |
| emitter.emit(event, payload); | |
| } | |
| // ----------------------------------------------------------------------- | |
| // Worker management helpers | |
| // ----------------------------------------------------------------------- | |
| /** | |
| * Stop the worker for a plugin if one is running. | |
| * This is a best-effort operation β if no worker manager is configured | |
| * or no worker is running, it silently succeeds. | |
| */ | |
| async function stopWorkerIfRunning( | |
| pluginId: string, | |
| pluginKey: string, | |
| ): Promise<void> { | |
| if (!workerManager) return; | |
| if (!workerManager.isRunning(pluginId) && !workerManager.getWorker(pluginId)) return; | |
| try { | |
| await workerManager.stopWorker(pluginId); | |
| log.info({ pluginId, pluginKey }, "plugin lifecycle: worker stopped"); | |
| emitDomain("plugin.worker_stopped", { pluginId, pluginKey }); | |
| } catch (err) { | |
| log.warn( | |
| { pluginId, pluginKey, err: err instanceof Error ? err.message : String(err) }, | |
| "plugin lifecycle: failed to stop worker (best-effort)", | |
| ); | |
| } | |
| } | |
| async function activateReadyPlugin(pluginId: string): Promise<void> { | |
| const supportsRuntimeActivation = | |
| typeof pluginLoaderInstance.hasRuntimeServices === "function" | |
| && typeof pluginLoaderInstance.loadSingle === "function"; | |
| if (!supportsRuntimeActivation || !pluginLoaderInstance.hasRuntimeServices()) { | |
| return; | |
| } | |
| const loadResult = await pluginLoaderInstance.loadSingle(pluginId); | |
| if (!loadResult.success) { | |
| throw new Error( | |
| loadResult.error | |
| ?? `Failed to activate plugin ${loadResult.plugin.pluginKey}`, | |
| ); | |
| } | |
| } | |
| async function deactivatePluginRuntime( | |
| pluginId: string, | |
| pluginKey: string, | |
| ): Promise<void> { | |
| const supportsRuntimeDeactivation = | |
| typeof pluginLoaderInstance.hasRuntimeServices === "function" | |
| && typeof pluginLoaderInstance.unloadSingle === "function"; | |
| if (supportsRuntimeDeactivation && pluginLoaderInstance.hasRuntimeServices()) { | |
| await pluginLoaderInstance.unloadSingle(pluginId, pluginKey); | |
| return; | |
| } | |
| await stopWorkerIfRunning(pluginId, pluginKey); | |
| } | |
| // ----------------------------------------------------------------------- | |
| // Public API | |
| // ----------------------------------------------------------------------- | |
| return { | |
| // -- load ------------------------------------------------------------- | |
| /** | |
| * load β Transitions a plugin to 'ready' status and starts its worker. | |
| * | |
| * This method is called after a plugin has been successfully installed and | |
| * validated. It marks the plugin as ready in the database and immediately | |
| * triggers the plugin loader to start the worker process. | |
| * | |
| * @param pluginId - The UUID of the plugin to load. | |
| * @returns The updated plugin record. | |
| */ | |
| async load(pluginId: string): Promise<PluginRecord> { | |
| const result = await transition(pluginId, "ready"); | |
| await activateReadyPlugin(pluginId); | |
| emitDomain("plugin.loaded", { | |
| pluginId, | |
| pluginKey: result.pluginKey, | |
| }); | |
| emitDomain("plugin.enabled", { | |
| pluginId, | |
| pluginKey: result.pluginKey, | |
| }); | |
| return result; | |
| }, | |
| // -- enable ----------------------------------------------------------- | |
| /** | |
| * enable β Re-enables a plugin that was previously in an error or upgrade state. | |
| * | |
| * Similar to load(), this method transitions the plugin to 'ready' and starts | |
| * its worker, but it specifically targets plugins that are currently disabled. | |
| * | |
| * @param pluginId - The UUID of the plugin to enable. | |
| * @returns The updated plugin record. | |
| */ | |
| async enable(pluginId: string): Promise<PluginRecord> { | |
| const plugin = await requirePlugin(pluginId); | |
| // Only allow enabling from disabled, error, or upgrade_pending states | |
| if (plugin.status !== "disabled" && plugin.status !== "error" && plugin.status !== "upgrade_pending") { | |
| throw badRequest( | |
| `Cannot enable plugin in status '${plugin.status}'. ` + | |
| `Plugin must be in 'disabled', 'error', or 'upgrade_pending' status to be enabled.`, | |
| ); | |
| } | |
| const result = await transition(pluginId, "ready", null, plugin); | |
| await activateReadyPlugin(pluginId); | |
| emitDomain("plugin.enabled", { | |
| pluginId, | |
| pluginKey: result.pluginKey, | |
| }); | |
| return result; | |
| }, | |
| // -- disable ---------------------------------------------------------- | |
| async disable(pluginId: string, reason?: string): Promise<PluginRecord> { | |
| const plugin = await requirePlugin(pluginId); | |
| // Only allow disabling from ready state | |
| if (plugin.status !== "ready") { | |
| throw badRequest( | |
| `Cannot disable plugin in status '${plugin.status}'. ` + | |
| `Plugin must be in 'ready' status to be disabled.`, | |
| ); | |
| } | |
| await deactivatePluginRuntime(pluginId, plugin.pluginKey); | |
| const result = await transition(pluginId, "disabled", reason ?? null, plugin); | |
| emitDomain("plugin.disabled", { | |
| pluginId, | |
| pluginKey: result.pluginKey, | |
| reason, | |
| }); | |
| return result; | |
| }, | |
| // -- unload ----------------------------------------------------------- | |
| async unload( | |
| pluginId: string, | |
| removeData = false, | |
| ): Promise<PluginRecord | null> { | |
| const plugin = await requirePlugin(pluginId); | |
| // If already uninstalled and removeData, hard-delete | |
| if (plugin.status === "uninstalled") { | |
| if (removeData) { | |
| await pluginLoaderInstance.cleanupInstallArtifacts(plugin); | |
| const deleted = await registry.uninstall(pluginId, true); | |
| log.info( | |
| { pluginId, pluginKey: plugin.pluginKey }, | |
| "plugin lifecycle: hard-deleted already-uninstalled plugin", | |
| ); | |
| emitDomain("plugin.unloaded", { | |
| pluginId, | |
| pluginKey: plugin.pluginKey, | |
| removeData: true, | |
| }); | |
| return deleted as PluginRecord | null; | |
| } | |
| throw badRequest( | |
| `Plugin ${plugin.pluginKey} is already uninstalled. ` + | |
| `Use removeData=true to permanently delete it.`, | |
| ); | |
| } | |
| await deactivatePluginRuntime(pluginId, plugin.pluginKey); | |
| await pluginLoaderInstance.cleanupInstallArtifacts(plugin); | |
| // Perform the uninstall via registry (handles soft/hard delete) | |
| const result = await registry.uninstall(pluginId, removeData); | |
| log.info( | |
| { pluginId, pluginKey: plugin.pluginKey, removeData }, | |
| `plugin lifecycle: ${plugin.status} β uninstalled${removeData ? " (hard delete)" : ""}`, | |
| ); | |
| emitter.emit("plugin.status_changed", { | |
| pluginId, | |
| pluginKey: plugin.pluginKey, | |
| previousStatus: plugin.status, | |
| newStatus: "uninstalled" as PluginStatus, | |
| }); | |
| emitDomain("plugin.unloaded", { | |
| pluginId, | |
| pluginKey: plugin.pluginKey, | |
| removeData, | |
| }); | |
| return result as PluginRecord | null; | |
| }, | |
| // -- markError -------------------------------------------------------- | |
| async markError(pluginId: string, error: string): Promise<PluginRecord> { | |
| // Stop the worker β the plugin is in an error state and should not | |
| // continue running. The worker manager's auto-restart is disabled | |
| // because we are intentionally taking the plugin offline. | |
| const plugin = await requirePlugin(pluginId); | |
| await deactivatePluginRuntime(pluginId, plugin.pluginKey); | |
| const result = await transition(pluginId, "error", error, plugin); | |
| emitDomain("plugin.error", { | |
| pluginId, | |
| pluginKey: result.pluginKey, | |
| error, | |
| }); | |
| return result; | |
| }, | |
| // -- markUpgradePending ----------------------------------------------- | |
| async markUpgradePending(pluginId: string): Promise<PluginRecord> { | |
| const plugin = await requirePlugin(pluginId); | |
| await deactivatePluginRuntime(pluginId, plugin.pluginKey); | |
| const result = await transition(pluginId, "upgrade_pending", null, plugin); | |
| emitDomain("plugin.upgrade_pending", { | |
| pluginId, | |
| pluginKey: result.pluginKey, | |
| }); | |
| return result; | |
| }, | |
| // -- upgrade ---------------------------------------------------------- | |
| /** | |
| * Upgrade a plugin to a newer version by performing a package update and | |
| * managing the lifecycle state transition. | |
| * | |
| * Following PLUGIN_SPEC.md Β§25.3, the upgrade process: | |
| * 1. Stops the current worker process (if running). | |
| * 2. Fetches and validates the new plugin package via the `PluginLoader`. | |
| * 3. Compares the capabilities declared in the new manifest against the old one. | |
| * 4. If new capabilities are added, transitions the plugin to `upgrade_pending` | |
| * to await operator approval (worker stays stopped). | |
| * 5. If no new capabilities are added, transitions the plugin back to `ready` | |
| * with the updated version and manifest metadata. | |
| * | |
| * @param pluginId - The UUID of the plugin to upgrade. | |
| * @param version - Optional target version specifier. | |
| * @returns The updated `PluginRecord`. | |
| * @throws {BadRequest} If the plugin is not in a ready or upgrade_pending state. | |
| */ | |
| async upgrade(pluginId: string, version?: string): Promise<PluginRecord> { | |
| const plugin = await requirePlugin(pluginId); | |
| // Can only upgrade plugins that are ready or already in upgrade_pending | |
| if (plugin.status !== "ready" && plugin.status !== "upgrade_pending") { | |
| throw badRequest( | |
| `Cannot upgrade plugin in status '${plugin.status}'. ` + | |
| `Plugin must be in 'ready' or 'upgrade_pending' status to be upgraded.`, | |
| ); | |
| } | |
| log.info( | |
| { pluginId, pluginKey: plugin.pluginKey, targetVersion: version }, | |
| "plugin lifecycle: upgrade requested", | |
| ); | |
| await deactivatePluginRuntime(pluginId, plugin.pluginKey); | |
| // 1. Download and validate new package via loader | |
| const { oldManifest, newManifest, discovered } = | |
| await pluginLoaderInstance.upgradePlugin(pluginId, { version }); | |
| log.info( | |
| { | |
| pluginId, | |
| pluginKey: plugin.pluginKey, | |
| oldVersion: oldManifest.version, | |
| newVersion: newManifest.version, | |
| }, | |
| "plugin lifecycle: package upgraded on disk", | |
| ); | |
| // 2. Compare capabilities | |
| const addedCaps = newManifest.capabilities.filter( | |
| (cap) => !oldManifest.capabilities.includes(cap), | |
| ); | |
| // 3. Transition state | |
| if (addedCaps.length > 0) { | |
| // New capabilities require operator approval β worker stays stopped | |
| log.info( | |
| { pluginId, pluginKey: plugin.pluginKey, addedCaps }, | |
| "plugin lifecycle: new capabilities detected, transitioning to upgrade_pending", | |
| ); | |
| // Skip the inner stopWorkerIfRunning since we already stopped above | |
| const result = await transition(pluginId, "upgrade_pending", null, plugin); | |
| emitDomain("plugin.upgrade_pending", { | |
| pluginId, | |
| pluginKey: result.pluginKey, | |
| }); | |
| return result; | |
| } else { | |
| const result = await transition(pluginId, "ready", null, { | |
| ...plugin, | |
| version: discovered.version, | |
| manifestJson: newManifest, | |
| } as PluginRecord); | |
| await activateReadyPlugin(pluginId); | |
| emitDomain("plugin.loaded", { | |
| pluginId, | |
| pluginKey: result.pluginKey, | |
| }); | |
| emitDomain("plugin.enabled", { | |
| pluginId, | |
| pluginKey: result.pluginKey, | |
| }); | |
| return result; | |
| } | |
| }, | |
| // -- startWorker ------------------------------------------------------ | |
| async startWorker( | |
| pluginId: string, | |
| options: WorkerStartOptions, | |
| ): Promise<void> { | |
| if (!workerManager) { | |
| throw badRequest( | |
| "Cannot start worker: no PluginWorkerManager is configured. " + | |
| "Provide a workerManager option when constructing the lifecycle manager.", | |
| ); | |
| } | |
| const plugin = await requirePlugin(pluginId); | |
| if (plugin.status !== "ready") { | |
| throw badRequest( | |
| `Cannot start worker for plugin in status '${plugin.status}'. ` + | |
| `Plugin must be in 'ready' status.`, | |
| ); | |
| } | |
| log.info( | |
| { pluginId, pluginKey: plugin.pluginKey }, | |
| "plugin lifecycle: starting worker", | |
| ); | |
| await workerManager.startWorker(pluginId, options); | |
| emitDomain("plugin.worker_started", { | |
| pluginId, | |
| pluginKey: plugin.pluginKey, | |
| }); | |
| log.info( | |
| { pluginId, pluginKey: plugin.pluginKey }, | |
| "plugin lifecycle: worker started", | |
| ); | |
| }, | |
| // -- stopWorker ------------------------------------------------------- | |
| async stopWorker(pluginId: string): Promise<void> { | |
| if (!workerManager) return; // No worker manager β nothing to stop | |
| const plugin = await requirePlugin(pluginId); | |
| await stopWorkerIfRunning(pluginId, plugin.pluginKey); | |
| }, | |
| // -- restartWorker ---------------------------------------------------- | |
| async restartWorker(pluginId: string): Promise<void> { | |
| if (!workerManager) { | |
| throw badRequest( | |
| "Cannot restart worker: no PluginWorkerManager is configured.", | |
| ); | |
| } | |
| const plugin = await requirePlugin(pluginId); | |
| if (plugin.status !== "ready") { | |
| throw badRequest( | |
| `Cannot restart worker for plugin in status '${plugin.status}'. ` + | |
| `Plugin must be in 'ready' status.`, | |
| ); | |
| } | |
| const handle = workerManager.getWorker(pluginId); | |
| if (!handle) { | |
| throw badRequest( | |
| `Cannot restart worker for plugin "${plugin.pluginKey}": no worker is running.`, | |
| ); | |
| } | |
| log.info( | |
| { pluginId, pluginKey: plugin.pluginKey }, | |
| "plugin lifecycle: restarting worker", | |
| ); | |
| await handle.restart(); | |
| emitDomain("plugin.worker_stopped", { pluginId, pluginKey: plugin.pluginKey }); | |
| emitDomain("plugin.worker_started", { pluginId, pluginKey: plugin.pluginKey }); | |
| log.info( | |
| { pluginId, pluginKey: plugin.pluginKey }, | |
| "plugin lifecycle: worker restarted", | |
| ); | |
| }, | |
| // -- getStatus -------------------------------------------------------- | |
| async getStatus(pluginId: string): Promise<PluginStatus | null> { | |
| const plugin = await registry.getById(pluginId); | |
| return plugin?.status ?? null; | |
| }, | |
| // -- canTransition ---------------------------------------------------- | |
| async canTransition(pluginId: string, to: PluginStatus): Promise<boolean> { | |
| const plugin = await registry.getById(pluginId); | |
| if (!plugin) return false; | |
| return isValidTransition(plugin.status, to); | |
| }, | |
| // -- Event subscriptions ---------------------------------------------- | |
| on(event, listener) { | |
| emitter.on(event, listener); | |
| }, | |
| off(event, listener) { | |
| emitter.off(event, listener); | |
| }, | |
| once(event, listener) { | |
| emitter.once(event, listener); | |
| }, | |
| }; | |
| } | |