Skip to main content

lattice_plugin_loader/
install.rs

1//! PL8.A/B — the crate-owned `install(boot)` entry point.
2//!
3//! Wiring the loader into the editor is **one line** in the host's Phase-B
4//! install list (`lattice_plugin_loader::install(&mut boot)`) and zero host
5//! internals — the mode-ownership acid test: no `Editor::` method, no host
6//! `Action` variant. `install` stands the runtime up, captures the editor
7//! environment (runtime handle, event bus, the runtime-mutable picker registry,
8//! the provenance sink) from the generic `SubsystemBoot` seams, registers the
9//! [`PluginLoaderHandle`] service, and spawns on-disk discovery **off the boot
10//! thread** so no plugin cold-start delays boot.
11
12use std::sync::Arc;
13
14use lattice_config::ConfigRegistry;
15use lattice_mode::{PluginMetaSinkHandle, SubsystemBoot};
16use lattice_picker::PickerRegistryHandle;
17use lattice_plugin_host::{
18    PluginHost, PluginTracePushed, PluginTracer, PluginTracerHandle, TraceLevel, TrustTier,
19};
20use lattice_protocol::{Event, EventKind};
21use lattice_runtime::{EventFilter, SubscriptionTarget};
22
23use std::sync::atomic::{AtomicBool, Ordering};
24
25use crate::{LoaderServices, PluginLoader, PluginLoaderHandle};
26
27/// OC.2: the concrete timer behind the `wake-every` seam.
28///
29/// It lives here rather than in `lattice-plugin-host` because that crate keeps
30/// `tokio` a dev-dependency on purpose — `futures` was picked over `tokio::sync`
31/// so the lib owns no runtime and every actor is spawned by its caller. This
32/// crate IS that caller, so the executor dependency lands where the executor
33/// already is.
34struct TokioSleeper;
35
36impl lattice_plugin_host::Sleeper for TokioSleeper {
37    fn sleep(&self, dur: std::time::Duration) -> futures::future::BoxFuture<'static, ()> {
38        Box::pin(tokio::time::sleep(dur))
39    }
40}
41
42/// Process-global opt-**in** to boot-time plugin auto-discovery, set by
43/// [`enable_autoload`]. Checked (together with the
44/// `LATTICE_DISABLE_PLUGIN_AUTOLOAD` env var, which still overrides) in
45/// [`install`].
46///
47/// ## Why opt-in, and what it cost to learn
48///
49/// This was an opt-**out** (`disable_autoload`), and 42 of the 45 test files
50/// that boot a real `Editor` never called it. Every one of them silently
51/// loaded the developer's real `~/.config/lattice/plugins/` — so a test's
52/// behaviour depended on which plugins the person running it happened to have
53/// installed, and CI (with an empty home) and a laptop disagreed.
54///
55/// It is not a hypothetical: `lsp_async_wake.rs` asserts that no
56/// `async_landed` wake fires within a second of settling, and a real plugin
57/// load fires one. The test passed for everyone until an org plugin was
58/// installed, then failed on that machine only — the shape that costs an
59/// afternoon, because nothing in the failure points at the cause.
60///
61/// The fix has to be structural rather than 42 edits: a per-file opt-out is a
62/// thing the 43rd file forgets, and its absence does not announce itself. So
63/// the default is now sealed and production opts in.
64///
65/// **The trade this accepts:** if the [`enable_autoload`] call is ever lost
66/// from the binary's startup, a shipped editor loads no plugins. That is a
67/// loud failure — the first run shows it — where the one it replaces was
68/// silent and machine-dependent.
69static AUTOLOAD_ENABLED: AtomicBool = AtomicBool::new(false);
70
71/// Turn on boot-time plugin auto-discovery for this process.
72///
73/// **Called by the binary's startup, once, before any `Editor::boot`.** Every
74/// other embedder — tests, benches, tools — gets a sealed editor by default
75/// and has to say otherwise.
76pub fn enable_autoload() {
77    AUTOLOAD_ENABLED.store(true, Ordering::Relaxed);
78}
79
80/// Write out every plugin's store before the process exits.
81///
82/// **Called by the binary, after its UI returns.** A store is otherwise
83/// written only when it changes, at most once a second, so the last second of
84/// changes would be lost on exit: `Drop` never runs for a store whose handle a
85/// task on a `static` runtime still holds.
86pub fn flush_plugin_stores() {
87    lattice_plugin_host::plugin_store::flush_all();
88}
89
90/// Suppress boot-time plugin auto-discovery for this process.
91///
92/// Now that the default is sealed this is only meaningful *after*
93/// [`enable_autoload`] — a binary turning discovery back off, or a test in a
94/// process that enabled it. Kept because it is the honest inverse and because
95/// existing callers state their intent, which is worth reading even where it
96/// is a no-op.
97pub fn disable_autoload() {
98    AUTOLOAD_ENABLED.store(false, Ordering::Relaxed);
99}
100
101/// Whether boot-time auto-discovery is currently on. The env override is
102/// deliberately NOT consulted: this reports the latch, which is what a caller
103/// setting it wants to confirm, and what `autoload_is_opt_in.rs` pins.
104pub fn autoload_enabled() -> bool {
105    AUTOLOAD_ENABLED.load(Ordering::Relaxed)
106}
107
108fn autoload_disabled() -> bool {
109    !AUTOLOAD_ENABLED.load(Ordering::Relaxed)
110        || std::env::var_os("LATTICE_DISABLE_PLUGIN_AUTOLOAD").is_some()
111}
112
113pub fn install(boot: &mut impl SubsystemBoot) {
114    let host = match PluginHost::new() {
115        Ok(host) => Arc::new(host),
116        Err(err) => {
117            tracing::warn!(
118                error = %err,
119                "plugin host unavailable; the editor runs without plugin support"
120            );
121            return;
122        }
123    };
124
125    // PO.1 (plugin observability): stand the boundary tracer up — its publisher
126    // bound to the runtime bus so every appended `PluginTraceRecord` streams as a
127    // typed `PluginTracePushed` event (the `LspLogger` boot-wiring precedent). The
128    // seams emit into it (PO.2/PO.3, via `LoaderServices.tracer`); the trace-buffer
129    // views subscribe (PO.4). Off the hot path by contract — this only wires it.
130    let tracer: PluginTracerHandle = Arc::new(PluginTracer::with_defaults());
131    let trace_bus = boot.event_bus().clone();
132    tracer.set_event_publisher(Box::new(move |record| {
133        trace_bus.publish_typed(PluginTracePushed { record });
134    }));
135    // PO.5: hand the tracer to the host so each instantiate/spawn stamps the
136    // plugin's `log_ctx` and the guest `logging` seam (Layer 2) routes into the
137    // same ring as the boundary trace.
138    host.set_tracer(tracer.clone());
139    // PR.6: hand the host what the guest `project` seam answers from. Both
140    // halves are required — the resolver turns a path into a project, the
141    // buffer store turns a `buffer` id into that path — so an absent either
142    // leaves the seam answering `none` rather than half-answering.
143    // CG.4: hand the host the foreground-cancel registry so `<C-g>`
144    // interrupts a running guest call, not just the next one.
145    // OC.3 / ML.6: hand the host what a plugin's modeline calls act on. Both
146    // halves together — a registry with no bus lets a plugin register a
147    // descriptor and push content that nothing ever repaints, which is
148    // half-wired in the exact way this seam is most likely to be.
149    if let Some(modeline) = boot.service::<lattice_mode::ModelineServiceHandle>() {
150        host.set_modeline((*modeline).clone(), boot.event_bus().clone());
151    } else {
152        tracing::debug!("modeline seam unwired: plugin segments will not register");
153    }
154    // OC.2: hand the host a timer for the `wake-every` seam. `lattice-plugin-host`
155    // owns no runtime by design, so it cannot make one — this crate, which spawns
156    // every actor, is where an executor is actually in scope.
157    host.set_sleeper(Arc::new(TokioSleeper));
158    if let Some(cancel) = boot.service::<lattice_mode::ForegroundCancelHandle>() {
159        host.set_foreground_cancel((*cancel).clone());
160    } else {
161        tracing::debug!("foreground-cancel unwired: plugin calls run to their budget");
162    }
163    if let (Some(resolver), Some(buffers)) = (
164        boot.service::<lattice_core::ProjectResolverHandle>(),
165        boot.service::<lattice_mode::BufferStoreHandle>(),
166    ) {
167        host.set_project_context((*resolver).clone(), (*buffers).clone());
168    } else {
169        tracing::debug!("project seam unwired: no resolver or buffer store at plugin install");
170    }
171    // CD.6b: the store `clamp-position` measures. Its own slot rather than
172    // `project`'s, so whether a buffer exists does not depend on whether
173    // project resolution was wired.
174    if let Some(buffers) = boot.service::<lattice_mode::BufferStoreHandle>() {
175        host.set_buffer_store((*buffers).clone());
176    } else {
177        tracing::debug!("clamp-position unwired: no buffer store at plugin install");
178    }
179    // OA.23: what answers "which file did this composed line come from".
180    //
181    // The registry alone: it maps a view's composed line to a source document,
182    // and a source carries its own path. Unwired leaves `excerpt-source`
183    // answering `none`, which is what a host with no multibuffers should say.
184    if let Some(views) = boot.service::<lattice_multibuffer::MultibufferRegistryHandle>() {
185        host.set_excerpt_source_resolver(std::sync::Arc::new(
186            lattice_multibuffer::registry::MultibufferExcerptSource::new((*views).clone()),
187        ));
188    } else {
189        tracing::debug!("excerpt-source seam unwired: no multibuffer registry");
190    }
191    // OA.27: what answers "what is this provider view showing".
192    //
193    // The per-view scan state, which already holds the arguments the view was
194    // opened with and carries them across every re-open. Unwired leaves
195    // `view-args` answering an empty list — and a guest reads that as a fresh
196    // view, so every chord that walks a view starts over from the default with
197    // no error anywhere. That silence is why this wiring has a boot pin.
198    if let Some(scan_views) =
199        boot.service::<lattice_multibuffer::providers::scan_view::ScanViewServiceHandle>()
200    {
201        host.set_view_args_resolver(std::sync::Arc::new(
202            lattice_multibuffer::providers::scan_view::ScanViewArgs::new((*scan_views).clone()),
203        ));
204    } else {
205        tracing::debug!("view-args seam unwired: no scan-view service");
206    }
207    // OA.30: the counter `refresh-decorations` bumps. Without it a guest
208    // decoration producer is asked once and cached forever — its answer never
209    // repaints, with no error anywhere, which reads as a broken feature rather
210    // than a missing wire.
211    if let Some(epoch) = boot.service::<lattice_mode::DecorationEpochHandle>() {
212        host.set_decoration_epoch((*epoch).clone());
213    } else {
214        tracing::debug!("refresh-decorations unwired: no decoration epoch service");
215    }
216
217    // Capture the editor environment from the generic boot seams. `service`
218    // returns `Arc<Handle-alias>` (double-Arc); unwrap one layer to the handle.
219    let services = LoaderServices {
220        media_registry: boot
221            .service::<lattice_mode::MediaSourceRegistryHandle>()
222            .map(|h| (*h).clone()),
223        agenda_registry: boot
224            .service::<lattice_mode::ScannedExcerptSourceRegistryHandle>()
225            .map(|h| (*h).clone()),
226        // CM.2: absent only in harnesses that wire no keymap. A plugin that
227        // declares an operator chord then fails its load loudly rather than
228        // registering an operator nobody can press.
229        operator_chords: boot
230            .service::<lattice_mode::OperatorChordWirerHandle>()
231            .map(|h| (*h).clone()),
232        runtime: Some(boot.runtime_handle().clone()),
233        bus: Some(boot.event_bus().clone()),
234        // MV.1: where a plugin's declared views register their openers, and
235        // where their excerpts land once `build` answers.
236        provider_view_registry: boot
237            .service::<lattice_mode::ProviderViewRegistryHandle>()
238            .map(|h| (*h).clone()),
239        multibuffer_registry: boot
240            .service::<lattice_multibuffer::registry::MultibufferRegistryHandle>()
241            .map(|h| (*h).clone()),
242        picker_registry: boot.service::<PickerRegistryHandle>().map(|h| (*h).clone()),
243        config_registry: boot.service::<Arc<ConfigRegistry>>().map(|h| (*h).clone()),
244        command_registry: boot
245            .service::<lattice_grammar::CommandRegistryHandle>()
246            .map(|h| (*h).clone()),
247        mode_registry: boot
248            .service::<lattice_mode::ModeRegistryHandle>()
249            .map(|h| (*h).clone()),
250        keymap: boot
251            .service::<lattice_keymap::KeymapHandle>()
252            .map(|h| (*h).clone()),
253        meta_sink: boot.service::<PluginMetaSinkHandle>().map(|h| (*h).clone()),
254        decoration_registry: boot
255            .service::<lattice_mode::GutterDecorationSourceRegistryHandle>()
256            .map(|h| (*h).clone()),
257        context_registry: boot
258            .service::<lattice_mode::ContextSourceRegistryHandle>()
259            .map(|h| (*h).clone()),
260        theme_registry: boot
261            .service::<lattice_theme::ThemeRegistryHandle>()
262            .map(|h| (*h).clone()),
263        // SG.3a: registered by `editor_boot` beside the theme registry, and
264        // for the same reason it must be captured HERE — the loader captures
265        // its drain services at install time, so a `signs` plugin's
266        // definitions have nowhere to land if the service is registered after
267        // this line. `plugin_loader_captures_every_drain_service` is the boot
268        // pin that catches it.
269        sign_registry: boot
270            .service::<lattice_mode::SignRegistryHandle>()
271            .map(|h| (*h).clone()),
272        // OC.3 / ML.6: registered by `editor_boot` in Phase A, alongside the
273        // built-in element registration — well before this line.
274        modeline: boot
275            .service::<lattice_mode::ModelineServiceHandle>()
276            .map(|h| (*h).clone()),
277        // CM.6b: registered by `lattice_compilation::install`, which runs
278        // early in Phase B — well before this line.
279        parser_factories: boot
280            .service::<lattice_compilation::CompilationParserFactoriesHandle>()
281            .map(|h| (*h).clone()),
282        // CR.3: registered by the host in Phase A (the `builtin_topics()`
283        // hoist), so it is present well before this line.
284        help_topics: boot
285            .service::<lattice_help::topics::HelpTopicRegistryHandle>()
286            .map(|h| (*h).clone()),
287        // CR.4: registered by `lattice_dashboard::install`, which runs early
288        // in Phase B — well before this line.
289        dashboard_sections: boot
290            .service::<lattice_dashboard::DashboardRegistryHandle>()
291            .map(|h| (*h).clone()),
292        // TR.2b: registered by `editor_boot` in Phase A (TR.1 moved it there
293        // from magit precisely so it is present regardless of which feature
294        // crates loaded), so it is available well before this line.
295        transient_registry: boot
296            .service::<lattice_picker::TransientSourceRegistryHandle>()
297            .map(|h| (*h).clone()),
298        tracer: Some(tracer.clone()),
299    };
300    if services.picker_registry.is_none() {
301        // The host always registers the picker registry; its absence means a
302        // boot-order regression. Degrade to no plugin support, logged.
303        tracing::warn!("picker registry service missing; the editor runs without plugin support");
304        return;
305    }
306
307    let loader: PluginLoaderHandle = Arc::new(PluginLoader::with_services(host, services));
308    // Option A (PL8.C.2): the loader self-registers its `:plugin-load` /
309    // `:plugin-unload` / `:plugin-reload` ex-commands into the runtime-mutable
310    // command registry — zero host code, the full command surface owned by the
311    // loader crate.
312    loader.register_ex_commands();
313    // PL8.H.1: track plugin health for the manager view — a `PluginCrashed`
314    // subscription drained on the runtime flips a trapped plugin to quarantined.
315    loader.subscribe_health();
316    // PM.3: react to `<id>.enabled` toggles — activate/deactivate a core plugin's
317    // default mode live (`:set auto-pair.enabled=false`).
318    loader.subscribe_mode_gates();
319    boot.register_service::<PluginLoaderHandle>(loader.clone());
320
321    // PO.4.3: observe `:set plugin.trace-level=…` and push the new default into
322    // the tracer live — PO.3's republish then reaches every un-overridden hot gate
323    // on the next keystroke. Mechanism B (the dashboard `install_recompose_triggers`
324    // precedent): the subsystem that OWNS the tracer subscribes its own
325    // `OptionChanged` channel and name-filters, rather than adding a plugin arm to
326    // the host's option cascade (mode-ownership — the App stays a thin host). The
327    // event carries the new value as a string, so no `lattice-config` value-type
328    // coupling — `TraceLevel::parse` bridges (the labels match `PluginTraceLevel`).
329    let observer_tracer = tracer.clone();
330    let (option_tx, mut option_rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
331    boot.event_bus().subscribe(
332        EventFilter::kind(EventKind::OptionChanged),
333        SubscriptionTarget::Channel(option_tx),
334    );
335    boot.runtime_handle().spawn(async move {
336        while let Some(event) = option_rx.recv().await {
337            let Event::OptionChanged { name, new, .. } = event else {
338                continue;
339            };
340            if name != "plugin.trace-level" {
341                continue;
342            }
343            match TraceLevel::parse(&new) {
344                Some(level) => observer_tracer.set_default_level(level),
345                None => tracing::warn!(value = %new, "ignoring unparseable plugin.trace-level"),
346            }
347        }
348    });
349
350    // PO.1: register the tracer (built above) as a boot service so the
351    // trace-buffer views (PO.4) resolve it; the seams already hold it via
352    // `LoaderServices.tracer`.
353    boot.register_service::<PluginTracerHandle>(tracer);
354
355    // Headless / CI / test opt-out: `LATTICE_DISABLE_PLUGIN_AUTOLOAD` skips the
356    // boot-time filesystem discovery below (core plugins + init.rs + on-disk
357    // plugins), so a developer's real `~/.config/lattice` never leaks into a
358    // test editor and CI can boot deterministically. The loader service and its
359    // `:plugin-load` / `:plugin-unload` / `:plugin-reload` ex-commands are
360    // already wired above, so manual loading still works — only auto-discovery
361    // is suppressed.
362    if autoload_disabled() {
363        tracing::debug!("plugin auto-load disabled; skipping boot-time plugin discovery");
364        return;
365    }
366
367    // CI.2: `init.rs` loads FIRST, then on-disk plugins — in ONE task, off the
368    // boot thread. init.rs is config authority (`<config>/lattice/init/`, loaded
369    // with a boot-capability `Bundled` tier); it must register its
370    // `plugin-loaded` subscriptions BEFORE any plugin fires that event, so a
371    // deferred `on-plugin-loaded` handler can't miss its plugin
372    // (config-and-init.md §3). Both stay OFF the boot thread — a plugin
373    // cold-start must not delay boot; contributions appear a frame or two after
374    // boot (the eventual-consistency the UX contract permits). An absent init /
375    // plugins dir (the common case) is a benign skip.
376    let init_dir = crate::default_init_dir();
377    let plugins_dir = crate::default_plugins_dir();
378    // PM.1: the core-plugins root — prebuilt plugins that ship WITH lattice,
379    // discovered from a runtime search path (plugin-manager.md §7). `None` when no
380    // runtime root is present (a source checkout with no staged `runtime/`, etc.).
381    let core_plugins_dir = crate::default_core_plugins_dir();
382    // PL8.D.4: watch the init dir so a rebuilt `init.wasm` auto-reloads without a
383    // manual `:reload-config`. A no-op if the dir doesn't exist.
384    if let Some(ref dir) = init_dir {
385        crate::watch::spawn_init_watcher(loader.clone(), dir.clone(), boot.runtime_handle());
386    }
387    if core_plugins_dir.is_some() || init_dir.is_some() || plugins_dir.is_some() {
388        let loader = loader.clone();
389        boot.runtime_handle().spawn(async move {
390            // 0. CORE plugins first (prebuilt runtime root, `Bundled` tier) — PM.1.
391            //    They enable via a config gate (PM.3), NOT init.rs, so loading them
392            //    before init.rs is correct: a user init.rs `on-plugin-loaded`
393            //    handler targets USER plugins (step 2), which still load after it.
394            if let Some(core_dir) = core_plugins_dir {
395                let n = loader
396                    .discover_and_load(&core_dir, TrustTier::Bundled)
397                    .await;
398                if n > 0 {
399                    tracing::info!(
400                        count = n,
401                        dir = %core_dir.display(),
402                        "core plugins loaded from the runtime root"
403                    );
404                }
405            }
406            // 1. init.rs next — AWAITED, so its subscriptions are live before
407            //    step 2 loads plugins that fire `plugin-loaded`.
408            if let Some(init_dir) = init_dir {
409                // PM.7b: build init.rs before loading it. It is a
410                // `wasm32-wasip2` component like any other, so the PM.5
411                // service builds it — one build primitive, two callers
412                // (design §6). This is what removes the "run cargo by hand
413                // first" step: an edited init.rs rebuilds on the next boot,
414                // and an unchanged one is a pure load that never invokes a
415                // toolchain.
416                //
417                // Failure is a skip. A user whose init.rs stopped compiling
418                // must still get an editor — with their previous init.wasm if
419                // one exists (`StaleKept`), and without config if not.
420                build_init_if_needed(&init_dir).await;
421                match loader.load_path(&init_dir, TrustTier::Bundled).await {
422                    Ok(id) => tracing::info!(
423                        id = id.0,
424                        dir = %init_dir.display(),
425                        "user init.rs config loaded"
426                    ),
427                    // WT.4: **this arm was the silent failure.** One `debug!`
428                    // covered two entirely different situations — "the user has
429                    // no init.rs", which is normal and uninteresting, and "the
430                    // user has an init.rs and it would not load", which is the
431                    // most consequential thing that can happen at boot: init.rs
432                    // holds the `require` that installs and rebuilds every other
433                    // plugin, so when it dies nothing else loads either. The
434                    // editor opened, everything was absent, and this line said
435                    // it at a level nobody sees.
436                    //
437                    // A `plugin.toml` in the init dir is what tells them apart:
438                    // if one is there the user meant to have config, so its
439                    // absence is a failure they need to be told about.
440                    Err(err) if init_dir.join("plugin.toml").is_file() => tracing::warn!(
441                        dir = %init_dir.display(),
442                        error = %err,
443                        "user init.rs failed to load — plugins it requires will not install; \
444                         `lattice --wit-sync` then restart if the plugin API has changed"
445                    ),
446                    Err(err) => tracing::debug!(
447                        dir = %init_dir.display(),
448                        error = %err,
449                        "no user init.rs loaded"
450                    ),
451                }
452                // PM.7b: whatever init.rs `require`d is now queued. Resolve,
453                // build and load it BEFORE step 2's on-disk scan, so a plugin
454                // that was just installed into the user root is discovered by
455                // that scan rather than waiting for the next boot.
456                install_required_plugins(&loader).await;
457            }
458            // 2. Then the plugins the init.rs handlers react to.
459            if let Some(dir) = plugins_dir {
460                let n = loader
461                    .discover_and_load(&dir, TrustTier::UserInstalled)
462                    .await;
463                if n > 0 {
464                    tracing::info!(count = n, dir = %dir.display(), "plugins loaded from disk");
465                }
466            }
467        });
468    }
469}
470
471/// PM.7b: build the user's `init.rs` in place, returning the [`BuildOutcome`] —
472/// or `None` when the directory holds no cargo project (the common case is a
473/// hand-built `init.wasm` dropped in place, which must keep working, so this
474/// returns `None` rather than an error and the caller loads the artifact as-is).
475///
476/// This is the reusable core the boot build ([`build_init_if_needed`]),
477/// `:reload-config`, and the plugins view's rebuild-of-`init` all share, so all
478/// three compile source → artifact identically. Returning the outcome (rather
479/// than logging and discarding it, as the boot-only helper used to) is what lets
480/// `:reload-config` surface the compiler error to the user instead of leaving it
481/// in a log they were not watching.
482///
483/// The build stages into the same directory the loader then discovers, so
484/// nothing downstream needs to know a build happened.
485///
486/// Runs on `spawn_blocking`: a cold component build is seconds to minutes, and
487/// every caller is either the boot task or an off-keystroke reload task, both of
488/// which share the async runtime with the editor (paramount goal #1 / #4).
489pub(crate) async fn build_init(init_dir: &std::path::Path) -> Option<crate::build::BuildOutcome> {
490    if !init_dir.join("Cargo.toml").is_file() {
491        tracing::debug!(
492            dir = %init_dir.display(),
493            "init dir is not a cargo project; loading any prebuilt init.wasm as-is"
494        );
495        return None;
496    }
497    let dir = init_dir.to_path_buf();
498    match tokio::task::spawn_blocking(move || {
499        let parent = dir.parent().map(|p| p.to_path_buf()).unwrap_or_default();
500        crate::build::build_plugin(
501            &crate::build::CargoComponentBuilder,
502            &dir,
503            "init",
504            &parent,
505            false,
506        )
507    })
508    .await
509    {
510        Ok(outcome) => Some(outcome),
511        Err(e) => {
512            tracing::warn!(error = %e, "init.rs build task failed to run");
513            Some(crate::build::BuildOutcome::Failed {
514                error: format!("init.rs build task failed to run: {e}"),
515            })
516        }
517    }
518}
519
520/// PM.7b: build the user's `init.rs` if its source changed (the boot path).
521///
522/// A thin wrapper over [`build_init`] that logs and discards the outcome — at
523/// boot there is no user watching a `*messages*` echo, and a build failure
524/// falls back to the previous artifact regardless.
525async fn build_init_if_needed(init_dir: &std::path::Path) {
526    let Some(outcome) = build_init(init_dir).await else {
527        return;
528    };
529    match outcome.error() {
530        Some(error) => tracing::warn!(
531            dir = %init_dir.display(),
532            %error,
533            "init.rs build failed; using the previous build if there is one"
534        ),
535        None => tracing::debug!(dir = %init_dir.display(), "init.rs is current"),
536    }
537}
538
539/// PM.7b: resolve, build and load everything `init.rs` declared via `require`.
540///
541/// Each spec is independent: one broken source costs that plugin and nothing
542/// else. The whole pipeline runs on `spawn_blocking` — it clones, downloads
543/// and compiles — and only the final load returns to the async context.
544async fn install_required_plugins(loader: &std::sync::Arc<crate::PluginLoader>) {
545    let specs = loader.take_required();
546    if specs.is_empty() {
547        return;
548    }
549    let Some(user_root) = crate::default_plugins_dir() else {
550        tracing::warn!("require: no config dir; cannot install declared plugins");
551        return;
552    };
553    let cache_root = crate::default_source_cache_dir();
554    tracing::info!(
555        count = specs.len(),
556        "installing plugins declared by init.rs"
557    );
558
559    let installs = match tokio::task::spawn_blocking({
560        let user_root = user_root.clone();
561        move || {
562            crate::pipeline::install_all(
563                &crate::resolve::SystemGit,
564                &crate::resolve::HttpFetcher,
565                &crate::build::CargoComponentBuilder,
566                &specs,
567                &cache_root,
568                &user_root,
569                // Boot uses what the user already has: deterministic, works
570                // offline, no network round trip per start. `:plugin-update`
571                // is the verb that goes looking for something newer.
572                crate::resolve::RefreshPolicy::UseCache,
573            )
574        }
575    })
576    .await
577    {
578        Ok(v) => v,
579        Err(e) => {
580            tracing::warn!(error = %e, "require: install task failed to run");
581            return;
582        }
583    };
584
585    for install in installs {
586        match install {
587            crate::pipeline::Install::Ready {
588                name,
589                stale,
590                enable_mode,
591                ..
592            } => {
593                if let Some(error) = stale {
594                    tracing::warn!(
595                        plugin = %name,
596                        %error,
597                        "plugin is running a previous build (rebuild failed)"
598                    );
599                }
600                // The artifact is staged in the user root; load it through the
601                // ordinary discovery path so a `require`d plugin and a
602                // hand-installed one take exactly the same route in.
603                let dir = user_root.join(&name);
604                match loader.load_path(&dir, TrustTier::UserInstalled).await {
605                    Ok(id) => {
606                        tracing::info!(plugin = %name, id = id.0, "required plugin loaded");
607                        // use-package's `enable-mode` sugar. Requested only
608                        // AFTER a successful load: asking to enable a mode
609                        // belonging to a plugin that failed to load would be
610                        // a request nothing can satisfy.
611                        if let Some(mode) = enable_mode {
612                            loader.request_mode_enablement(&mode);
613                        }
614                    }
615                    Err(err) => tracing::warn!(
616                        plugin = %name,
617                        error = %err,
618                        "required plugin built but failed to load"
619                    ),
620                }
621            }
622            crate::pipeline::Install::Skipped { name, error } => tracing::warn!(
623                plugin = %name,
624                %error,
625                "required plugin skipped"
626            ),
627        }
628    }
629}