bext-plugin-api 0.2.0

Plugin trait definitions and shared types for bext — the public ABI for plugin authors
Documentation
//! Scheduled capability trait and types for cron-style background work.
//!
//! Plugins declare a set of [`Schedule`]s at init time; the host-owned
//! scheduler ticks, decides which schedules are due, and calls
//! [`ScheduledPlugin::run`] for each one. The plugin returns a result and
//! execution continues on the next tick.
//!
//! Schedules are **declarative**, not dynamic. A plugin returns its schedule
//! list once from [`ScheduledPlugin::schedules`]; there is no runtime
//! `register_schedule` host function. This mirrors the existing built-in
//! scheduler in `bext-core::scheduler` (driven by `[[tasks]]` in
//! `bext.config.toml`) and keeps the shape introspectable at install time —
//! the registry resolver, the dev dashboard, and `cap_conformance` can all
//! enumerate a plugin's schedules without running it.
//!
//! Data types are plain POD with `String`/`u64`/enum fields so the trait
//! stays ABI-compatible with the WASM guest crate (matches the convention
//! used by [`crate::lifecycle::LifecyclePlugin`] — JSON strings, `Result<_,
//! String>`, no framework types). The `bext-core` side owns the rich
//! `scheduler::Schedule` enum and converts to/from this POD shape at the
//! host boundary.

/// How the scheduler should behave when it discovers a schedule that was
/// due to fire while the host was down, restarting, or otherwise not
/// ticking.
///
/// This is distinct from "a previous run of this schedule is still
/// executing" — in that case the scheduler always skips (see
/// `is_due` in `bext-core::scheduler`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MissedRunPolicy {
    /// Run once to catch up, then resume normal cadence. Sensible default
    /// for idempotent jobs like cache warming or GC sweeps.
    RunOnce,
    /// Skip missed runs entirely and wait for the next scheduled tick.
    /// Use for jobs where a delayed run is worse than no run (e.g.
    /// time-sensitive notifications).
    Skip,
    /// Run every missed occurrence back to back. Use sparingly — only
    /// makes sense for accounting-style jobs that must not drop any tick.
    RunAll,
}

impl Default for MissedRunPolicy {
    fn default() -> Self {
        Self::RunOnce
    }
}

/// Hint to the scheduler about distributed-locking requirements.
///
/// Single-node deployments can ignore this field. Multi-node deployments
/// use it, in combination with a configured [`crate::locking::LockingPlugin`],
/// to decide which instance fires each schedule.
///
/// # How the host uses the hint
///
/// The Locking capability shipped in E2 as
/// [`crate::locking::LockingPlugin`]. The host-owned scheduler in
/// `bext-core::scheduler` consults this field at each tick:
///
/// - [`LockingHint::NodeLocal`] — skip locking entirely; every node
///   may fire its own copy.
/// - [`LockingHint::RequireGlobal`] — call
///   [`LockingPlugin::try_lock`](crate::locking::LockingPlugin::try_lock)
///   keyed on `schedule_id` before invoking
///   [`ScheduledPlugin::run`]. On `Ok(None)` another node holds the
///   lock; skip this tick. On `Err(..)` log and skip.
/// - [`LockingHint::PreferGlobal`] — try the lock as above, but if no
///   `LockingPlugin` is configured fall back to `NodeLocal` behavior
///   rather than failing the install.
///
/// Performing the acquisition lives in `bext-core::scheduler` and is
/// tracked separately from this trait-level doc. The shape, however, is
/// stable: plugins declare the hint once, the host does the rest.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LockingHint {
    /// No coordination needed — every node may fire its own copy.
    /// Use for per-node maintenance like local cache GC.
    NodeLocal,
    /// The scheduler should hold a cluster-wide lock for the duration of
    /// the run. Only one node fires per scheduled tick. Default for most
    /// business logic.
    RequireGlobal,
    /// Best-effort cluster coordination — try to acquire a global lock,
    /// fall back to node-local if the Locking capability is not
    /// configured. Lets a plugin work correctly in both single-node and
    /// multi-node deployments without hard-failing the install.
    PreferGlobal,
}

impl Default for LockingHint {
    fn default() -> Self {
        Self::RequireGlobal
    }
}

/// A single declared schedule.
///
/// The `cron` field accepts the same syntax as `bext-core::scheduler::Schedule::parse`:
/// - simple intervals: `"30s"`, `"5m"`, `"2h"`
/// - cron `*/N` subset: `"*/5 * * * *"`, `"0 */3 * * *"`
/// - shortcuts: `"@hourly"`, `"@daily"`
///
/// Invalid expressions are rejected at plugin load time (the host parses
/// before registering), so a plugin that returns a bad expression fails
/// to start with a clear error.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Schedule {
    /// Stable identifier passed back to [`ScheduledPlugin::run`]. Must be
    /// unique within a plugin. Used as the locking key and as the
    /// dashboard / metrics label.
    pub id: String,
    /// Human-readable description for the dev dashboard. Optional.
    #[serde(default)]
    pub description: String,
    /// Schedule expression. See module docs for accepted syntax.
    pub cron: String,
    /// Maximum random delay applied to each tick, in milliseconds. Use
    /// non-zero jitter for jobs that several plugins might fire
    /// simultaneously (e.g., a 60s job with 5000ms jitter spreads load
    /// across a 5-second window). Zero means no jitter.
    #[serde(default)]
    pub jitter_ms: u64,
    /// Behavior when the scheduler detects a missed run.
    #[serde(default)]
    pub missed_run_policy: MissedRunPolicy,
    /// Multi-instance coordination hint. Ignored until `Locking` lands
    /// in E2.
    #[serde(default)]
    pub locking: LockingHint,
    /// Opaque per-schedule config the plugin can use to parameterize its
    /// run logic. Encoded as a JSON string for WASM ABI compatibility
    /// (matches the convention in [`crate::lifecycle::LifecyclePlugin`]).
    /// Empty string means "no config".
    #[serde(default)]
    pub config_json: String,
}

/// Context supplied to a single invocation of [`ScheduledPlugin::run`].
///
/// Plain-data only — no framework types. The host populates this from its
/// own clock and scheduler state; the plugin reads it to decide how to
/// behave on catch-up runs, to emit tracing, etc.
#[derive(Debug, Clone)]
pub struct ScheduledRunCtx {
    /// The `Schedule::id` being invoked. The plugin dispatches on this
    /// when it owns more than one schedule.
    pub schedule_id: String,
    /// Wall-clock time the scheduler decided to fire this run,
    /// milliseconds since Unix epoch. May differ from "now" when the run
    /// is a catch-up under `MissedRunPolicy::RunAll`.
    pub scheduled_at_ms: u64,
    /// Monotonic run counter since the scheduler started. Lets the
    /// plugin detect "first run after restart" (run_count == 1) without
    /// storing its own state.
    pub run_count: u64,
    /// `true` if this run is catching up for a tick missed while the
    /// host was down. `false` for normal on-time runs. Lets idempotent
    /// jobs skip expensive work on catch-up.
    pub is_catchup: bool,
    /// Tenant id the run is scoped to, if the plugin is installed under
    /// a tenant. Matches the `tenant_id` on
    /// [`crate::middleware::RequestContext`] for consistency.
    pub tenant_id: Option<String>,
    /// Site id the run is scoped to, if the plugin is installed under a
    /// site.
    pub site_id: Option<String>,
}

/// A plugin that declares cron-style background work.
///
/// **Compile-time and WASM execution.** All methods receive and return
/// JSON-friendly or POD data for ABI compatibility with WASM guests.
/// Runs are fire-and-forget from the request path's point of view — the
/// scheduler ticks on a dedicated task, so [`run`] must not block any
/// foreground work and is allowed to take its time (subject to the fuel
/// budget in [`fuel::RUN`]).
///
/// Concurrency: the host guarantees at most one concurrent invocation of
/// `run()` per `schedule_id` per node. Across nodes, at-most-one is
/// provided by the [`LockingHint`] in conjunction with a configured
/// [`crate::locking::LockingPlugin`] — the E2 Locking capability. When
/// no `LockingPlugin` is installed, multi-node deployments may
/// double-fire `RequireGlobal` schedules and should be configured with a
/// single scheduler node; the scheduler logs a warning at start when it
/// detects this condition.
///
/// [`run`]: ScheduledPlugin::run
/// [`fuel::RUN`]: fuel::RUN
pub trait ScheduledPlugin: Send + Sync {
    /// Unique identifier (e.g., `"cron"`, `"daily-reports"`). Must be
    /// stable across restarts — used as part of the locking key and the
    /// metrics label.
    fn name(&self) -> &str;

    /// Execution order among scheduled plugins when more than one has a
    /// schedule firing in the same tick. Lower runs first. Default:
    /// `1000`. The number matches the priority convention used by
    /// [`crate::lifecycle::LifecyclePlugin`] since scheduled jobs are
    /// conceptually in the same "background" family.
    fn priority(&self) -> u32 {
        1000
    }

    /// Declare the set of schedules this plugin owns.
    ///
    /// Called once at plugin init (server start, or plugin hot-reload).
    /// The returned list is cached by the host and passed to
    /// [`crate::types::PluginManifest::provides_capabilities`] discovery,
    /// the dev dashboard, and the `cap_conformance` harness. Re-reading
    /// is cheap but not required — plugins are expected to return the
    /// same list for the lifetime of a process.
    ///
    /// Returning an empty list is legal: a plugin may implement
    /// `ScheduledPlugin` just so it can be installed by configuration
    /// and then receive dynamic schedules from elsewhere (future
    /// extension point, not used in E1).
    fn schedules(&self) -> Result<Vec<Schedule>, String>;

    /// Execute one scheduled run.
    ///
    /// The host calls this when a declared schedule is due. Returns
    /// `Ok(())` on success, `Err(message)` on failure; the scheduler
    /// records the failure in [`bext-core::scheduler::TaskState::error_count`]
    /// and logs the message. Plugins must not panic — panic inside a
    /// WASM run counts as an error and is caught by the host.
    ///
    /// The method is synchronous in the trait to stay WASM-ABI friendly
    /// (like [`crate::lifecycle::LifecyclePlugin::on_request_complete`]).
    /// Native plugins that need async work should drive their own
    /// runtime inside `run()`; WASM plugins that need async I/O should
    /// use the host-function bridge documented in
    /// `bext-core::host_fns::scheduled`.
    fn run(&self, ctx: &ScheduledRunCtx) -> Result<(), String>;

    /// Called before the plugin is unloaded. Release resources (open
    /// files, database handles, etc.) here. Default: no-op.
    fn cleanup(&self) -> Result<(), String> {
        Ok(())
    }
}

/// Fuel budgets for WASM scheduled plugin calls.
///
/// Matches the shape in [`crate::types::fuel`]. The `RUN` budget is
/// deliberately generous — scheduled jobs routinely do real work
/// (database sweeps, report generation) and the host gates them via the
/// scheduler's tick interval rather than per-call fuel.
pub mod fuel {
    /// Fuel for a single [`super::ScheduledPlugin::run`] call.
    pub const RUN: u64 = 1_000_000_000;
    /// Fuel for [`super::ScheduledPlugin::schedules`] (called once at init).
    /// Small because the list is static.
    pub const SCHEDULES: u64 = 10_000_000;
    /// Fuel for [`super::ScheduledPlugin::cleanup`].
    pub const CLEANUP: u64 = 100_000_000;
}