Skip to main content

lattice_plugin_loader/
status.rs

1//! PL8.H.1 — the loader's plugin **status data model**: the read-only snapshot
2//! of every loaded plugin (identity · trust tier · capabilities granted/denied ·
3//! health) that the `:plugins` manager view (PL8.H.2/.3) renders.
4//!
5//! The loader already owns the loaded-plugin set and the `unload` / `reload`
6//! APIs; this adds the *observable* state the manager surfaces. All of it is
7//! off the keystroke path — computed at load time or flipped by an
8//! `Event::PluginCrashed` subscription drained on the runtime.
9//!
10//! Structured, not pre-formatted: `PluginStatus` carries typed [`Capability`]
11//! lists and a [`PluginHealth`] enum so the *view* owns presentation (glyphs,
12//! column widths, theming) — the view crate maps these to cells.
13
14use lattice_plugin_host::{Capability, TrustTier};
15
16use crate::source_record::SourceRecord;
17
18/// A loaded plugin's health — the quarantine/reload surface the manager view
19/// shows. A component trap taints its instance irrecoverably (wasmtime offers no
20/// rollback), so the instance is dead-until-reload; [`Event::PluginCrashed`]
21/// fires exactly once per instance on that first trap, flipping health here.
22///
23/// [`Event::PluginCrashed`]: lattice_protocol::Event::PluginCrashed
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum PluginHealth {
26    /// Loaded and running — no trap observed.
27    Healthy,
28    /// A seam export trapped: the instance is dead until a `:plugin-reload`
29    /// mints a fresh `Store`. `func` is the export that trapped (`"on-event"` /
30    /// `"generate"` / …); `kind` is the stable label (`"fuel"` / `"epoch"` /
31    /// `"trap"`) — the `Event::PluginCrashed` provenance, verbatim.
32    Quarantined { func: String, kind: String },
33}
34
35impl PluginHealth {
36    /// True once the plugin has crashed (a manager-view filter / glyph gate).
37    pub fn is_quarantined(&self) -> bool {
38        matches!(self, PluginHealth::Quarantined { .. })
39    }
40}
41
42#[cfg(test)]
43mod build_state_tests {
44    use super::BuildState;
45
46    #[test]
47    fn every_state_has_a_distinct_label() {
48        // Two states rendering the same cell would make the column useless
49        // exactly when it matters — `stale` vs `build-failed` are different
50        // problems with different fixes.
51        let labels = [
52            BuildState::NotBuilt.label(),
53            BuildState::Cached.label(),
54            BuildState::Stale.label(),
55            BuildState::Building.label(),
56            BuildState::Failed.label(),
57        ];
58        let mut seen = std::collections::HashSet::new();
59        for l in labels {
60            assert!(seen.insert(l), "duplicate build label: {l}");
61        }
62    }
63}
64
65/// WT.4: a plugin the loader tried to load and could not.
66///
67/// **A plugin that failed to load is indistinguishable from one that was never
68/// installed**, and that is not a cosmetic gap — it is what turned the reported
69/// failure into a debugging session rather than a glance. The editor opened, the
70/// file opened, and org was simply absent: no language, no highlighting, no
71/// folds, no chords, and nothing anywhere saying why.
72///
73/// Held separately from [`PluginStatus`] rather than as another `PluginHealth`
74/// variant, because a failed load has none of what that type carries: no
75/// host-issued id, no granted capabilities, no trust tier that was ever applied.
76/// Modelling it as a degenerate `PluginStatus` would mean inventing all three,
77/// and `:plugins`' row→plugin index mapping would then point at rows with no
78/// plugin behind them.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct FailedLoad {
81    /// The manifest id where one could be read, else the directory's file name.
82    /// A plugin whose manifest is itself the problem still needs a name in the
83    /// report, or the user cannot tell which of their plugins is being described.
84    pub name: String,
85    /// Where it was loaded from — the actionable half. The name says *what*
86    /// broke; this says *which copy on disk* to go and look at.
87    pub dir: std::path::PathBuf,
88    /// The rendered [`crate::PluginLoaderError`]. A string rather than the error
89    /// itself: this is a snapshot for a view, outliving the load that produced
90    /// it, and the view has no use for the variant.
91    pub error: String,
92}
93
94/// A read-only snapshot of one loaded plugin for the manager view. Cloned out of
95/// the loader's loaded-set under its lock (never a live borrow), so the view
96/// renders a stable frame while loads/unloads proceed.
97#[derive(Debug, Clone)]
98pub struct PluginStatus {
99    /// The host-issued numeric plugin id (the `u32` inside `SourceLayer::Plugin`
100    /// and `Event::PluginCrashed.plugin`).
101    pub id: u32,
102    /// The manifest id — the user-facing name and the `:plugin-unload <name>`
103    /// key.
104    pub name: String,
105    /// The trust tier the plugin loaded under (`Bundled` / `UserInstalled`) —
106    /// which gates `proc:spawn`.
107    pub tier: TrustTier,
108    /// Capabilities the plugin requested **and** received under its tier.
109    pub granted: Vec<Capability>,
110    /// Requested-but-withheld capabilities (tier-gated, e.g. `proc:spawn` for a
111    /// user-installed plugin). Never fatal — the plugin loaded degraded.
112    pub denied: Vec<Capability>,
113    /// Whether the plugin is running or quarantined after a crash.
114    pub health: PluginHealth,
115    /// PM.8a: where the plugin came from, read from its on-disk `.source`
116    /// marker. Persisted rather than remembered, so it is still right on the
117    /// boot *after* the one that installed it.
118    pub source: SourceRecord,
119    /// PM.8a: whether the cached artifact matches its source.
120    pub build: BuildState,
121}
122
123/// PM.8a: how current a plugin's built artifact is.
124///
125/// Answered from the `.build-stamp` PM.5 writes, so it survives a restart
126/// like the source does. Only meaningful for a buildable source — there is no
127/// such thing as a stale prebuilt or a stale bundled plugin, which is why
128/// [`BuildState::NotBuilt`] exists rather than reporting those as `Cached`.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum BuildState {
131    /// Nothing the editor builds: bundled, prebuilt, or an unknown source.
132    NotBuilt,
133    /// The artifact was built from the source as it stands.
134    Cached,
135    /// The source has changed since the artifact was built. The plugin is
136    /// running old code until the next build.
137    Stale,
138    /// A build is running right now.
139    ///
140    /// The one state that is NOT derived from disk. Everything else here is a
141    /// fact about files that outlives the process; this is a fact about the
142    /// process, so it lives in memory and disappears with it — which is
143    /// correct, because a build interrupted by a crash is not still running
144    /// after a restart.
145    Building,
146    /// The last build attempted this session failed. The plugin is running
147    /// whatever it was running before (or nothing, if it never built).
148    ///
149    /// Also in-memory: on the next boot the artifact either exists — and the
150    /// stamp says whether it is stale — or it does not. Persisting a failure
151    /// would mean showing a user an error about a build they may since have
152    /// fixed.
153    Failed,
154}
155
156impl BuildState {
157    /// The view's BUILD cell.
158    pub fn label(&self) -> &'static str {
159        match self {
160            BuildState::NotBuilt => "—",
161            BuildState::Cached => "cached",
162            BuildState::Stale => "stale",
163            BuildState::Building => "building…",
164            BuildState::Failed => "build-failed",
165        }
166    }
167}