Skip to main content

alba_engine/
scheduler.rs

1//! The scheduler: turns a validated [`Project`] and a set of target beams
2//! into a bounded-parallel run.
3//!
4//! ## Shape
5//!
6//! [`run`] extracts the union of every target's execution subgraph, rejects
7//! the two things that make a run unschedulable as a whole (a plugin beam
8//! whose `alba-executor-<name>` binary is not on the `PATH`, and parameters
9//! that do not match what the targets declare) *before* starting any work,
10//! then spawns one tokio task per beam. Each task waits for its dependencies,
11//! renders its `run`/`env`
12//! templates with the target's parameters in scope, takes a permit from a
13//! semaphore sized by [`RunOptions::jobs`], consults the cache, and — on a
14//! miss — runs its commands sequentially, stopping at the first one that
15//! fails. The permit covers the cache decision as well as the commands:
16//! deciding reads every input file, which is work `jobs` must bound too.
17//!
18//! Anything that goes wrong from the moment a beam starts — a command that
19//! exits non-zero, one that cannot be spawned, a template that will not
20//! render — is that beam's own failure, reported through its normal event
21//! stream and its status, never an abandoned run. That is what lets
22//! `keep_going`, `allow_failure`, and dependency propagation govern it.
23//!
24//! ## Completion signalling
25//!
26//! Every beam owns a `watch` channel carrying `Option<BeamOutcome>`, and
27//! its dependents hold receivers cloned *before* any task is spawned.
28//! `watch::Receiver::wait_for` reads the channel's current value before it
29//! ever suspends, so a beam that finishes before a dependent starts
30//! waiting is still observed — a bare notification primitive would lose
31//! that wakeup. Nothing in this module holds a lock (or a `watch` borrow
32//! guard) across an `.await`.
33//!
34//! ## Two cancellation tokens
35//!
36//! The caller's token stops *running* commands: it is what reaches an
37//! [`ExecContext`], so cancelling it terminates child processes. `stop` is
38//! a child of it and means "no beam may start from now on": fail-fast
39//! cancels `stop` only, which is precisely why a failure cancels the beams
40//! that have not started while the ones already running finish. Because
41//! `stop` is a child token, the caller's cancellation implies it too.
42
43use std::collections::{HashMap, HashSet};
44use std::path::PathBuf;
45use std::sync::Arc;
46use std::time::{Duration, Instant};
47
48use alba_core::{
49    Beam, BeamId, CoreError, ExecutorKind, OptionValue, Project, execution_subgraph, expand_globs,
50    outputs_satisfied, render_template,
51};
52use alba_executors::{
53    BeamContext, CommandSpec, ExecContext, Executor, OutputLine, PluginExecutor, Stream,
54};
55use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
56use tokio::sync::{OwnedSemaphorePermit, Semaphore, watch};
57use tokio_util::sync::CancellationToken;
58
59use crate::EngineError;
60use crate::cache::{
61    BeamFacts, CacheOptions, CacheStore, FORMAT_VERSION, Manifest, fingerprint, hash_file,
62    static_contribution,
63};
64use crate::event::{BeamStatus, RunEvent, RunSummary};
65
66/// How a run is parameterized: everything the caller chose, as opposed to
67/// what the Beamfile declares.
68#[derive(Debug, Clone)]
69pub struct RunOptions {
70    /// How many beams may run at once. Zero is treated as one — a run with
71    /// no slots at all could only ever hang.
72    pub jobs: usize,
73    /// Keep running everything still possible after a failure, instead of
74    /// cancelling the beams that have not started.
75    pub keep_going: bool,
76    /// Positional arguments for the target beam, e.g. `staging` in
77    /// `alba run deploy staging`.
78    pub params: Vec<String>,
79    /// Where the cache lives and how this run treats it. `None` disables
80    /// caching entirely — `alba check` and most tests want that.
81    pub cache: Option<CacheOptions>,
82    /// Ambient variables the caller adds to every command of the run, on
83    /// top of the process environment and under the beam's own `env`
84    /// (a name both set keeps the beam's value). Reaches the executors
85    /// only, never the cache fingerprint: the interactive front end uses
86    /// it to force colour, and a beam must hit the same cache entry
87    /// whether a terminal or a pipe ran it.
88    pub extra_env: Vec<(String, String)>,
89}
90
91/// The executors a run can dispatch to, chosen per beam.
92#[derive(Clone)]
93pub struct Executors {
94    /// `ExecutorKind::Shell`: the default (the embedded shell, once the
95    /// CLI wires it in).
96    pub embedded: Arc<dyn Executor>,
97    /// `ExecutorKind::SystemShell`: the per-beam opt-out.
98    pub system: Arc<dyn Executor>,
99    /// `ExecutorKind::Docker`: a beam that opted into a container.
100    pub docker: Arc<dyn Executor>,
101}
102
103impl Executors {
104    /// All three slots on the same executor: what tests and `alba check`
105    /// want.
106    pub fn uniform(executor: Arc<dyn Executor>) -> Self {
107        Self {
108            embedded: Arc::clone(&executor),
109            system: Arc::clone(&executor),
110            docker: executor,
111        }
112    }
113
114    /// Which executor a beam's declared kind dispatches to.
115    ///
116    /// Only ever called with a built-in kind: a plugin beam's executor is
117    /// built directly in [`run`] from the path [`resolve_plugins`] already
118    /// found for it, one [`PluginExecutor`] per plugin name rather than a
119    /// shared instance held here.
120    fn for_beam(&self, kind: &ExecutorKind) -> Arc<dyn Executor> {
121        match kind {
122            ExecutorKind::Shell => Arc::clone(&self.embedded),
123            ExecutorKind::SystemShell => Arc::clone(&self.system),
124            ExecutorKind::Docker { .. } => Arc::clone(&self.docker),
125            ExecutorKind::Plugin { .. } => {
126                unreachable!("plugin beams are dispatched in `run`, never through `for_beam`")
127            }
128        }
129    }
130}
131
132/// The fingerprint's own name for a beam's executor: participates in the
133/// cache key (see [`crate::cache::BeamFacts::executor`]) so switching a
134/// beam between `Shell` and `SystemShell`, or changing a docker image or a
135/// plugin option, invalidates its cache entry even when nothing else about
136/// it changed.
137fn executor_label(kind: &ExecutorKind) -> String {
138    match kind {
139        // Byte-identical to before: no field ever needs escaping, and
140        // existing cache manifests for shell beams must stay valid.
141        ExecutorKind::Shell => "embedded".to_string(),
142        ExecutorKind::SystemShell => "system".to_string(),
143        ExecutorKind::Docker {
144            image,
145            volumes,
146            workdir,
147        } => {
148            let mut label = String::from("docker");
149            push_field(&mut label, image);
150            push_field(&mut label, &volumes.len().to_string());
151            for volume in volumes {
152                push_field(&mut label, volume);
153            }
154            push_field(&mut label, workdir.as_deref().unwrap_or(""));
155            label
156        }
157        ExecutorKind::Plugin { name, options } => {
158            let mut label = String::from("plugin");
159            push_field(&mut label, name);
160            push_field(&mut label, &options.len().to_string());
161            for (key, value) in options {
162                push_field(&mut label, key);
163                push_option_value(&mut label, value);
164            }
165            label
166        }
167    }
168}
169
170/// Appends `value` to `label` as a length-prefixed field: `|<byte
171/// length>:<value>`. Prefixing every field with its own byte length is what
172/// makes the field boundary unambiguous — without it, a value that itself
173/// contains the separator (a volume string with a `;`, a plugin option with
174/// a `,`) could forge a boundary and make two different configurations
175/// label identically, which would serve a stale cache hit for a beam whose
176/// image, mounts, or options actually changed.
177fn push_field(label: &mut String, value: &str) {
178    label.push_str(&format!("|{}:{value}", value.len()));
179}
180
181/// Appends one plugin option's value to `label`, folded into
182/// [`executor_label`] so a beam's cache entry changes with any option it
183/// declares. The value's kind is encoded alongside its content — not just
184/// length-prefixed on its own — so, say, `Str("a,b")` cannot label the same
185/// as `List(["a", "b"])`.
186fn push_option_value(label: &mut String, value: &OptionValue) {
187    match value {
188        OptionValue::Str(v) => {
189            push_field(label, "str");
190            push_field(label, v);
191        }
192        OptionValue::Bool(v) => {
193            push_field(label, "bool");
194            push_field(label, if *v { "true" } else { "false" });
195        }
196        OptionValue::List(v) => {
197            push_field(label, "list");
198            push_field(label, &v.len().to_string());
199            for item in v {
200                push_field(label, item);
201            }
202        }
203    }
204}
205
206/// What a session's `BeamContext.options` carries for this beam's kind.
207/// The shell executors take no options; a docker beam's configuration is
208/// exactly what its executor needs to deserialize; a plugin beam's is its
209/// declared options, verbatim, as the plugin binary is the only party that
210/// knows how to interpret them.
211fn executor_options(kind: &ExecutorKind) -> serde_json::Value {
212    match kind {
213        ExecutorKind::Shell | ExecutorKind::SystemShell => serde_json::Value::Null,
214        ExecutorKind::Docker {
215            image,
216            volumes,
217            workdir,
218        } => serde_json::json!({
219            "image": image,
220            "volumes": volumes,
221            "workdir": workdir,
222        }),
223        ExecutorKind::Plugin { options, .. } => serde_json::Value::Object(
224            options
225                .iter()
226                .map(|(key, value)| (key.clone(), option_json(value)))
227                .collect(),
228        ),
229    }
230}
231
232/// One plugin option's value, converted from the core model's
233/// [`OptionValue`] into the JSON shape [`executor_options`] hands the
234/// plugin — a `List` becomes a JSON array of strings, matching how the
235/// docker options above already serialize theirs.
236fn option_json(value: &OptionValue) -> serde_json::Value {
237    match value {
238        OptionValue::Str(v) => serde_json::Value::String(v.clone()),
239        OptionValue::Bool(v) => serde_json::Value::Bool(*v),
240        OptionValue::List(v) => v.iter().cloned().map(serde_json::Value::String).collect(),
241    }
242}
243
244/// What a run schedules: the target beams (each with its subgraph), and
245/// where they came from when a git reference chose them.
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub struct Targets {
248    pub beams: Vec<BeamId>,
249    pub affected_by: Option<String>,
250}
251
252impl Targets {
253    /// The everyday case: `alba run <beam>`.
254    pub fn beam(id: BeamId) -> Self {
255        Self {
256            beams: vec![id],
257            affected_by: None,
258        }
259    }
260}
261
262/// Runs every beam in `targets` and everything each one needs, and reports
263/// what happened.
264///
265/// Returns `Err` only for something Alba itself cannot do — an unknown
266/// target, an unresolvable plugin beam, wrong parameters — or for a
267/// beam's task panicking, which is a bug rather than an outcome. A beam
268/// that merely fails is not an error: it lands in [`RunSummary::failed`], and
269/// [`RunSummary::exit_code`] turns that into the process exit code.
270/// `RunFinished` is emitted last, and only on the `Ok` path — an `Err` has
271/// no summary to report.
272pub async fn run(
273    project: &Project,
274    targets: &Targets,
275    options: RunOptions,
276    executors: Executors,
277    events: UnboundedSender<RunEvent>,
278    cancel: CancellationToken,
279) -> Result<RunSummary, EngineError> {
280    let started_at = Instant::now();
281    let (beams, plugins) = plan(project, &targets.beams, &options.params)?;
282
283    let _ = events.send(RunEvent::RunStarted {
284        targets: targets.beams.clone(),
285        affected_by: targets.affected_by.clone(),
286        beams: beams.iter().map(|beam| beam.id.clone()).collect(),
287        edges: beams
288            .iter()
289            .flat_map(|beam| {
290                beam.needs
291                    .iter()
292                    .map(|need| (beam.id.clone(), need.value.clone()))
293            })
294            .collect(),
295    });
296
297    let slots = Arc::new(Semaphore::new(options.jobs.max(1)));
298    let stop = cancel.child_token();
299    // One store for the whole run, shared by every task: it holds only a
300    // path, and each entry is written atomically under the beam's own name.
301    let cache = options
302        .cache
303        .as_ref()
304        .map(|options| Arc::new(CacheStore::new(options.dir.clone())));
305    let force = options.cache.as_ref().is_some_and(|options| options.force);
306
307    // Both halves of every channel exist before the first task is spawned,
308    // so no dependent can ever start waiting on a beam whose channel does
309    // not exist yet. The senders are kept in a `Vec` parallel to `beams`
310    // (rather than a map) so each task provably gets its own.
311    let mut senders = Vec::with_capacity(beams.len());
312    let mut receivers = HashMap::with_capacity(beams.len());
313    for beam in &beams {
314        let (sender, receiver) = watch::channel(None);
315        senders.push(sender);
316        receivers.insert(beam.id.0.as_str(), receiver);
317    }
318
319    let mut tasks = Vec::with_capacity(beams.len());
320    for (beam, status) in beams.iter().zip(senders) {
321        // Every `needs` entry resolves: the project passed graph
322        // validation and `beams` is the target's transitive closure. The
323        // assertion makes that an enforced invariant rather than a comment
324        // — silently dropping an entry would let this beam run without
325        // waiting for a dependency.
326        let dependencies: Vec<_> = beam
327            .needs
328            .iter()
329            .filter_map(|need| receivers.get(need.value.0.as_str()).cloned())
330            .collect();
331        debug_assert_eq!(
332            dependencies.len(),
333            beam.needs.len(),
334            "every `needs` entry of `{}` must resolve within its own subgraph",
335            beam.id.0
336        );
337
338        let task = BeamTask {
339            args: if targets.beams.len() == 1 && beam.id == targets.beams[0] {
340                options.params.clone()
341            } else {
342                Vec::new()
343            },
344            // A spawned task must own its data; a `Beam` is a handful of
345            // small vectors, cloned once per run.
346            beam: (*beam).clone(),
347            dependencies,
348            status,
349            slots: Arc::clone(&slots),
350            executor: match &beam.executor {
351                ExecutorKind::Plugin { name, .. } => {
352                    Arc::new(PluginExecutor::new(plugins[name.as_str()].clone()))
353                }
354                kind => executors.for_beam(kind),
355            },
356            events: events.clone(),
357            cancel: cancel.clone(),
358            stop: stop.clone(),
359            keep_going: options.keep_going,
360            cache: cache.clone(),
361            force,
362            extra_env: options.extra_env.clone(),
363        };
364        tasks.push((beam.id.clone(), tokio::spawn(run_beam(task))));
365    }
366
367    // Awaited in declaration order, which is also the order the summary
368    // reports: a run's buckets must not depend on which task happened to
369    // finish first. Every task is awaited even once one of them has
370    // panicked, so no task outlives this call.
371    let mut summary = RunSummary::default();
372    let mut failure: Option<EngineError> = None;
373    for (id, task) in tasks {
374        match task.await {
375            Ok(status) => summary.record(id, &status),
376            Err(_) if failure.is_none() => failure = Some(EngineError::Panicked { beam: id }),
377            Err(_) => {}
378        }
379    }
380    if let Some(error) = failure {
381        return Err(error);
382    }
383
384    summary.duration = started_at.elapsed();
385    let _ = events.send(RunEvent::RunFinished {
386        summary: summary.clone(),
387    });
388    Ok(summary)
389}
390
391/// The beams to run, in the project's declaration order, and the binary
392/// path resolved for each distinct plugin name among them, once the things
393/// that make a run unschedulable as a whole have been ruled out.
394///
395/// The subgraph is the union of every target's own execution subgraph,
396/// deduplicated so a beam shared by several targets is scheduled once.
397///
398/// These checks happen here, before [`run`] spawns anything, so a plugin
399/// beam whose binary is missing, or a bad parameter list, fails the run
400/// without half of its subgraph having already executed. They are the only
401/// such checks: everything else that can go wrong belongs to one beam and
402/// is reported as that beam's failure once the run is under way.
403fn plan<'a>(
404    project: &'a Project,
405    targets: &[BeamId],
406    params: &[String],
407) -> Result<(Vec<&'a Beam>, HashMap<String, PathBuf>), EngineError> {
408    let mut ids: HashSet<String> = HashSet::new();
409    for target in targets {
410        ids.extend(
411            execution_subgraph(project, target)?
412                .into_iter()
413                .map(|id| id.0),
414        );
415    }
416    let beams: Vec<&Beam> = project
417        .beams
418        .iter()
419        .filter(|beam| ids.contains(beam.id.0.as_str()))
420        .collect();
421
422    let plugins = resolve_plugins(&beams, |binary| which::which(binary).ok())?;
423    check_parameters(&beams, targets, params)?;
424
425    Ok((beams, plugins))
426}
427
428/// Resolves every distinct plugin name in the subgraph to its binary, or
429/// fails the run before anything starts. `lookup` receives the binary
430/// name (`alba-executor-<name>`) and answers with its path; production
431/// passes a `which` lookup, tests inject their own.
432fn resolve_plugins(
433    beams: &[&Beam],
434    lookup: impl Fn(&str) -> Option<PathBuf>,
435) -> Result<HashMap<String, PathBuf>, EngineError> {
436    let mut resolved = HashMap::new();
437    for beam in beams {
438        let ExecutorKind::Plugin { name, .. } = &beam.executor else {
439            continue;
440        };
441        if resolved.contains_key(name.as_str()) {
442            continue;
443        }
444        let binary = format!("alba-executor-{name}");
445        match lookup(&binary) {
446            Some(path) => {
447                resolved.insert(name.clone(), path);
448            }
449            None => {
450                let mut message = format!(
451                    "beam `{}` uses executor `{name}`: `{name}` is neither a built-in \
452                     executor nor `{binary}` on the PATH",
453                    beam.id.0,
454                );
455                if let Some(candidate) =
456                    alba_core::suggest(name, ["shell", "system_shell", "docker"].into_iter())
457                {
458                    message.push_str(&format!(" — did you mean `{candidate}`?"));
459                }
460                return Err(EngineError::Unschedulable(message));
461            }
462        }
463    }
464    Ok(resolved)
465}
466
467/// Checks that the run's positional arguments match what the target beams
468/// declare, and that no other beam in the subgraph declares parameters —
469/// only a target can be given any, so a dependency with parameters could
470/// never have them bound.
471///
472/// Positional arguments only ever bind when there is exactly one target:
473/// with several, nothing tells which target they belong to, so every
474/// target must then take none.
475fn check_parameters(
476    beams: &[&Beam],
477    targets: &[BeamId],
478    params: &[String],
479) -> Result<(), EngineError> {
480    if !params.is_empty() && targets.len() != 1 {
481        return Err(EngineError::Unschedulable(format!(
482            "positional arguments need exactly one target beam, got {}",
483            targets.len()
484        )));
485    }
486    for beam in beams {
487        let is_target = targets.contains(&beam.id);
488        if !is_target && !beam.params.is_empty() {
489            return Err(EngineError::Unschedulable(format!(
490                "beam `{}` declares parameters, but only a target beam can be given any",
491                beam.id.0
492            )));
493        }
494        if is_target {
495            let given = if targets.len() == 1 { params.len() } else { 0 };
496            check_arity(beam, given)?;
497        }
498    }
499    Ok(())
500}
501
502/// Checks that `beam` — a target — declares exactly `given` parameters.
503fn check_arity(beam: &Beam, given: usize) -> Result<(), EngineError> {
504    let expected = beam.params.len();
505    if expected == given {
506        return Ok(());
507    }
508
509    Err(EngineError::Unschedulable(if expected == 0 {
510        format!("beam `{}` takes no parameters, got {given}", beam.id.0)
511    } else {
512        let names = beam
513            .params
514            .iter()
515            .map(|name| format!("`{name}`"))
516            .collect::<Vec<_>>()
517            .join(", ");
518        let plural = if expected == 1 { "" } else { "s" };
519        format!(
520            "beam `{}` expects {expected} parameter{plural} ({names}), got {given}",
521            beam.id.0
522        )
523    }))
524}
525
526/// What a finished beam publishes to its dependents: how it ended, and
527/// what it contributes to their fingerprints. `contribution` is `None`
528/// when nothing stable could be computed (a render failure, a
529/// cancellation) — a dependent seeing `None` from a satisfied dependency
530/// is simply non-cacheable for that run.
531#[derive(Debug, Clone)]
532struct BeamOutcome {
533    status: BeamStatus,
534    contribution: Option<String>,
535}
536
537/// Everything one beam's task owns. A struct rather than a long argument
538/// list, since all of it is handed to a spawned task as a unit.
539struct BeamTask {
540    beam: Beam,
541    /// The lone target's positional arguments — bindable only when there
542    /// is exactly one target; empty for every other beam.
543    args: Vec<String>,
544    dependencies: Vec<watch::Receiver<Option<BeamOutcome>>>,
545    status: watch::Sender<Option<BeamOutcome>>,
546    slots: Arc<Semaphore>,
547    executor: Arc<dyn Executor>,
548    events: UnboundedSender<RunEvent>,
549    /// The caller's token: reaches the executor, so cancelling it
550    /// terminates commands that are already running.
551    cancel: CancellationToken,
552    /// "No beam may start from now on" — see the module doc comment.
553    stop: CancellationToken,
554    keep_going: bool,
555    /// Where cache entries live; `None` when caching is disabled.
556    cache: Option<Arc<CacheStore>>,
557    /// Ignore any stored entry when reading, but still write one.
558    force: bool,
559    /// The run's ambient environment, added to every command under the
560    /// beam's own `env`. Never reaches the cache fingerprint.
561    extra_env: Vec<(String, String)>,
562}
563
564/// One beam's whole life: wait, run (or not), report, release dependents.
565///
566/// Every way a beam can go wrong ends in a [`BeamStatus`], never in an
567/// error that abandons the run: a command that exits non-zero, one that
568/// cannot be spawned, and a template that cannot be rendered are all
569/// per-beam outcomes, which is exactly what `keep_going` and
570/// `allow_failure` govern. The only thing that can still take a beam's
571/// task down is a panic, which [`run`] turns into an
572/// [`EngineError::Panicked`].
573///
574/// Publishing the status is deliberately the last thing that happens, and
575/// always happens: a dependent blocked on this beam is released only once
576/// its `BeamFinished` event is out and, on a failure, only once `stop` has
577/// been cancelled — so the dependent cannot observe a stale "nothing has
578/// failed yet" and start.
579async fn run_beam(mut task: BeamTask) -> BeamStatus {
580    let (status, duration, contribution) = match wait_for_dependencies(&mut task.dependencies).await
581    {
582        Some(contributions) => process(&task, &contributions).await,
583        None => (BeamStatus::Cancelled, Duration::ZERO, None),
584    };
585
586    if matches!(status, BeamStatus::Failed { .. }) && !task.keep_going {
587        task.stop.cancel();
588    }
589    let _ = task.events.send(RunEvent::BeamFinished {
590        id: task.beam.id.clone(),
591        status: status.clone(),
592        duration,
593    });
594    let _ = task.status.send(Some(BeamOutcome {
595        status: status.clone(),
596        contribution,
597    }));
598    status
599}
600
601/// Waits for every dependency and collects what each contributes to this
602/// beam's fingerprint, in `needs` order. `None` means a dependency failed
603/// or was cancelled — this beam must not run. `FailedAllowed` and
604/// `Cached` both count as satisfied.
605///
606/// That a `FailedAllowed` dependency satisfies its dependents holds even
607/// under `keep_going`: a beam whose dependency did not produce its outputs
608/// cannot meaningfully run.
609async fn wait_for_dependencies(
610    dependencies: &mut [watch::Receiver<Option<BeamOutcome>>],
611) -> Option<Vec<Option<String>>> {
612    let mut contributions = Vec::with_capacity(dependencies.len());
613    for dependency in dependencies {
614        let outcome = wait_for_outcome(dependency).await;
615        match outcome.status {
616            BeamStatus::Succeeded | BeamStatus::Cached | BeamStatus::FailedAllowed { .. } => {
617                contributions.push(outcome.contribution);
618            }
619            BeamStatus::Failed { .. } | BeamStatus::Cancelled => return None,
620        }
621    }
622    Some(contributions)
623}
624
625/// Blocks until `dependency` publishes its final outcome.
626///
627/// `wait_for` inspects the channel's current value before it suspends, so
628/// a dependency that finished before this call is still observed. Its
629/// borrow guard holds the channel's internal lock, so the outcome is cloned
630/// out of it and the guard dropped before returning — never held across an
631/// await.
632async fn wait_for_outcome(dependency: &mut watch::Receiver<Option<BeamOutcome>>) -> BeamOutcome {
633    let cancelled = BeamOutcome {
634        status: BeamStatus::Cancelled,
635        contribution: None,
636    };
637    match dependency.wait_for(Option::is_some).await {
638        Ok(seen) => match &*seen {
639            Some(outcome) => outcome.clone(),
640            None => cancelled,
641        },
642        // The sender was dropped without publishing: that beam's task died
643        // unexpectedly, so treat it as cancelled rather than wait forever.
644        Err(_) => cancelled,
645    }
646}
647
648/// What the cache concluded about a cacheable beam. `fingerprint` doubles
649/// as this beam's contribution to its dependents; `hit` carries the
650/// manifest to replay when the beam can be skipped.
651struct Assessment {
652    fingerprint: String,
653    hit: Option<Manifest>,
654}
655
656/// Renders, consults the cache, and either replays a hit or executes.
657///
658/// Rendering happens here — before the cache decision, which needs the
659/// rendered command — and the successful result is handed to `execute` so
660/// commands are rendered exactly once. A template that fails to render is
661/// deliberately *re*-rendered inside `execute`: the failure then follows
662/// the exact event path it always has (started, stderr line, failed).
663///
664/// The `jobs` slot is taken here, around both the assessment and the
665/// commands, rather than around the commands alone. Assessing a beam
666/// walks its tree and reads every input file whole; leaving that outside
667/// the semaphore let every beam whose dependencies were satisfied do it at
668/// once, which is the same resource problem `jobs` exists to bound.
669async fn process(
670    task: &BeamTask,
671    contributions: &[Option<String>],
672) -> (BeamStatus, Duration, Option<String>) {
673    let cancelled = (BeamStatus::Cancelled, Duration::ZERO, None);
674
675    // A run that is already stopping starts no beam, and holding a cache
676    // hit is not an exception: this beam never started, so it is cancelled
677    // like any other. `Cached` counts as satisfied, so replaying a hit here
678    // would release this beam's dependents and let an aborting run report a
679    // whole subgraph green. Checked before the assessment, not merely
680    // before the hit is returned, so an aborting run also stops expanding
681    // globs and hashing files instead of fingerprinting its way through the
682    // rest of the graph.
683    if task.stop.is_cancelled() {
684        return cancelled;
685    }
686
687    let plan = render(&task.beam, &task.args).ok();
688    let Some(permit) = acquire_slot(task).await else {
689        return cancelled;
690    };
691    let (assessment, notice) = assess(task, plan.as_ref(), contributions).await;
692
693    // Waiting for a slot, then hashing, may have taken a while, during
694    // which the run may have been stopped: this beam has still not
695    // started, and still holds no licence to report a hit.
696    if task.stop.is_cancelled() {
697        return cancelled;
698    }
699
700    if let Some(assessment) = &assessment
701        && let Some(manifest) = &assessment.hit
702    {
703        // Released before replaying: a hit occupies a slot only for as
704        // long as deciding it takes, never for reading its logs back.
705        drop(permit);
706        replay(task);
707        return (
708            BeamStatus::Cached,
709            Duration::from_millis(manifest.duration_ms),
710            Some(assessment.fingerprint.clone()),
711        );
712    }
713
714    let contribution = match (&assessment, &plan) {
715        (Some(assessment), _) => Some(assessment.fingerprint.clone()),
716        (None, Some(plan)) => Some(static_contribution(
717            &plan.commands,
718            &plan.cwd.to_string_lossy(),
719            &plan.env,
720            &task.args,
721            &executor_label(&task.beam.executor),
722        )),
723        (None, None) => None,
724    };
725
726    let (status, duration, lines) = execute(task, plan, notice, permit).await;
727
728    // Only a plain success is worth remembering: a failure, an allowed
729    // failure, and a cancellation all leave the previous entry alone.
730    if status == BeamStatus::Succeeded
731        && let (Some(assessment), Some(store)) = (&assessment, &task.cache)
732    {
733        store.store(
734            &task.beam.id,
735            &Manifest {
736                version: FORMAT_VERSION,
737                fingerprint: assessment.fingerprint.clone(),
738                outputs: task.beam.outputs.clone(),
739                duration_ms: u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
740            },
741            &lines,
742        );
743    }
744
745    (status, duration, contribution)
746}
747
748/// The cache's verdict for this beam, plus an optional user-facing notice.
749///
750/// The verdict is `None` when the beam is not cacheable at all: caching
751/// disabled, no declared `inputs`, `inputs` that resolve to no file at
752/// all, a template that will not render, a dependency with no stable
753/// contribution, or an input file that cannot be hashed. The last two
754/// filesystem cases carry a notice — they are the ones worth telling the
755/// user about, emitted as a stderr line once the beam starts.
756async fn assess(
757    task: &BeamTask,
758    plan: Option<&RenderedBeam>,
759    contributions: &[Option<String>],
760) -> (Option<Assessment>, Option<String>) {
761    let (Some(store), Some(plan)) = (task.cache.as_ref(), plan) else {
762        return (None, None);
763    };
764    if task.beam.inputs.is_empty() {
765        return (None, None);
766    }
767    let Some(needs) = contributions
768        .iter()
769        .cloned()
770        .collect::<Option<Vec<String>>>()
771    else {
772        return (None, None);
773    };
774
775    let facts = CacheableBeam {
776        store: Arc::clone(store),
777        id: task.beam.id.clone(),
778        dir: task.beam.dir.clone(),
779        inputs: task.beam.inputs.clone(),
780        outputs: task.beam.outputs.clone(),
781        commands: plan.commands.clone(),
782        cwd: plan.cwd.to_string_lossy().into_owned(),
783        env: plan.env.clone(),
784        args: task.args.clone(),
785        needs,
786        force: task.force,
787        executor: executor_label(&task.beam.executor),
788    };
789    // Off the runtime's worker threads: `decide` walks a directory tree
790    // and reads every input file whole through `std::fs`. Doing that in an
791    // `async fn` blocked a worker for as long as it took, and with one
792    // beam per worker doing it at once nothing else could be polled —
793    // including the CLI's interrupt watcher and the task draining the
794    // event channel, so Ctrl-C and the display both froze.
795    //
796    // A panic in there degrades like every other cache failure: the beam
797    // runs uncached rather than taking the run down.
798    tokio::task::spawn_blocking(move || facts.decide())
799        .await
800        .unwrap_or((None, None))
801}
802
803/// Everything the cache decision needs about one beam, owned rather than
804/// borrowed: [`CacheableBeam::decide`] runs on a blocking thread, which
805/// cannot hold a borrow of the scheduler's state.
806struct CacheableBeam {
807    store: Arc<CacheStore>,
808    id: BeamId,
809    dir: PathBuf,
810    inputs: Vec<String>,
811    outputs: Vec<String>,
812    commands: Vec<String>,
813    cwd: String,
814    env: Vec<(String, String)>,
815    args: Vec<String>,
816    needs: Vec<String>,
817    force: bool,
818    executor: String,
819}
820
821impl CacheableBeam {
822    /// The blocking half of [`assess`]: resolve the inputs, hash them,
823    /// fingerprint, and consult the store. Every step here touches the
824    /// filesystem, which is exactly why it is kept in one place.
825    fn decide(&self) -> (Option<Assessment>, Option<String>) {
826        // A beam that declares `inputs` and resolves none of them is not
827        // cacheable for this run. The fingerprint of an empty file list is
828        // a constant, so the first run would write a manifest nothing can
829        // ever invalidate and every later run would replay it — however
830        // much the sources changed. The ordinary causes are all mistakes
831        // worth naming: a misspelled path, an input directory the
832        // project's `.gitignore` excludes, a pattern that will not compile.
833        let matched = expand_globs(&self.dir, &self.inputs);
834        if matched.is_empty() {
835            return (
836                None,
837                Some("cache: `inputs` matched no file; running without cache".to_string()),
838            );
839        }
840
841        let mut files = Vec::new();
842        for (relative, absolute) in matched {
843            match hash_file(&absolute) {
844                Ok(hash) => files.push((relative, hash)),
845                // Unreadable between glob resolution and hashing (deleted,
846                // permissions): non-cacheable this run, never a failed run.
847                Err(error) => {
848                    return (
849                        None,
850                        Some(format!(
851                            "cache: cannot hash input `{relative}` ({error}); running without cache"
852                        )),
853                    );
854                }
855            }
856        }
857
858        let fingerprint = fingerprint(&BeamFacts {
859            files: &files,
860            commands: &self.commands,
861            cwd: &self.cwd,
862            env: &self.env,
863            args: &self.args,
864            needs: &self.needs,
865            executor: &self.executor,
866        });
867
868        let hit = (!self.force)
869            .then(|| self.store.load(&self.id))
870            .flatten()
871            .filter(|manifest| manifest.fingerprint == fingerprint)
872            .filter(|_| outputs_satisfied(&self.dir, &self.outputs));
873
874        (Some(Assessment { fingerprint, hit }), None)
875    }
876}
877
878/// Announces a hit, then replays its stored output lines in order —
879/// `BeamCached` first, so a consumer sees the hit before any of the
880/// original run's lines, mirroring a live beam's started/output order.
881fn replay(task: &BeamTask) {
882    let _ = task.events.send(RunEvent::BeamCached {
883        id: task.beam.id.clone(),
884    });
885    if let Some(store) = &task.cache {
886        for line in store.load_logs(&task.beam.id) {
887            let _ = task.events.send(RunEvent::BeamOutput {
888                id: task.beam.id.clone(),
889                line,
890                replayed: true,
891            });
892        }
893    }
894}
895
896/// Takes a `jobs` slot, or `None` when the run stopped before one came
897/// free.
898///
899/// `biased` so a run that is already stopping does not start one more beam
900/// just because a permit happened to be free at the same instant. The
901/// semaphore lives in [`run`] until every task has been awaited, so it is
902/// never closed; treating a closed one as a cancellation keeps this total
903/// without a panic.
904async fn acquire_slot(task: &BeamTask) -> Option<OwnedSemaphorePermit> {
905    tokio::select! {
906        biased;
907        () = task.stop.cancelled() => None,
908        permit = Arc::clone(&task.slots).acquire_owned() => permit.ok(),
909    }
910}
911
912/// Runs the beam's commands on the slot its caller already holds,
913/// returning how it ended, how long it took, and the output lines worth
914/// storing.
915async fn execute(
916    task: &BeamTask,
917    plan: Option<RenderedBeam>,
918    notice: Option<String>,
919    // Bound (rather than dropped as `_`) so the slot stays held until this
920    // function returns.
921    _permit: OwnedSemaphorePermit,
922) -> (BeamStatus, Duration, Vec<OutputLine>) {
923    let started_at = Instant::now();
924    let _ = task.events.send(RunEvent::BeamStarted {
925        id: task.beam.id.clone(),
926    });
927
928    // Output is forwarded by a task of its own so lines reach the consumer
929    // while the command is still running, rather than being buffered until
930    // it exits. Joining it before returning is what guarantees every
931    // `BeamOutput` precedes this beam's `BeamFinished`; it ends when the
932    // last sender is dropped, which an `Executor` does by the time
933    // `execute` returns (the trait's contract).
934    let (lines, output) = tokio::sync::mpsc::unbounded_channel();
935    let forwarder = tokio::spawn(forward_output(
936        task.beam.id.clone(),
937        output,
938        task.events.clone(),
939    ));
940
941    // Why this beam is running uncached, told where the user is already
942    // looking rather than out of band.
943    if let Some(notice) = notice {
944        let _ = lines.send(OutputLine {
945            stream: Stream::Stderr,
946            text: notice,
947        });
948    }
949
950    // A template that will not render is this beam's failure, not the
951    // run's — the same rule a command that cannot be spawned follows, and
952    // for the same reason: `env(NAME)` with no default is deliberately
953    // deferred to this moment, so an unset variable is an ordinary per-beam
954    // outcome that `keep_going` and `allow_failure` are meant to govern.
955    // Abandoning the whole run instead discarded the summary, dropped the
956    // final event, and left this beam with no events at all.
957    //
958    // `plan` is what the caller already rendered successfully; re-rendering
959    // here is how a beam whose template does not render reaches the failure
960    // path with its error message intact.
961    let status = match plan.map_or_else(|| render(&task.beam, &task.args), Ok) {
962        Ok(plan) => run_commands(task, &plan, &lines).await,
963        Err(error) => {
964            let _ = lines.send(OutputLine {
965                stream: Stream::Stderr,
966                text: error.to_string(),
967            });
968            failure_status(task)
969        }
970    };
971    drop(lines);
972    let lines = forwarder.await.unwrap_or_default();
973
974    (status, started_at.elapsed(), lines)
975}
976
977/// How a beam that failed before running a single command is recorded:
978/// `NO_EXIT_CODE`, through its own `allow_failure` flag.
979fn failure_status(task: &BeamTask) -> BeamStatus {
980    if task.beam.allow_failure {
981        BeamStatus::FailedAllowed {
982            exit_code: NO_EXIT_CODE,
983        }
984    } else {
985        BeamStatus::Failed {
986            exit_code: NO_EXIT_CODE,
987        }
988    }
989}
990
991/// The environment a command receives: the run's ambient `extra_env`
992/// first, then the beam's own `env`, with a name both set kept once
993/// with the beam's value. One entry per name, so no executor has to
994/// decide which duplicate wins (docker's repeated `-e` would take the
995/// last, the embedded shell overwrites in place: same answer, but only
996/// because nothing is duplicated here).
997fn command_env(extra: &[(String, String)], beam: &[(String, String)]) -> Vec<(String, String)> {
998    let mut env: Vec<(String, String)> = extra
999        .iter()
1000        .filter(|(name, _)| !beam.iter().any(|(own, _)| own == name))
1001        .cloned()
1002        .collect();
1003    env.extend(beam.iter().cloned());
1004    env
1005}
1006
1007/// A beam's commands and the environment they run in, all rendered.
1008struct RenderedBeam {
1009    commands: Vec<String>,
1010    env: Vec<(String, String)>,
1011    cwd: PathBuf,
1012}
1013
1014/// Renders the beam's schedule-time templates with its parameters bound.
1015///
1016/// Every error is stamped with the beam's own [`alba_core::SourceId`]:
1017/// there is no active source scope outside `alba-core`'s loading, so an
1018/// unstamped error would point the CLI's caret at the root Beamfile
1019/// whatever file this beam actually came from.
1020fn render(beam: &Beam, args: &[String]) -> Result<RenderedBeam, EngineError> {
1021    let scope = beam.scope.with_params(&beam.params, args);
1022    let stamp = |error: CoreError| EngineError::Core(error.with_source_id(beam.source));
1023
1024    let mut commands = Vec::with_capacity(beam.run.len());
1025    for template in &beam.run {
1026        commands.push(render_template(template, &scope).map_err(stamp)?);
1027    }
1028    let mut env = Vec::with_capacity(beam.env.len());
1029    for (name, template) in &beam.env {
1030        env.push((
1031            name.clone(),
1032            render_template(template, &scope).map_err(stamp)?,
1033        ));
1034    }
1035
1036    let cwd = match &beam.cwd {
1037        // `join` with an absolute `cwd` yields that absolute path, so a
1038        // beam can opt out of its Beamfile's directory.
1039        Some(cwd) => beam.dir.join(cwd),
1040        None => beam.dir.clone(),
1041    };
1042
1043    Ok(RenderedBeam { commands, env, cwd })
1044}
1045
1046/// The exit code reported for a command that never produced one, because
1047/// the executor could not run it at all. Matches `SystemShellExecutor`'s
1048/// own fallback for a child with no discrete exit code.
1049const NO_EXIT_CODE: i32 = -1;
1050
1051/// Opens the beam's session, runs its commands one after another through
1052/// it, stopping at the first failure, and closes the session — in every
1053/// case, whether the commands succeeded, failed, or were cancelled.
1054///
1055/// A command that could not be spawned at all is that beam's failure, not
1056/// the run's: a missing shell or a `cwd` that does not exist is a per-beam
1057/// problem, which is exactly what `keep_going` and `allow_failure` are
1058/// about. Likewise for a session that could not be opened at all — no
1059/// runtime, a missing image. The executor's message would otherwise be
1060/// lost, so it is emitted as a stderr line first — the CLI already renders
1061/// those where the user is looking.
1062async fn run_commands(
1063    task: &BeamTask,
1064    plan: &RenderedBeam,
1065    output: &UnboundedSender<OutputLine>,
1066) -> BeamStatus {
1067    let context = BeamContext {
1068        beam: task.beam.id.0.clone(),
1069        dir: task.beam.dir.clone(),
1070        options: executor_options(&task.beam.executor),
1071        output: output.clone(),
1072        cancel: task.cancel.clone(),
1073    };
1074    let mut session = match task.executor.open(context).await {
1075        Ok(session) => session,
1076        Err(error) => {
1077            let _ = output.send(OutputLine {
1078                stream: Stream::Stderr,
1079                text: error.to_string(),
1080            });
1081            return if task.cancel.is_cancelled() {
1082                BeamStatus::Cancelled
1083            } else {
1084                failure_status(task)
1085            };
1086        }
1087    };
1088
1089    let mut status = BeamStatus::Succeeded;
1090    for command in &plan.commands {
1091        // Rechecked before every command, not just relied on to end the
1092        // loop through a failing exit code: `docs/plugin-protocol.md`
1093        // leaves what a cancelled command's `exit` code says entirely up
1094        // to the plugin (`-1` is only the reference plugin's own
1095        // convention), so a conformant plugin that answers a cancelled
1096        // command with `{"type":"exit","code":0}` must not make this loop
1097        // read that as "keep going" and dispatch the beam's remaining
1098        // commands one by one, each opening only to be cancelled again.
1099        if task.cancel.is_cancelled() {
1100            status = BeamStatus::Cancelled;
1101            break;
1102        }
1103
1104        let spec = CommandSpec {
1105            command: command.clone(),
1106            env: command_env(&task.extra_env, &plan.env),
1107            cwd: plan.cwd.clone(),
1108        };
1109        let context = ExecContext {
1110            output: output.clone(),
1111            cancel: task.cancel.clone(),
1112        };
1113
1114        let exit_code = match session.execute(spec, context).await {
1115            Ok(result) if result.exit_code == 0 => continue,
1116            Ok(result) => result.exit_code,
1117            Err(error) => {
1118                let _ = output.send(OutputLine {
1119                    stream: Stream::Stderr,
1120                    text: error.to_string(),
1121                });
1122                NO_EXIT_CODE
1123            }
1124        };
1125
1126        // A cancelled command still reports an exit code, and no code is
1127        // reserved to mean "was killed" (`-1` is a legitimate one on
1128        // windows), so the token — not the code — is what tells a
1129        // cancellation apart from a genuine failure.
1130        status = if task.cancel.is_cancelled() {
1131            BeamStatus::Cancelled
1132        } else if task.beam.allow_failure {
1133            BeamStatus::FailedAllowed { exit_code }
1134        } else {
1135            BeamStatus::Failed { exit_code }
1136        };
1137        break;
1138    }
1139
1140    // Guaranteed cleanup: success, failure, and cancellation all pass
1141    // here. A close failure is a notice, never a verdict change — the
1142    // commands' own outcome is already decided.
1143    if let Err(error) = session.close().await {
1144        let _ = output.send(OutputLine {
1145            stream: Stream::Stderr,
1146            text: format!("session close: {error}"),
1147        });
1148    }
1149    status
1150}
1151
1152/// Relabels an executor's output lines as this beam's output events until
1153/// the executor drops the last sender, and returns everything it saw —
1154/// the capture a successful beam stores for future replay.
1155async fn forward_output(
1156    id: BeamId,
1157    mut output: UnboundedReceiver<OutputLine>,
1158    events: UnboundedSender<RunEvent>,
1159) -> Vec<OutputLine> {
1160    let mut seen = Vec::new();
1161    while let Some(line) = output.recv().await {
1162        let _ = events.send(RunEvent::BeamOutput {
1163            id: id.clone(),
1164            line: line.clone(),
1165            replayed: false,
1166        });
1167        seen.push(line);
1168    }
1169    seen
1170}
1171
1172#[cfg(test)]
1173mod tests {
1174    use super::*;
1175
1176    #[test]
1177    fn shell_labels_are_unchanged() {
1178        assert_eq!(executor_label(&ExecutorKind::Shell), "embedded");
1179        assert_eq!(executor_label(&ExecutorKind::SystemShell), "system");
1180    }
1181
1182    #[test]
1183    fn a_docker_label_changes_with_every_piece_of_its_configuration() {
1184        let base = ExecutorKind::Docker {
1185            image: "alpine:3".into(),
1186            volumes: vec![],
1187            workdir: None,
1188        };
1189        let other_image = ExecutorKind::Docker {
1190            image: "alpine:4".into(),
1191            volumes: vec![],
1192            workdir: None,
1193        };
1194        let with_volume = ExecutorKind::Docker {
1195            image: "alpine:3".into(),
1196            volumes: vec!["h:/c".into()],
1197            workdir: None,
1198        };
1199        let with_workdir = ExecutorKind::Docker {
1200            image: "alpine:3".into(),
1201            volumes: vec![],
1202            workdir: Some("/w".into()),
1203        };
1204        let labels: Vec<String> = [&base, &other_image, &with_volume, &with_workdir]
1205            .iter()
1206            .map(|kind| executor_label(kind))
1207            .collect();
1208        let unique: std::collections::HashSet<&String> = labels.iter().collect();
1209        assert_eq!(
1210            unique.len(),
1211            4,
1212            "every configuration must label distinctly: {labels:?}"
1213        );
1214    }
1215
1216    #[test]
1217    fn a_plugin_label_changes_with_its_options() {
1218        let a = ExecutorKind::Plugin {
1219            name: "podman".into(),
1220            options: vec![("image".into(), OptionValue::Str("x".into()))],
1221        };
1222        let b = ExecutorKind::Plugin {
1223            name: "podman".into(),
1224            options: vec![("image".into(), OptionValue::Str("y".into()))],
1225        };
1226        assert_ne!(executor_label(&a), executor_label(&b));
1227    }
1228
1229    /// A single volume string containing `;workdir=` must not be able to
1230    /// forge a workdir field boundary and land on the same label as a
1231    /// distinct configuration that actually sets that workdir.
1232    #[test]
1233    fn a_volume_cannot_forge_a_workdir_boundary() {
1234        let injected_volume = ExecutorKind::Docker {
1235            image: "img".into(),
1236            volumes: vec!["a;workdir=X".into()],
1237            workdir: None,
1238        };
1239        let real_workdir = ExecutorKind::Docker {
1240            image: "img".into(),
1241            volumes: vec!["a".into()],
1242            workdir: Some("X;workdir=".into()),
1243        };
1244        assert_ne!(
1245            executor_label(&injected_volume),
1246            executor_label(&real_workdir)
1247        );
1248    }
1249
1250    /// One volume whose path contains a comma must not label the same as
1251    /// two separate volumes joined by a comma.
1252    #[test]
1253    fn a_comma_inside_a_volume_cannot_forge_a_second_volume() {
1254        let one_volume = ExecutorKind::Docker {
1255            image: "img".into(),
1256            volumes: vec!["a,b".into()],
1257            workdir: None,
1258        };
1259        let two_volumes = ExecutorKind::Docker {
1260            image: "img".into(),
1261            volumes: vec!["a".into(), "b".into()],
1262            workdir: None,
1263        };
1264        assert_ne!(executor_label(&one_volume), executor_label(&two_volumes));
1265    }
1266
1267    /// Two plugin options must not label the same as one option whose value
1268    /// happens to contain the `;key=value` separator sequence.
1269    #[test]
1270    fn a_plugin_option_value_cannot_forge_a_second_option() {
1271        let two_options = ExecutorKind::Plugin {
1272            name: "podman".into(),
1273            options: vec![
1274                ("a".into(), OptionValue::Str("1".into())),
1275                ("b".into(), OptionValue::Str("2".into())),
1276            ],
1277        };
1278        let injected_option = ExecutorKind::Plugin {
1279            name: "podman".into(),
1280            options: vec![("a".into(), OptionValue::Str("1;b=2".into()))],
1281        };
1282        assert_ne!(
1283            executor_label(&two_options),
1284            executor_label(&injected_option)
1285        );
1286    }
1287
1288    /// A `List` option whose only element contains a comma must not label
1289    /// the same as a `List` with two separate elements.
1290    #[test]
1291    fn a_comma_inside_a_list_option_value_cannot_forge_a_second_element() {
1292        let one_element = ExecutorKind::Plugin {
1293            name: "podman".into(),
1294            options: vec![("flags".into(), OptionValue::List(vec!["a,b".into()]))],
1295        };
1296        let two_elements = ExecutorKind::Plugin {
1297            name: "podman".into(),
1298            options: vec![(
1299                "flags".into(),
1300                OptionValue::List(vec!["a".into(), "b".into()]),
1301            )],
1302        };
1303        assert_ne!(executor_label(&one_element), executor_label(&two_elements));
1304    }
1305
1306    /// A `Str` option must not label the same as a `List` option carrying
1307    /// the same joined text: the value's kind is itself part of the label.
1308    #[test]
1309    fn a_str_option_cannot_forge_a_list_option_of_the_same_text() {
1310        let as_str = ExecutorKind::Plugin {
1311            name: "podman".into(),
1312            options: vec![("flags".into(), OptionValue::Str("a,b".into()))],
1313        };
1314        let as_list = ExecutorKind::Plugin {
1315            name: "podman".into(),
1316            options: vec![(
1317                "flags".into(),
1318                OptionValue::List(vec!["a".into(), "b".into()]),
1319            )],
1320        };
1321        assert_ne!(executor_label(&as_str), executor_label(&as_list));
1322    }
1323
1324    #[test]
1325    fn a_missing_plugin_names_the_search_and_suggests_the_typo() {
1326        let project =
1327            alba_core::load_str(r#"beam d { executor dokcer { image "x" } run "x" }"#).unwrap();
1328        let beams: Vec<&Beam> = project.beams.iter().collect();
1329        let err = resolve_plugins(&beams, |_| None).unwrap_err();
1330        let message = err.to_string();
1331        assert!(
1332            message.contains(
1333                "`dokcer` is neither a built-in executor nor `alba-executor-dokcer` on the PATH"
1334            ),
1335            "{message}"
1336        );
1337        assert!(message.contains("did you mean `docker`?"), "{message}");
1338    }
1339
1340    #[test]
1341    fn resolution_collects_one_path_per_distinct_plugin_name() {
1342        let project = alba_core::load_str(
1343            r#"
1344beam a { executor podman { image "x" } run "a" }
1345beam b { executor podman { image "y" } run "b" }
1346beam c { executor remote run "c" }
1347"#,
1348        )
1349        .unwrap();
1350        let beams: Vec<&Beam> = project.beams.iter().collect();
1351        let resolved = resolve_plugins(&beams, |binary| {
1352            Some(PathBuf::from(format!("/bin/{binary}")))
1353        })
1354        .unwrap();
1355        let expected: HashMap<String, PathBuf> = [
1356            (
1357                "podman".to_string(),
1358                PathBuf::from("/bin/alba-executor-podman"),
1359            ),
1360            (
1361                "remote".to_string(),
1362                PathBuf::from("/bin/alba-executor-remote"),
1363            ),
1364        ]
1365        .into_iter()
1366        .collect();
1367        assert_eq!(resolved, expected);
1368    }
1369}