| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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"; |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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"], |
| }; |
|
|
| |
| |
| |
| function isValidTransition(from: PluginStatus, to: PluginStatus): boolean { |
| return VALID_TRANSITIONS[from]?.includes(to) ?? false; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| export interface PluginLifecycleEvents { |
| |
| "plugin.loaded": { pluginId: string; pluginKey: string }; |
| |
| "plugin.enabled": { pluginId: string; pluginKey: string }; |
| |
| "plugin.disabled": { pluginId: string; pluginKey: string; reason?: string }; |
| |
| "plugin.unloaded": { pluginId: string; pluginKey: string; removeData: boolean }; |
| |
| "plugin.status_changed": { |
| pluginId: string; |
| pluginKey: string; |
| previousStatus: PluginStatus; |
| newStatus: PluginStatus; |
| }; |
| |
| "plugin.error": { pluginId: string; pluginKey: string; error: string }; |
| |
| "plugin.upgrade_pending": { pluginId: string; pluginKey: string }; |
| |
| "plugin.worker_started": { pluginId: string; pluginKey: string }; |
| |
| "plugin.worker_stopped": { pluginId: string; pluginKey: string }; |
| } |
|
|
| type LifecycleEventName = keyof PluginLifecycleEvents; |
| type LifecycleEventPayload<K extends LifecycleEventName> = PluginLifecycleEvents[K]; |
|
|
| |
| |
| |
|
|
| export interface PluginLifecycleManager { |
| |
| |
| |
| |
| |
| |
| |
| load(pluginId: string): Promise<PluginRecord>; |
|
|
| |
| |
| |
| |
| enable(pluginId: string): Promise<PluginRecord>; |
|
|
| |
| |
| |
| |
| disable(pluginId: string, reason?: string): Promise<PluginRecord>; |
|
|
| |
| |
| |
| |
| |
| |
| |
| unload(pluginId: string, removeData?: boolean): Promise<PluginRecord | null>; |
|
|
| |
| |
| |
| |
| markError(pluginId: string, error: string): Promise<PluginRecord>; |
|
|
| |
| |
| |
| |
| markUpgradePending(pluginId: string): Promise<PluginRecord>; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| upgrade(pluginId: string, version?: string): Promise<PluginRecord>; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| startWorker(pluginId: string, options: WorkerStartOptions): Promise<void>; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| stopWorker(pluginId: string): Promise<void>; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| restartWorker(pluginId: string): Promise<void>; |
|
|
| |
| |
| |
| getStatus(pluginId: string): Promise<PluginStatus | null>; |
|
|
| |
| |
| |
| canTransition(pluginId: string, to: PluginStatus): Promise<boolean>; |
|
|
| |
| |
| |
| on<K extends LifecycleEventName>( |
| event: K, |
| listener: (payload: LifecycleEventPayload<K>) => void, |
| ): void; |
|
|
| |
| |
| |
| off<K extends LifecycleEventName>( |
| event: K, |
| listener: (payload: LifecycleEventPayload<K>) => void, |
| ): void; |
|
|
| |
| |
| |
| once<K extends LifecycleEventName>( |
| event: K, |
| listener: (payload: LifecycleEventPayload<K>) => void, |
| ): void; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| export interface PluginLifecycleManagerOptions { |
| |
| loader?: PluginLoader; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| workerManager?: PluginWorkerManager; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function pluginLifecycleManager( |
| db: Db, |
| options?: PluginLoader | PluginLifecycleManagerOptions, |
| ): PluginLifecycleManager { |
| |
| |
| let loaderArg: PluginLoader | undefined; |
| let workerManager: PluginWorkerManager | undefined; |
|
|
| if (options && typeof options === "object" && "discoverAll" in options) { |
| |
| 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); |
|
|
| const log = logger.child({ service: "plugin-lifecycle" }); |
|
|
| |
| |
| |
|
|
| 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}`, |
| ); |
|
|
| |
| 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); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| 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); |
| } |
|
|
| |
| |
| |
|
|
| return { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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; |
| }, |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async enable(pluginId: string): Promise<PluginRecord> { |
| const plugin = await requirePlugin(pluginId); |
|
|
| |
| 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; |
| }, |
|
|
| |
| async disable(pluginId: string, reason?: string): Promise<PluginRecord> { |
| const plugin = await requirePlugin(pluginId); |
|
|
| |
| 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; |
| }, |
|
|
| |
| async unload( |
| pluginId: string, |
| removeData = false, |
| ): Promise<PluginRecord | null> { |
| const plugin = await requirePlugin(pluginId); |
|
|
| |
| 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); |
|
|
| |
| 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; |
| }, |
|
|
| |
| async markError(pluginId: string, error: string): Promise<PluginRecord> { |
| |
| |
| |
| 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; |
| }, |
|
|
| |
| 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; |
| }, |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async upgrade(pluginId: string, version?: string): Promise<PluginRecord> { |
| const plugin = await requirePlugin(pluginId); |
|
|
| |
| 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); |
|
|
| |
| 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", |
| ); |
|
|
| |
| const addedCaps = newManifest.capabilities.filter( |
| (cap) => !oldManifest.capabilities.includes(cap), |
| ); |
|
|
| |
| if (addedCaps.length > 0) { |
| |
| log.info( |
| { pluginId, pluginKey: plugin.pluginKey, addedCaps }, |
| "plugin lifecycle: new capabilities detected, transitioning to upgrade_pending", |
| ); |
| |
| 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; |
| } |
| }, |
|
|
| |
| 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", |
| ); |
| }, |
|
|
| |
| async stopWorker(pluginId: string): Promise<void> { |
| if (!workerManager) return; |
|
|
| const plugin = await requirePlugin(pluginId); |
| await stopWorkerIfRunning(pluginId, plugin.pluginKey); |
| }, |
|
|
| |
| 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", |
| ); |
| }, |
|
|
| |
| async getStatus(pluginId: string): Promise<PluginStatus | null> { |
| const plugin = await registry.getById(pluginId); |
| return plugin?.status ?? null; |
| }, |
|
|
| |
| async canTransition(pluginId: string, to: PluginStatus): Promise<boolean> { |
| const plugin = await registry.getById(pluginId); |
| if (!plugin) return false; |
| return isValidTransition(plugin.status, to); |
| }, |
|
|
| |
| on(event, listener) { |
| emitter.on(event, listener); |
| }, |
|
|
| off(event, listener) { |
| emitter.off(event, listener); |
| }, |
|
|
| once(event, listener) { |
| emitter.once(event, listener); |
| }, |
| }; |
| } |
|
|