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}