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