Skip to main content

lattice_plugin_loader/
pipeline.rs

1//! PM.7: the resolve → build → stage pipeline behind a `require`.
2//!
3//! Design: [`plugin-manager.md`](../../../docs/dev/architecture/plugin-manager.md)
4//! §2, §5, §6. The host records `require` specs during a guest's
5//! `register-plugins` export (`lattice_plugin_host::plugin_manager_host`) and
6//! drains them afterwards; each drained spec comes here.
7//!
8//! The body is short because PM.5 and PM.6 are the parts with behaviour:
9//! [`crate::resolve`] turns a source into either a tree or a finished
10//! artifact, and [`crate::build_plugin`] turns a tree into a cached artifact.
11//! A `Prebuilt` short-circuits the build entirely — that is the whole reason
12//! `Resolved` is an enum.
13//!
14//! What this module adds is the **failure policy**. Every step can fail for
15//! reasons outside the editor's control (no network, no toolchain, a bad
16//! revision), and none of them may take the editor down:
17//!
18//! - a spec that fails to resolve or build becomes [`Install::Skipped`],
19//!   logged and reportable, and the *next* spec is still attempted;
20//! - a stale rebuild that fails still yields an artifact
21//!   ([`crate::BuildOutcome::StaleKept`]) and is installed, carrying its
22//!   error forward so `:plugins` can show that it is running old code.
23//!
24//! Blocking throughout — clone, download, compile. Callers run it on
25//! `spawn_blocking` (paramount goal #1 / #4).
26
27use std::path::{Path, PathBuf};
28
29use crate::build::{BuildOutcome, ComponentBuilder, build_plugin};
30use crate::resolve::{Fetcher, GitRunner, PluginSource, RefreshPolicy, Resolved, resolve};
31
32/// One plugin a guest declared. The loader-side mirror of the host's
33/// `RequiredPlugin`.
34///
35/// Mirrored rather than shared because the dependency runs `loader → host`,
36/// and a WIT-facing type that changed shape when a loader refactor touched it
37/// would break a public API on an internal edit. The conversion is one `match`
38/// at the boot call site.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct RequiredSpec {
41    pub name: String,
42    pub source: PluginSource,
43    /// Carried opaquely to the mode-enable step; the loader never interprets
44    /// it (`feedback_mode_owns_its_surface`).
45    pub enable_mode: Option<String>,
46    pub pinned: bool,
47}
48
49/// Convert a host-side `RequiredPlugin` into the loader's mirror.
50///
51/// The one `match` the mirroring costs. Worth it: without it, the WIT-facing
52/// type and the loader's would be the same type, and a loader refactor could
53/// reshape a published plugin API.
54pub fn to_required_spec(
55    p: lattice_plugin_host::plugin_manager_host::RequiredPlugin,
56) -> RequiredSpec {
57    use lattice_plugin_host::plugin_manager_host::RequiredSource as Host;
58    RequiredSpec {
59        name: p.name,
60        source: match p.source {
61            // `~` expands here, at the boundary where a user's string becomes
62            // a path. `init.rs` is meant to be portable — the same file on a
63            // Mac and a Linux box — and without this the only way to name a
64            // local checkout is one machine's absolute path. Every other path a
65            // user writes already takes `~`; this is the plugin source catching
66            // up with that, not a new convention.
67            Host::Local(path) => {
68                PluginSource::Local(PathBuf::from(lattice_core::home::expand_tilde(&path)))
69            }
70            Host::Git { url, rev } => PluginSource::Git { url, rev },
71            Host::Prebuilt { url } => PluginSource::Prebuilt { url },
72        },
73        enable_mode: p.enable_mode,
74        pinned: p.pinned,
75    }
76}
77
78/// What happened to one required plugin.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum Install {
81    /// There is an artifact to load.
82    Ready {
83        name: String,
84        artifact: PathBuf,
85        enable_mode: Option<String>,
86        /// Set when the artifact is **stale** — a rebuild failed and the
87        /// previous build is what will load. Not a failure; a caveat the
88        /// user should be able to see.
89        stale: Option<String>,
90    },
91    /// Nothing loadable. The plugin is absent this session.
92    Skipped { name: String, error: String },
93}
94
95impl Install {
96    pub fn name(&self) -> &str {
97        match self {
98            Install::Ready { name, .. } | Install::Skipped { name, .. } => name,
99        }
100    }
101}
102
103/// Resolve, build and stage one required plugin.
104///
105/// Blocking. Run on `spawn_blocking`.
106pub fn install_required(
107    git: &dyn GitRunner,
108    fetcher: &dyn Fetcher,
109    builder: &dyn ComponentBuilder,
110    spec: &RequiredSpec,
111    cache_root: &Path,
112    user_root: &Path,
113    policy: RefreshPolicy,
114) -> Install {
115    let skip = |error: String| Install::Skipped {
116        name: spec.name.clone(),
117        error,
118    };
119
120    let resolved = match resolve(
121        git,
122        fetcher,
123        &spec.source,
124        &spec.name,
125        cache_root,
126        user_root,
127        policy,
128    ) {
129        Ok(r) => r,
130        Err(e) => {
131            tracing::warn!(plugin = %spec.name, error = %e, "require: resolve failed; skipping");
132            return skip(e);
133        }
134    };
135
136    let source_dir = match resolved {
137        // A prebuilt is already the artifact — no toolchain is consulted at
138        // all, which is the point of the kind.
139        Resolved::Artifact(artifact) => {
140            // PM.8a: remember where it came from, so the view can say so and
141            // a later re-download knows the URL.
142            if let Some(dir) = artifact.parent() {
143                crate::source_record::write(dir, &spec.source);
144            }
145            return Install::Ready {
146                name: spec.name.clone(),
147                artifact,
148                enable_mode: spec.enable_mode.clone(),
149                stale: None,
150            };
151        }
152        Resolved::Source(dir) => dir,
153    };
154
155    let outcome = build_plugin(builder, &source_dir, &spec.name, user_root, spec.pinned);
156    // PM.8a: the marker goes beside the artifact whenever there IS one —
157    // including the stale-kept case, where knowing the source is exactly what
158    // lets the user retry the build that failed.
159    if let Some(artifact) = outcome.artifact()
160        && let Some(dir) = artifact.parent()
161    {
162        crate::source_record::write(dir, &spec.source);
163    }
164    match outcome {
165        BuildOutcome::Cached { artifact } | BuildOutcome::Fresh { artifact } => Install::Ready {
166            name: spec.name.clone(),
167            artifact,
168            enable_mode: spec.enable_mode.clone(),
169            stale: None,
170        },
171        BuildOutcome::StaleKept { artifact, error } => Install::Ready {
172            name: spec.name.clone(),
173            artifact,
174            enable_mode: spec.enable_mode.clone(),
175            stale: Some(error),
176        },
177        BuildOutcome::Failed { error } => {
178            tracing::warn!(plugin = %spec.name, error = %error, "require: build failed; skipping");
179            skip(error)
180        }
181    }
182}
183
184/// Run every required spec, in declaration order.
185///
186/// One spec's failure never stops the next: a user with five plugins and one
187/// broken source should lose that one, not the four that work.
188pub fn install_all(
189    git: &dyn GitRunner,
190    fetcher: &dyn Fetcher,
191    builder: &dyn ComponentBuilder,
192    specs: &[RequiredSpec],
193    cache_root: &Path,
194    user_root: &Path,
195    policy: RefreshPolicy,
196) -> Vec<Install> {
197    specs
198        .iter()
199        .map(|spec| install_required(git, fetcher, builder, spec, cache_root, user_root, policy))
200        .collect()
201}
202
203#[cfg(test)]
204mod tests {
205    #![allow(clippy::unwrap_used, clippy::panic)]
206    use super::*;
207
208    /// A `Local` plugin source expands a leading `~`.
209    ///
210    /// `init.rs` is a user's config and is meant to be portable — the same file
211    /// on a Mac and a Linux box, committed to a dotfiles repo. Without this the
212    /// only way to name a local checkout is an absolute path, so a config that
213    /// `require`s a plugin from disk names one machine's home directory and
214    /// silently fails to resolve anywhere else.
215    ///
216    /// Every other path a user writes in lattice already takes `~`
217    /// (`org.agenda-files = "~/org"`), so this is the plugin source catching up
218    /// with the convention rather than inventing one.
219    #[test]
220    fn a_local_source_expands_a_leading_tilde() {
221        use lattice_plugin_host::plugin_manager_host::{RequiredPlugin, RequiredSource};
222        let spec = to_required_spec(RequiredPlugin {
223            name: "org".into(),
224            source: RequiredSource::Local("~/src/lattice-org-plugin".into()),
225            enable_mode: None,
226            pinned: false,
227        });
228        let PluginSource::Local(path) = spec.source else {
229            panic!("a Local source stays Local");
230        };
231        let home = dirs::home_dir().expect("a home directory");
232        assert_eq!(
233            path,
234            home.join("src/lattice-org-plugin"),
235            "`~` must expand against the user's home, not be taken literally"
236        );
237    }
238
239    /// An absolute path is untouched, and `~user` is left alone rather than
240    /// mangled into `<home>user` — a plausible path to the wrong place.
241    #[test]
242    fn a_local_source_leaves_absolute_and_tilde_user_paths_alone() {
243        use lattice_plugin_host::plugin_manager_host::{RequiredPlugin, RequiredSource};
244        for raw in ["/opt/plugins/org", "~someone/org"] {
245            let spec = to_required_spec(RequiredPlugin {
246                name: "org".into(),
247                source: RequiredSource::Local(raw.into()),
248                enable_mode: None,
249                pinned: false,
250            });
251            let PluginSource::Local(path) = spec.source else {
252                panic!("a Local source stays Local");
253            };
254            assert_eq!(
255                path,
256                std::path::PathBuf::from(raw),
257                "{raw} must be verbatim"
258            );
259        }
260    }
261    use std::sync::atomic::{AtomicUsize, Ordering};
262
263    static COUNTER: AtomicUsize = AtomicUsize::new(0);
264
265    fn tempdir(tag: &str) -> PathBuf {
266        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
267        let dir =
268            std::env::temp_dir().join(format!("lattice-pm7-{tag}-{}-{n}", std::process::id()));
269        let _ = std::fs::remove_dir_all(&dir);
270        std::fs::create_dir_all(&dir).unwrap();
271        dir
272    }
273
274    struct NoGit;
275    impl GitRunner for NoGit {
276        fn run(&self, _cwd: &Path, _args: &[&str]) -> Result<String, String> {
277            Err("git unavailable".into())
278        }
279    }
280
281    struct StubFetch;
282    impl Fetcher for StubFetch {
283        fn fetch(&self, _url: &str, dest: &Path) -> Result<(), String> {
284            if let Some(p) = dest.parent() {
285                std::fs::create_dir_all(p).unwrap();
286            }
287            std::fs::write(dest, b"\0asm-prebuilt").unwrap();
288            Ok(())
289        }
290    }
291
292    struct StubBuild {
293        fail: bool,
294        calls: AtomicUsize,
295    }
296    impl StubBuild {
297        fn ok() -> Self {
298            Self {
299                fail: false,
300                calls: AtomicUsize::new(0),
301            }
302        }
303        fn failing() -> Self {
304            Self {
305                fail: true,
306                calls: AtomicUsize::new(0),
307            }
308        }
309    }
310    impl ComponentBuilder for StubBuild {
311        fn build(&self, source_dir: &Path) -> Result<PathBuf, String> {
312            self.calls.fetch_add(1, Ordering::SeqCst);
313            if self.fail {
314                return Err("compile error".into());
315            }
316            let out = source_dir
317                .parent()
318                .unwrap_or(source_dir)
319                .join("stub-out.wasm");
320            std::fs::write(&out, b"\0asm").unwrap();
321            Ok(out)
322        }
323    }
324
325    fn source_tree(root: &Path, name: &str) -> PathBuf {
326        let src = root.join(format!("{name}-src"));
327        std::fs::create_dir_all(src.join("src")).unwrap();
328        std::fs::write(src.join("plugin.toml"), format!("id = \"{name}\"\n")).unwrap();
329        std::fs::write(src.join("src").join("lib.rs"), "// v1").unwrap();
330        src
331    }
332
333    fn local(root: &Path, name: &str) -> RequiredSpec {
334        RequiredSpec {
335            name: name.to_string(),
336            source: PluginSource::Local(source_tree(root, name)),
337            enable_mode: Some(format!("{name}-mode")),
338            pinned: false,
339        }
340    }
341
342    #[test]
343    fn a_local_spec_resolves_builds_and_becomes_ready() {
344        let root = tempdir("ready");
345        let out = install_required(
346            &NoGit,
347            &StubFetch,
348            &StubBuild::ok(),
349            &local(&root, "demo"),
350            &root.join("cache"),
351            &root.join("user"),
352            RefreshPolicy::UseCache,
353        );
354        match out {
355            Install::Ready {
356                name,
357                artifact,
358                enable_mode,
359                stale,
360            } => {
361                assert_eq!(name, "demo");
362                assert!(artifact.is_file());
363                assert_eq!(enable_mode.as_deref(), Some("demo-mode"));
364                assert_eq!(stale, None);
365            }
366            other => panic!("expected Ready, got {other:?}"),
367        }
368    }
369
370    #[test]
371    fn a_prebuilt_spec_never_reaches_the_builder() {
372        // The kind exists so a user with no toolchain can install a plugin;
373        // consulting the builder would defeat it.
374        let root = tempdir("prebuilt");
375        let builder = StubBuild::ok();
376        let out = install_required(
377            &NoGit,
378            &StubFetch,
379            &builder,
380            &RequiredSpec {
381                name: "pre".into(),
382                source: PluginSource::Prebuilt {
383                    url: "https://example.invalid/p.wasm".into(),
384                },
385                enable_mode: None,
386                pinned: false,
387            },
388            &root.join("cache"),
389            &root.join("user"),
390            RefreshPolicy::UseCache,
391        );
392        assert!(matches!(out, Install::Ready { .. }));
393        assert_eq!(
394            builder.calls.load(Ordering::SeqCst),
395            0,
396            "a prebuilt must skip the build entirely"
397        );
398    }
399
400    #[test]
401    fn a_resolve_failure_is_a_skip_not_a_panic() {
402        let root = tempdir("resolve-fail");
403        let out = install_required(
404            &NoGit,
405            &StubFetch,
406            &StubBuild::ok(),
407            &RequiredSpec {
408                name: "gone".into(),
409                source: PluginSource::Local(root.join("does-not-exist")),
410                enable_mode: None,
411                pinned: false,
412            },
413            &root.join("cache"),
414            &root.join("user"),
415            RefreshPolicy::UseCache,
416        );
417        match out {
418            Install::Skipped { name, error } => {
419                assert_eq!(name, "gone");
420                assert!(error.contains("not a directory"), "got: {error}");
421            }
422            other => panic!("expected Skipped, got {other:?}"),
423        }
424    }
425
426    #[test]
427    fn a_build_failure_with_no_prior_artifact_is_a_skip() {
428        let root = tempdir("build-fail");
429        let out = install_required(
430            &NoGit,
431            &StubFetch,
432            &StubBuild::failing(),
433            &local(&root, "broken"),
434            &root.join("cache"),
435            &root.join("user"),
436            RefreshPolicy::UseCache,
437        );
438        assert!(matches!(out, Install::Skipped { .. }));
439    }
440
441    #[test]
442    fn a_stale_rebuild_failure_still_installs_and_reports_the_caveat() {
443        // The plugin runs old code — which is right — but the user must be
444        // able to find out that it is.
445        let root = tempdir("stale");
446        let user = root.join("user");
447        let spec = local(&root, "demo");
448        install_required(
449            &NoGit,
450            &StubFetch,
451            &StubBuild::ok(),
452            &spec,
453            &root.join("cache"),
454            &user,
455            RefreshPolicy::UseCache,
456        );
457
458        // Dirty the source so the next attempt is a rebuild, then fail it.
459        let PluginSource::Local(ref dir) = spec.source else {
460            unreachable!()
461        };
462        let f = dir.join("src").join("lib.rs");
463        std::fs::write(&f, "// broken").unwrap();
464        let later = std::time::SystemTime::now() + std::time::Duration::from_secs(120);
465        let file = std::fs::OpenOptions::new().write(true).open(&f).unwrap();
466        file.set_times(std::fs::FileTimes::new().set_modified(later))
467            .unwrap();
468
469        let out = install_required(
470            &NoGit,
471            &StubFetch,
472            &StubBuild::failing(),
473            &spec,
474            &root.join("cache"),
475            &user,
476            RefreshPolicy::UseCache,
477        );
478        match out {
479            Install::Ready {
480                stale, artifact, ..
481            } => {
482                assert!(artifact.is_file(), "the previous build still loads");
483                assert!(
484                    stale.unwrap().contains("compile error"),
485                    "the caveat must be reportable, not silent"
486                );
487            }
488            other => panic!("expected a stale Ready, got {other:?}"),
489        }
490    }
491
492    #[test]
493    fn one_broken_spec_does_not_stop_the_others() {
494        // A user with five plugins and one broken source loses that one.
495        let root = tempdir("install-all");
496        let specs = vec![
497            local(&root, "first"),
498            RequiredSpec {
499                name: "broken".into(),
500                source: PluginSource::Local(root.join("nope")),
501                enable_mode: None,
502                pinned: false,
503            },
504            local(&root, "third"),
505        ];
506        let out = install_all(
507            &NoGit,
508            &StubFetch,
509            &StubBuild::ok(),
510            &specs,
511            &root.join("cache"),
512            &root.join("user"),
513            RefreshPolicy::UseCache,
514        );
515
516        assert_eq!(out.len(), 3, "every spec is attempted");
517        assert!(matches!(out[0], Install::Ready { .. }));
518        assert!(matches!(out[1], Install::Skipped { .. }));
519        assert!(
520            matches!(out[2], Install::Ready { .. }),
521            "a failure must not abort the specs after it"
522        );
523    }
524
525    #[test]
526    fn the_host_to_loader_conversion_preserves_every_field() {
527        // The one cost of mirroring the types across the boundary. If it
528        // drifts, a user's `pinned` or `enable-mode` silently stops working
529        // with nothing failing to compile — so it gets a test rather than a
530        // reviewer's attention.
531        use lattice_plugin_host::plugin_manager_host::{RequiredPlugin, RequiredSource};
532        let got = to_required_spec(RequiredPlugin {
533            name: "demo".into(),
534            source: RequiredSource::Git {
535                url: "https://example.invalid/d.git".into(),
536                rev: Some("abc".into()),
537            },
538            enable_mode: Some("demo-mode".into()),
539            pinned: true,
540        });
541        assert_eq!(
542            got,
543            RequiredSpec {
544                name: "demo".into(),
545                source: PluginSource::Git {
546                    url: "https://example.invalid/d.git".into(),
547                    rev: Some("abc".into()),
548                },
549                enable_mode: Some("demo-mode".into()),
550                pinned: true,
551            }
552        );
553    }
554
555    #[test]
556    fn every_source_kind_survives_the_conversion() {
557        use lattice_plugin_host::plugin_manager_host::{RequiredPlugin, RequiredSource};
558        let spec = |source| RequiredPlugin {
559            name: "d".into(),
560            source,
561            enable_mode: None,
562            pinned: false,
563        };
564        assert_eq!(
565            to_required_spec(spec(RequiredSource::Local("/tmp/x".into()))).source,
566            PluginSource::Local(PathBuf::from("/tmp/x"))
567        );
568        assert_eq!(
569            to_required_spec(spec(RequiredSource::Prebuilt {
570                url: "https://example.invalid/x.wasm".into()
571            }))
572            .source,
573            PluginSource::Prebuilt {
574                url: "https://example.invalid/x.wasm".into()
575            }
576        );
577    }
578
579    #[test]
580    fn declaration_order_is_preserved() {
581        // `require` order is the user's stated order; a plugin that
582        // configures another should be able to rely on it.
583        let root = tempdir("order");
584        let specs = vec![
585            local(&root, "aaa"),
586            local(&root, "bbb"),
587            local(&root, "ccc"),
588        ];
589        let out = install_all(
590            &NoGit,
591            &StubFetch,
592            &StubBuild::ok(),
593            &specs,
594            &root.join("cache"),
595            &root.join("user"),
596            RefreshPolicy::UseCache,
597        );
598        let names: Vec<&str> = out.iter().map(|i| i.name()).collect();
599        assert_eq!(names, vec!["aaa", "bbb", "ccc"]);
600    }
601}