Files
2026-05-27 23:29:53 +01:00

7.3 KiB

LifecycleDriver

(no submodules)

Purpose

LifecycleDriver is the runtime executor for the per-plugin lifecycle FSM defined in @suaveplan/api-lifecycle. It owns one record per registered plugin (manifest hash, plugin module from definePlugin, schemaVersion, current FSM state, retry trackers, hook log) and translates the author-facing verbs install / start / stop / uninstall / crash / retryInstall / retryStart / clearRollback / forceUninstall / repair into the trigger sequences that the createPluginFsm interpreter applies against the legal-transition table.

Each high-level operation dispatches the entry trigger, executes the side-effect descriptors captured on the FSM (the discriminated-union SideEffectOp from @suaveplan/api-lifecycle/side-effects), then dispatches the completion trigger. Failures along the way auto-dispatch the matching *Failed trigger and the FSM ends in the documented parked state (install_failed, start_failed, uninstall_failed). Auto-retry of start_failed follows the @suaveplan/retry exponential-backoff policy defined in RETRY_POLICIES.startFailed; crashed plugins auto-restart up to RETRY_POLICIES.crashed.crashRestartMax (default 3) with cooldown reset through CrashBudgetTracker.

Features

  • Full coverage of every (state, trigger) edge in @suaveplan/api-lifecycle's PLUGIN_TRANSITIONS table.
  • Per-plugin mutex enforcement inherited from createPluginFsm — concurrent triggers queue FIFO; tests assert this dedup.
  • SideEffectOp execution against pluggable adapter surfaces (RouterAdapter, JobSchedulerAdapter, TopicSubscriberAdapter, HealthMarkerAdapter, LedgerAdapter, MigrationRunner, RbacAdapter, CapabilityInjector, InflightTracker).
  • Emits plugin.lifecycle_transitions_total{plugin, from, to, trigger, outcome} on every transition via Chunk-A's HostInstruments.
  • Emits typed events onto the host EventBus<LifecycleEventMap> — plugin.installed, plugin.started, plugin.stopped, plugin.uninstalled, plugin.rolled_back, plugin.crashed.
  • snapshot(plugin) renders an admin-friendly view with the FSM state, the most recent hook outcomes, and any captured lastError.
  • rollback(plugin, cause) is invoked by the ReloadDriver (see ../hot-reload/) on a failed reload — drives starting → rolled_back without operator intervention.

Usage: Basic

import { EventBus } from "@suaveplan/events";
import {
    createHostInstruments,
    LifecycleDriver,
} from "@suaveplan/api-host";

const events = new EventBus();
const instruments = createHostInstruments({
    hostId: "h1",
    hostVersion: "0.1.0",
});

const driver = new LifecycleDriver({
    adapters: {
        router: { mount() {}, unmount() {} },
        jobs: { register() {}, pause() {}, unpause() {} },
        topics: { subscribe() {}, unsubscribe() {} },
        health: { mark() {} },
        ledger: {
            async write() {},
            async drop() {},
            async read() { return null; },
        },
        migrations: { async runUpTo() {} },
        rbac: { registerScopes() {}, dropScopes() {} },
        capabilities: {
            inject: () => ({}) as never,
            dispose: async () => {},
        },
        inflight: {
            async drain() { return 0; },
            forceCancel: () => 0,
        },
    },
    events,
    instruments,
});

driver.register({
    module: pluginModule,
    manifest,
    manifestHash: "sha256:...",
    specifier: "file:///opt/plugin/dist/index.js?v=1",
    schemaVersion: 1,
});

await driver.install(pluginModule.name);
await driver.start(pluginModule.name);

Usage: Advanced

Customise the per-plugin mutex (e.g. backed by a distributed lock) and the crash-restart cap:

import { LifecycleDriver } from "@suaveplan/api-host/lifecycle";
import type { PluginMutex } from "@suaveplan/api-lifecycle";

class RedisPluginMutex implements PluginMutex {
    async acquire(plugin: string) { /* … */ }
    tryAcquire(plugin: string) { /* … */ }
}

const driver = new LifecycleDriver({
    adapters,
    events,
    instruments,
    mutex: new RedisPluginMutex(),
    crashRestartMax: 5,
    sleep: () => Promise.resolve(),
});

driver.events.on("plugin.rolled_back", ({ plugin, from, to }) => {
    operator.notify(`${plugin} rolled back ${from} → ${to}`);
});

await driver.crash(plugin.name, new Error("worker exit code 137"));
const snap = driver.snapshot(plugin.name);
if (snap.lastError !== null) operator.escalate(snap);

API Reference

  • class LifecycleDriver
    • constructor(options: LifecycleDriverOptions)
    • register(record: RegisteredPlugin): void
    • update(record: RegisteredPlugin): void
    • install(plugin: string): Promise<PluginState>
    • start(plugin: string): Promise<PluginState>
    • stop(plugin: string): Promise<PluginState>
    • uninstall(plugin: string): Promise<PluginState>
    • crash(plugin: string, cause: unknown): Promise<PluginState>
    • retryInstall(plugin: string): Promise<PluginState>
    • retryStart(plugin: string): Promise<PluginState>
    • clearRollback(plugin: string): Promise<PluginState>
    • forceUninstall(plugin: string): Promise<PluginState>
    • repair(plugin: string): Promise<PluginState>
    • rollback(plugin: string, cause: unknown): Promise<PluginState>
    • snapshot(plugin: string): LifecycleSnapshot
    • names(): readonly string[]
  • interface LifecycleDriverOptions — adapters, events, instruments, mutex?, crashRestartMax?, sleep?
  • interface LifecycleEventMap — plugin.installed, plugin.started, plugin.stopped, plugin.uninstalled, plugin.rolled_back, plugin.crashed.
  • Adapter surfaces from lifecycle.types.ts — RouterAdapter, JobSchedulerAdapter, TopicSubscriberAdapter, HealthMarkerAdapter, LedgerAdapter, MigrationRunner, RbacAdapter, CapabilityInjector, InflightTracker, LifecycleAdapters.

Implementation Notes

The driver intentionally delegates illegal-transition detection to the underlying PluginFsm — every operator verb maps to one entry trigger; attempting start() from crashed therefore raises a LifecycleError(ILLEGAL_TRANSITION) from the FSM rather than a duplicate guard in the driver. This keeps the source-of-truth for legal transitions in The contract package.

Side-effect ops validate-manifest, resolve-deps, acquire-db-connection, complete-otel-span, apply-data-policy, and rollback-to-prior-module are documented but execute as no-ops inside the driver: manifest validation ran at registration time, dep-graph resolution is computed by api-manifest/dep-graph before registration, the ledger adapter holds the DB connection, OTel span completion is captured via the lifecycle counter, the data policy is applied by the next drop-ledger op, and the rollback module switch is performed by the ReloadDriver before the rolled_back transition fires. Keeping the no-op branches lets Stage-3 wire concrete behaviour without revising the FSM definition.

Auto-retry of start_failed uses backoffDelay(base, factor, attempt, max) locally rather than @suaveplan/retry's retry() engine because the driver needs to inter-leave the FSM transitions between attempts — @suaveplan/retry's engine is single-call-with-retries semantics. The underlying delay strategy is identical to RETRY_POLICIES.startFailed.delay.