Skip to main content

repolith_engine/
orchestrator.rs

1//! Orchestrator — drives a `Plan` to completion.
2//!
3//! `Orchestrator` owns the actions, the cache, and the execution context;
4//! `execute_plan` walks the plan layer by layer, fanning the stale actions
5//! out into a `FuturesUnordered` capped by a `tokio::sync::Semaphore`.
6//! On the first error in `ExecMode::FailFast` mode, a shared
7//! `CancellationToken` is fired so in-flight peers can short-circuit.
8//! `ExecMode::KeepGoing` lets the current layer settle but still halts
9//! before the next layer is started.
10//!
11//! **Implementation invariant**: this module never uses
12//! `futures::future::join_all` — it would defeat `FailFast` because
13//! `join_all` ignores cancellation and waits for every future to finish.
14
15use futures::stream::{FuturesUnordered, StreamExt};
16use repolith_core::action::Action;
17use repolith_core::cache::{Cache, CacheError};
18use repolith_core::manifest::Manifest;
19use repolith_core::plan::{Plan, PlanError};
20use repolith_core::types::{ActionId, BuildEvent, Ctx, ExecMode};
21use std::collections::HashMap;
22use std::sync::Arc;
23use std::time::Instant;
24use thiserror::Error;
25use tokio::sync::Semaphore;
26use tokio_util::sync::CancellationToken;
27
28/// Top-level orchestrator. Construct via [`Orchestrator::builder`].
29pub struct Orchestrator {
30    actions: Vec<Box<dyn Action>>,
31    cache: Box<dyn Cache>,
32    /// Optional manifest snapshot — kept for downstream consumers (CLI, telemetry).
33    pub manifest: Option<Manifest>,
34    max_parallelism: usize,
35    base_ctx: Ctx,
36}
37
38/// Errors raised while executing a `Plan`.
39#[derive(Debug, Error)]
40pub enum ExecError {
41    /// At least one action in a layer reported an error. The vector contains
42    /// every event recorded **so far** (across all completed layers + the
43    /// current failing layer), so callers can render a full report.
44    #[error("layer failed: {} event(s) recorded", events.len())]
45    LayerFailed {
46        /// Every event recorded up to and including the failing layer.
47        events: Vec<BuildEvent>,
48    },
49    /// Underlying `Plan::compute` failure surfaced through the orchestrator.
50    #[error(transparent)]
51    Plan(#[from] PlanError),
52    /// Cache write failure while persisting an event.
53    #[error(transparent)]
54    Cache(#[from] CacheError),
55}
56
57/// Errors raised while validating a [`Builder`] configuration.
58#[derive(Debug, Error)]
59pub enum BuilderError {
60    /// `Builder::cache(...)` was never called.
61    #[error("orchestrator builder requires a cache")]
62    MissingCache,
63}
64
65/// Builder for [`Orchestrator`]. Use [`Orchestrator::builder`] to obtain one.
66pub struct Builder {
67    actions: Vec<Box<dyn Action>>,
68    cache: Option<Box<dyn Cache>>,
69    manifest: Option<Manifest>,
70    max_parallelism: usize,
71    base_ctx: Ctx,
72}
73
74impl Orchestrator {
75    /// Start a fresh [`Builder`].
76    ///
77    /// The default `max_parallelism` is `num_cpus::get()`. The default
78    /// `base_ctx` uses a fresh [`CancellationToken`], `current_dir()` for
79    /// `workdir`, and an **empty** env map. SDK/library consumers must
80    /// explicitly call [`Builder::base_ctx`] with an allowlisted env (the
81    /// CLI does this via `filtered_env()`) — defaulting to a snapshot of
82    /// the parent process env would leak secrets like `GITHUB_TOKEN` /
83    /// `AWS_SECRET_ACCESS_KEY` through any future `tracing::debug!(?ctx)`
84    /// or panic dump (CWE-200). This is the engine-layer counterpart to
85    /// the CLI's existing env allowlist.
86    #[must_use]
87    pub fn builder() -> Builder {
88        Builder {
89            actions: vec![],
90            cache: None,
91            manifest: None,
92            max_parallelism: num_cpus::get().max(1),
93            base_ctx: Ctx {
94                cancel: CancellationToken::new(),
95                workdir: std::env::current_dir().unwrap_or_default(),
96                env: std::collections::HashMap::new(),
97            },
98        }
99    }
100
101    /// Compute the `Plan` for the registered actions against the current
102    /// cache state. Pure: no side effects.
103    ///
104    /// # Errors
105    /// Forwarded from `Plan::compute`.
106    pub async fn compute_plan(&self) -> Result<Plan, PlanError> {
107        Plan::compute(&self.actions, self.cache.as_ref(), &self.base_ctx).await
108    }
109
110    /// Execute every stale action in the plan, layer by layer.
111    ///
112    /// Returns the chronological list of `BuildEvent`s recorded across all
113    /// executed layers. Cache writes happen **after** each layer settles, so
114    /// a crash mid-layer leaves the prior layers persisted.
115    ///
116    /// # Errors
117    /// - [`ExecError::LayerFailed`] when any action in a layer errors out
118    ///   (further layers are not started, regardless of `mode`).
119    /// - [`ExecError::Cache`] when persisting an event to the cache fails.
120    pub async fn execute_plan(
121        &mut self,
122        plan: &Plan,
123        mode: ExecMode,
124    ) -> Result<Vec<BuildEvent>, ExecError> {
125        let sem = Arc::new(Semaphore::new(self.max_parallelism));
126        let mut all_events: Vec<BuildEvent> = Vec::new();
127
128        for layer in plan.layers() {
129            // Honor cancel between layers — without this, a cancel
130            // fired after layer N completes still triggers layer N+1's
131            // child token to be created from the already-cancelled
132            // parent, which makes every action in N+1 instantly fail
133            // with `Cancelled` and pollutes the cache with bogus
134            // `BuildEvent::Failed` entries.
135            if self.base_ctx.cancel.is_cancelled() {
136                return Err(ExecError::LayerFailed { events: all_events });
137            }
138
139            let stale: Vec<ActionId> = layer
140                .iter()
141                .filter(|id| plan.reasons().contains_key(*id))
142                .cloned()
143                .collect();
144            if stale.is_empty() {
145                continue;
146            }
147
148            let layer_events = self.execute_layer(&stale, plan, mode, &sem).await;
149
150            // Persist the whole layer's events atomically — a crash mid-batch
151            // shouldn't leave half the layer marked Success and the other
152            // half un-persisted (and thus replayed on the next sync).
153            self.cache.record_batch(layer_events.clone()).await?;
154            all_events.extend(layer_events.iter().cloned());
155
156            if layer_events
157                .iter()
158                .any(|e| matches!(e, BuildEvent::Failed { .. }))
159            {
160                return Err(ExecError::LayerFailed { events: all_events });
161            }
162        }
163
164        Ok(all_events)
165    }
166
167    /// Run one layer's actions concurrently, capped by `sem`. Never panics
168    /// on errors — they're folded into [`BuildEvent::Failed`] entries.
169    async fn execute_layer(
170        &self,
171        stale_ids: &[ActionId],
172        plan: &Plan,
173        mode: ExecMode,
174        sem: &Arc<Semaphore>,
175    ) -> Vec<BuildEvent> {
176        // Fresh child token per layer — failing layer N doesn't poison layer N+1.
177        let cancel = self.base_ctx.cancel.child_token();
178        let layer_ctx = Ctx {
179            cancel: cancel.clone(),
180            workdir: self.base_ctx.workdir.clone(),
181            env: self.base_ctx.env.clone(),
182        };
183
184        let by_id: HashMap<ActionId, &Box<dyn Action>> =
185            self.actions.iter().map(|a| (a.id(), a)).collect();
186
187        let mut pending: FuturesUnordered<_> = stale_ids
188            .iter()
189            .filter_map(|id| by_id.get(id).map(|a| (id.clone(), *a)))
190            .map(|(id, action)| {
191                let sem = Arc::clone(sem);
192                let layer_ctx = layer_ctx.clone();
193                // Reuse the input hash captured during `Plan::compute` —
194                // saves a second `git ls-remote` / `cargo --version` per
195                // action and means a network failure here surfaces as a
196                // typed `BuildEvent::Failed` rather than a silent
197                // `Sha256([0; 32])` poison value (was masking input_hash
198                // errors entirely).
199                let input_hash = plan
200                    .input_hash(&id)
201                    .expect("stale id must have been planned with an input_hash");
202                async move {
203                    // Acquire concurrency permit — yields when the layer is wider than the limit.
204                    let _permit = sem.acquire().await.expect("semaphore never closed");
205                    let started = Instant::now();
206                    let result = action.execute(&layer_ctx).await;
207                    (id, started.elapsed(), input_hash, result)
208                }
209            })
210            .collect();
211
212        let mut events: Vec<BuildEvent> = Vec::new();
213
214        while let Some((id, dur, input_hash, result)) = pending.next().await {
215            let ms = u64::try_from(dur.as_millis()).unwrap_or(u64::MAX);
216            match result {
217                Ok(out) => events.push(BuildEvent::Success {
218                    id,
219                    input: input_hash,
220                    output: out.output_hash,
221                    ms,
222                }),
223                Err(e) => {
224                    events.push(BuildEvent::Failed {
225                        id,
226                        input: input_hash,
227                        error: e,
228                        ms,
229                    });
230                    if mode == ExecMode::FailFast {
231                        // Signal in-flight peers to short-circuit on their next .await poll.
232                        cancel.cancel();
233                    }
234                }
235            }
236        }
237
238        events
239    }
240}
241
242impl Builder {
243    /// Set the cache backend. **Required** — `build()` errors otherwise.
244    #[must_use]
245    pub fn cache<C: Cache + 'static>(mut self, c: C) -> Self {
246        self.cache = Some(Box::new(c));
247        self
248    }
249
250    /// Attach the parsed manifest snapshot (optional, for downstream consumers).
251    #[must_use]
252    pub fn manifest(mut self, m: Manifest) -> Self {
253        self.manifest = Some(m);
254        self
255    }
256
257    /// Cap on concurrent in-flight actions. Floored to `1` (zero is meaningless).
258    #[must_use]
259    pub fn max_parallelism(mut self, n: usize) -> Self {
260        self.max_parallelism = n.max(1);
261        self
262    }
263
264    /// Override the base [`Ctx`] (cancel token, workdir, env).
265    #[must_use]
266    pub fn base_ctx(mut self, ctx: Ctx) -> Self {
267        self.base_ctx = ctx;
268        self
269    }
270
271    /// Register one action. May be called many times.
272    #[must_use]
273    pub fn register<A: Action + 'static>(mut self, a: A) -> Self {
274        self.actions.push(Box::new(a));
275        self
276    }
277
278    /// Register an already-boxed action. Useful when the concrete type is
279    /// only known at runtime (e.g. when assembled from a manifest factory).
280    #[must_use]
281    pub fn register_boxed(mut self, a: Box<dyn Action>) -> Self {
282        self.actions.push(a);
283        self
284    }
285
286    /// Finalize.
287    ///
288    /// # Errors
289    /// [`BuilderError::MissingCache`] when no cache was set.
290    pub fn build(self) -> Result<Orchestrator, BuilderError> {
291        let cache = self.cache.ok_or(BuilderError::MissingCache)?;
292        Ok(Orchestrator {
293            actions: self.actions,
294            cache,
295            manifest: self.manifest,
296            max_parallelism: self.max_parallelism,
297            base_ctx: self.base_ctx,
298        })
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use repolith_core::cache::Result as CacheResult;
306
307    struct StubCache;
308    #[async_trait::async_trait]
309    impl Cache for StubCache {
310        async fn last_build(&self, _id: &ActionId) -> Option<BuildEvent> {
311            None
312        }
313        async fn record(&mut self, _event: BuildEvent) -> CacheResult<()> {
314            Ok(())
315        }
316    }
317
318    /// CWE-200 — `Orchestrator::builder()` default `base_ctx.env` must be
319    /// empty. Library consumers that forget `.base_ctx(...)` can't inherit
320    /// `GITHUB_TOKEN` / `AWS_SECRET_ACCESS_KEY` from the parent process
321    /// into `Ctx`. The CLI overrides via `filtered_env()`.
322    #[test]
323    fn builder_default_env_is_empty() {
324        let orch = Orchestrator::builder()
325            .cache(StubCache)
326            .build()
327            .expect("build");
328        assert!(
329            orch.base_ctx.env.is_empty(),
330            "default builder env must be empty, got keys: {:?}",
331            orch.base_ctx.env.keys().collect::<Vec<_>>()
332        );
333    }
334}