Plandex / server /src /services /plugin-lifecycle.ts
AUXteam's picture
Upload folder using huggingface_hub
cf9339a verified
/**
* 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);
},
};
}