Skip to main content

lattice_plugin_host/
dashboard_host.rs

1//! The `dashboard` guest→host section seam (CR.4).
2//!
3//! Design:
4//! [`contributable-registries.md`](../../../docs/dev/architecture/contributable-registries.md)
5//! §3.2, and the `dashboard` WIT interface.
6//!
7//! A plugin implementing `dashboard-plugin` declares its section ids once at
8//! load (`register-dashboard-sections`) and renders them on demand
9//! (`render-section`). The host wraps each declared id in a
10//! [`WasmDashboardSection`], which IS a [`lattice_dashboard::DashboardSection`]
11//! — so the registry's ordering, the `dashboard.sections` selection and the
12//! compositor treat a plugin section and a built-in identically, which is what
13//! the trait was written for.
14//!
15//! ## Sync, and holding a live `Store`
16//!
17//! Unlike `help` — which crosses its data once and drops the guest — a
18//! section is a function of a `DashboardCtx` the guest cannot know at load, so
19//! the instance stays alive for the editor's lifetime. `render` takes `&self`
20//! (the trait's shape, because the registry shares sections behind `Arc`),
21//! and a `Store` needs `&mut`, so the store sits behind a `Mutex`. Contention
22//! is nil in practice: composition happens on the actor thread, one section at
23//! a time.
24//!
25//! ## Guest output is untrusted
26//!
27//! Every row is validated host-side and dropped on failure, never trapped on.
28//! A trap poisons the section — it renders nothing further this session — and
29//! the rest of the page still composes, the `WasmErrorParser` contract
30//! verbatim. A plugin that breaks must cost its own block, not the launch
31//! page.
32
33use std::sync::Mutex;
34
35use lattice_dashboard::{
36    Align, DashboardCtx, DashboardFragment, DashboardRole, DashboardRow, DashboardSpan, LinkTarget,
37};
38
39use crate::{Component, PluginBudget, PluginHost, PluginHostError, PluginManifest, TrustTier};
40
41pub(crate) mod bindings {
42    wasmtime::component::bindgen!({
43        world: "dashboard-plugin",
44        path: "../lattice-wit/wit",
45        // Sync exports — see the module docs. `render-section` runs inside
46        // the dashboard compositor on the actor thread and must not suspend.
47        with: {
48            "lattice:plugin-host/logging": crate::lattice::plugin_host::logging,
49            "lattice:plugin-host/project": crate::lattice::plugin_host::project,
50        },
51    });
52}
53
54use bindings::lattice::plugin_host::dashboard as wit;
55
56/// How many rows a single guest section may contribute.
57///
58/// Not a safety limit — fuel already bounds the guest's *time*. This bounds
59/// the output of a guest that returns quickly with an absurd row count, which
60/// fuel does not catch and which would otherwise be composed into a buffer
61/// and painted. 512 is far above any plausible section (the largest built-in
62/// is under 20) and far below anything that would stall the compositor.
63const MAX_ROWS: usize = 512;
64
65/// What a guest declared during `register-dashboard-sections`.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct DashboardSectionSpec {
68    pub id: String,
69    pub order: i32,
70    pub default_enabled: bool,
71}
72
73/// Validate a declaration, or reject it.
74///
75/// The one rejection is an empty id: `dashboard.sections` addresses sections
76/// by id, and a section nothing can name is one the user can neither order
77/// nor disable.
78pub fn validate_section(
79    id: &str,
80    order: i32,
81    default_enabled: bool,
82) -> Result<DashboardSectionSpec, String> {
83    let id = id.trim();
84    if id.is_empty() {
85        return Err("register-section: id is empty; `dashboard.sections` \
86                    addresses sections by id, so an unnamed one cannot be \
87                    ordered or disabled"
88            .to_string());
89    }
90    Ok(DashboardSectionSpec {
91        id: id.to_string(),
92        order,
93        default_enabled,
94    })
95}
96
97fn role_from_wit(r: wit::Role) -> DashboardRole {
98    match r {
99        wit::Role::Logo => DashboardRole::Logo,
100        wit::Role::Cursor => DashboardRole::Cursor,
101        wit::Role::Title => DashboardRole::Title,
102        wit::Role::Tagline => DashboardRole::Tagline,
103        wit::Role::SectionHeading => DashboardRole::SectionHeading,
104        wit::Role::Body => DashboardRole::Body,
105        wit::Role::Key => DashboardRole::Key,
106        wit::Role::Hint => DashboardRole::Hint,
107        wit::Role::Link => DashboardRole::Link,
108    }
109}
110
111fn align_from_wit(a: wit::Align) -> Align {
112    match a {
113        wit::Align::Left => Align::Left,
114        wit::Align::Center => Align::Center,
115    }
116}
117
118/// Convert a guest link target, or drop it.
119///
120/// A `topic:`/`cmd:`/`url:` value that is blank would render as a live-looking
121/// link that silently does nothing on `<CR>`, which is worse than plain text —
122/// so the span keeps its label and loses the link rather than keeping a dead
123/// one.
124fn link_from_wit(t: wit::LinkTarget) -> Option<LinkTarget> {
125    let (value, build): (String, fn(String) -> LinkTarget) = match t {
126        wit::LinkTarget::Command(v) => (v, LinkTarget::Command),
127        wit::LinkTarget::Topic(v) => (v, LinkTarget::Topic),
128        wit::LinkTarget::Url(v) => (v, LinkTarget::Url),
129    };
130    let value = value.trim().to_string();
131    if value.is_empty() {
132        return None;
133    }
134    Some(build(value))
135}
136
137/// Convert a guest fragment to the native one, dropping what does not survive
138/// validation.
139///
140/// `plugin` is for the log lines only. Rejections are `debug!` rather than
141/// `warn!`: this runs once per section per compose, and a plugin with a
142/// systematically bad row would otherwise write a line every time the user
143/// opens the dashboard.
144pub fn fragment_from_wit(plugin: &str, f: wit::Fragment) -> DashboardFragment {
145    let mut out = DashboardFragment::new();
146    if f.rows.len() > MAX_ROWS {
147        tracing::debug!(
148            plugin,
149            rows = f.rows.len(),
150            cap = MAX_ROWS,
151            "dashboard section returned more rows than the cap; truncating"
152        );
153    }
154    for row in f.rows.into_iter().take(MAX_ROWS) {
155        // A row with no spans is a blank line to the compositor, which is a
156        // legitimate spacer — so it is kept, not dropped. Only spans are
157        // filtered.
158        let spans: Vec<DashboardSpan> = row
159            .spans
160            .into_iter()
161            .map(|s| DashboardSpan {
162                text: s.text,
163                role: role_from_wit(s.role),
164                link: s.link.and_then(link_from_wit),
165            })
166            .collect();
167        out.push(DashboardRow {
168            spans,
169            align: align_from_wit(row.align),
170        });
171    }
172    out
173}
174
175/// A plugin-backed dashboard section.
176///
177/// Holds its own `Store`, instantiated once at load and kept for the editor's
178/// lifetime — see the module docs for why this cannot be data.
179pub struct WasmDashboardSection {
180    inner: Mutex<SectionGuest>,
181    spec: DashboardSectionSpec,
182    plugin: String,
183    plugin_id: u64,
184    /// Re-armed before EVERY render. Fuel is a per-call budget, not a
185    /// per-instance one — see [`WasmDashboardSection::render`].
186    budget: PluginBudget,
187}
188
189struct SectionGuest {
190    store: wasmtime::Store<crate::PluginState>,
191    bindings: bindings::DashboardPlugin,
192    /// Set once the guest traps. A trapped component is dead until reloaded
193    /// (wasmtime offers no rollback), and continuing to call it would trap on
194    /// every compose for the rest of the session.
195    poisoned: bool,
196}
197
198impl std::fmt::Debug for WasmDashboardSection {
199    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200        f.debug_struct("WasmDashboardSection")
201            .field("plugin", &self.plugin)
202            .field("id", &self.spec.id)
203            .finish()
204    }
205}
206
207impl WasmDashboardSection {
208    /// The plugin this section came from — teardown removes by it.
209    pub fn plugin_name(&self) -> &str {
210        &self.plugin
211    }
212}
213
214impl lattice_dashboard::DashboardSection for WasmDashboardSection {
215    fn id(&self) -> &str {
216        &self.spec.id
217    }
218
219    fn order(&self) -> i32 {
220        self.spec.order
221    }
222
223    fn default_enabled(&self) -> bool {
224        self.spec.default_enabled
225    }
226
227    fn plugin_id(&self) -> Option<u64> {
228        Some(self.plugin_id)
229    }
230
231    fn render(&self, ctx: &DashboardCtx) -> DashboardFragment {
232        let Ok(mut guest) = self.inner.lock() else {
233            // A poisoned mutex means a previous render panicked. The editor
234            // stays up and this section stays blank; taking the page down
235            // over one plugin's block would be the wrong trade.
236            tracing::debug!(
237                plugin = %self.plugin,
238                id = %self.spec.id,
239                "dashboard section mutex poisoned; rendering nothing"
240            );
241            return DashboardFragment::new();
242        };
243        if guest.poisoned {
244            return DashboardFragment::new();
245        }
246        let wit_ctx = wit::Ctx {
247            pane_width: ctx.pane_width.min(u32::MAX as usize) as u32,
248            nerd_fonts: ctx.nerd_fonts,
249            version: ctx.version.clone(),
250        };
251        let SectionGuest {
252            store,
253            bindings,
254            poisoned,
255        } = &mut *guest;
256        // Re-arm the per-call budget. THIS IS NOT OPTIONAL and it is the one
257        // thing about this seam that differs from `help`: fuel is spent per
258        // call, and a section is called on every compose for the editor's
259        // lifetime. Arming once at instantiate (which is right for a
260        // declare-once seam like `config` or `help`) makes a section work for
261        // the first ~1000 composes and then trap on exhaustion — permanently,
262        // and with no cause a user could see. Every other repeated-call seam
263        // here does the same thing at the same point: `grammar_trampoline`,
264        // `completion_task`, `context_task`, `decoration_task`, `event_task`.
265        if let Err(e) = crate::arm_store(store, self.budget) {
266            *poisoned = true;
267            tracing::warn!(
268                plugin = %self.plugin,
269                id = %self.spec.id,
270                error = %e,
271                "could not re-arm a dashboard section's budget; it will render nothing further"
272            );
273            return DashboardFragment::new();
274        }
275        match bindings.call_render_section(&mut *store, &self.spec.id, &wit_ctx) {
276            Ok(fragment) => fragment_from_wit(&self.plugin, fragment),
277            Err(e) => {
278                *poisoned = true;
279                tracing::warn!(
280                    plugin = %self.plugin,
281                    id = %self.spec.id,
282                    error = %e,
283                    "dashboard section trapped; it will render nothing further this session"
284                );
285                DashboardFragment::new()
286            }
287        }
288    }
289}
290
291impl PluginHost {
292    /// Instantiate a `dashboard-plugin` component, drive its
293    /// `register-dashboard-sections` export once, and hand back one live
294    /// section per id it declared.
295    ///
296    /// Each returned section owns its own instance. Sharing one store across
297    /// several sections would serialise their renders behind a single mutex
298    /// and let a trap in one blank all the others — a plugin's sections
299    /// should fail independently, the way two plugins' do.
300    pub fn spawn_dashboard_sections(
301        &self,
302        component: &Component,
303        manifest: &PluginManifest,
304        tier: TrustTier,
305        budget: PluginBudget,
306    ) -> Result<(crate::PluginId, Vec<WasmDashboardSection>), PluginHostError> {
307        let id = self.alloc_id();
308        // The declaring instance. Its only job is to run the export and
309        // report the specs; the per-section instances below are what the
310        // registry keeps.
311        let mut declarer =
312            self.instantiate_dashboard_guest(component, manifest, tier, budget, id)?;
313        declarer
314            .bindings
315            .call_register_dashboard_sections(&mut declarer.store)
316            .map_err(|source| PluginHostError::Trap {
317                func: "register-dashboard-sections",
318                kind: crate::classify_trap(&source),
319                source: source.into(),
320            })?;
321        let specs = std::mem::take(&mut declarer.store.data_mut().dashboard_contributions);
322        drop(declarer);
323
324        let mut sections = Vec::with_capacity(specs.len());
325        for spec in specs {
326            let guest = self.instantiate_dashboard_guest(component, manifest, tier, budget, id)?;
327            sections.push(WasmDashboardSection {
328                inner: Mutex::new(guest),
329                spec,
330                plugin: manifest.id.clone(),
331                plugin_id: id.0 as u64,
332                budget,
333            });
334        }
335        Ok((id, sections))
336    }
337
338    /// Instantiate one `dashboard-plugin` guest against the **sync** linker.
339    ///
340    /// Named for grammar because grammar was its first user, but it is the
341    /// host's one sync import table — sync WASI plus the sync host funcs —
342    /// and instantiating against a superset of a world's imports is what the
343    /// multi-seam path already does.
344    fn instantiate_dashboard_guest(
345        &self,
346        component: &Component,
347        manifest: &PluginManifest,
348        tier: TrustTier,
349        budget: PluginBudget,
350        id: crate::PluginId,
351    ) -> Result<SectionGuest, PluginHostError> {
352        let (wasi, outcome, _data_dir) = self.build_plugin_wasi(manifest, tier);
353        for denied in &outcome.denied {
354            tracing::warn!(
355                plugin = %manifest.id,
356                capability = ?denied,
357                "dashboard plugin loaded with a withheld capability (reduced function)"
358            );
359        }
360        let mut store = self.new_store(wasi, outcome.grant, budget, Some(&manifest.id))?;
361        let bindings =
362            bindings::DashboardPlugin::instantiate(&mut store, component, &self.grammar_linker)
363                .map_err(|e| PluginHostError::Instantiate(e.into()))?;
364        store.data_mut().log_ctx = self.log_ctx_for(id);
365        crate::arm_store(&mut store, budget)?;
366        Ok(SectionGuest {
367            store,
368            bindings,
369            poisoned: false,
370        })
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    fn span(text: &str, link: Option<wit::LinkTarget>) -> wit::Span {
379        wit::Span {
380            text: text.to_string(),
381            role: wit::Role::Body,
382            link,
383        }
384    }
385
386    fn frag(rows: Vec<wit::Row>) -> wit::Fragment {
387        wit::Fragment { rows }
388    }
389
390    #[test]
391    fn a_well_formed_fragment_converts() {
392        let f = fragment_from_wit(
393            "p",
394            frag(vec![wit::Row {
395                spans: vec![
396                    span("Open ", None),
397                    span(
398                        ":tutor",
399                        Some(wit::LinkTarget::Command("tutor".to_string())),
400                    ),
401                ],
402                align: wit::Align::Center,
403            }]),
404        );
405        assert_eq!(f.rows.len(), 1);
406        assert_eq!(f.rows[0].text(), "Open :tutor");
407        assert_eq!(f.rows[0].align, Align::Center);
408        assert_eq!(
409            f.rows[0].spans[1].link,
410            Some(LinkTarget::Command("tutor".into()))
411        );
412    }
413
414    /// A blank link value would render as a live-looking link whose `<CR>`
415    /// does nothing. The label survives; the dead link does not.
416    #[test]
417    fn a_blank_link_target_is_dropped_but_the_label_stays() {
418        let f = fragment_from_wit(
419            "p",
420            frag(vec![wit::Row {
421                spans: vec![span(
422                    "dead",
423                    Some(wit::LinkTarget::Topic("   ".to_string())),
424                )],
425                align: wit::Align::Left,
426            }]),
427        );
428        assert_eq!(f.rows[0].spans[0].text, "dead");
429        assert!(f.rows[0].spans[0].link.is_none());
430    }
431
432    /// An empty row is a spacer, which sections legitimately use — it must
433    /// survive the filter that drops bad spans.
434    #[test]
435    fn an_empty_row_is_kept_as_a_spacer() {
436        let f = fragment_from_wit(
437            "p",
438            frag(vec![
439                wit::Row {
440                    spans: vec![],
441                    align: wit::Align::Left,
442                },
443                wit::Row {
444                    spans: vec![span("after", None)],
445                    align: wit::Align::Left,
446                },
447            ]),
448        );
449        assert_eq!(f.rows.len(), 2);
450        assert_eq!(f.rows[0].text(), "");
451        assert_eq!(f.rows[1].text(), "after");
452    }
453
454    /// Fuel bounds the guest's time, not its output. A guest that returns
455    /// quickly with an absurd row count is what this catches.
456    #[test]
457    fn a_fragment_over_the_row_cap_is_truncated() {
458        let rows = (0..MAX_ROWS + 10)
459            .map(|_| wit::Row {
460                spans: vec![span("x", None)],
461                align: wit::Align::Left,
462            })
463            .collect();
464        let f = fragment_from_wit("p", frag(rows));
465        assert_eq!(f.rows.len(), MAX_ROWS);
466    }
467
468    #[test]
469    fn every_role_maps() {
470        for (w, native) in [
471            (wit::Role::Logo, DashboardRole::Logo),
472            (wit::Role::Cursor, DashboardRole::Cursor),
473            (wit::Role::Title, DashboardRole::Title),
474            (wit::Role::Tagline, DashboardRole::Tagline),
475            (wit::Role::SectionHeading, DashboardRole::SectionHeading),
476            (wit::Role::Body, DashboardRole::Body),
477            (wit::Role::Key, DashboardRole::Key),
478            (wit::Role::Hint, DashboardRole::Hint),
479            (wit::Role::Link, DashboardRole::Link),
480        ] {
481            assert_eq!(role_from_wit(w), native);
482        }
483    }
484
485    #[test]
486    fn a_section_with_no_id_is_rejected() {
487        assert!(validate_section("", 0, true).is_err());
488        assert!(validate_section("   ", 0, true).is_err());
489        assert_eq!(
490            validate_section(" recent ", 5, false).expect("accepted"),
491            DashboardSectionSpec {
492                id: "recent".to_string(),
493                order: 5,
494                default_enabled: false,
495            }
496        );
497    }
498}