Skip to main content

repolith_engine/
federation.rs

1//! Federation — `kind = "repolith"` runs a child stack's manifest as a
2//! nested plan (orchestrator-of-orchestrators).
3//!
4//! `RepolithSync` is an `Action` that, at execute time, parses the
5//! child manifest found inside the node's checkout, builds a nested
6//! [`Orchestrator`](crate::orchestrator::Orchestrator) over it, and runs
7//! it to completion. The child stack keeps its **own local cache** —
8//! exactly what a human would get running `repolith sync` in that
9//! directory — so parent cache ids never collide with child ids.
10//!
11//! # Guard rails
12//!
13//! - **Cycle detection**: every `RepolithSync` carries the canonicalized
14//!   manifest paths of its ancestors. A child manifest already in that
15//!   chain aborts with the full offending chain in the error.
16//! - **Depth bound**: `MAX_FEDERATION_DEPTH` levels, then a hard error.
17//! - **Cancellation tree**: the child orchestrator's root token is a
18//!   `child_token()` of the parent's `Ctx` token — Ctrl-C at any level
19//!   reaps the whole tree, subprocess groups included.
20//! - **Global concurrency**: the child draws permits from the *parent's*
21//!   semaphore (see `Builder::shared_semaphore`), so `--jobs N` bounds the
22//!   whole tree. `RepolithSync` itself is a coordinator
23//!   (`Action::is_coordinator`) and is never charged a permit.
24//!
25//! # Wiring (dependency direction)
26//!
27//! Turning a parsed `Manifest` into concrete actions requires the
28//! action implementations (`repolith-actions`) and a cache backend
29//! (`repolith-cache`) — both out of reach from this crate without
30//! inverting the dependency graph. The CLI injects them through
31//! `FederationHooks`; recursion happens naturally because the CLI's
32//! factory constructs nested `RepolithSync` values with the same hooks.
33
34use crate::orchestrator::Orchestrator;
35use async_trait::async_trait;
36use repolith_core::action::Action;
37use repolith_core::cache::Cache;
38use repolith_core::manifest::Manifest;
39use repolith_core::paths::contain_within;
40use repolith_core::types::{ActionId, BuildError, BuildEvent, BuildOutput, Ctx, ExecMode, Sha256};
41use sha2::{Digest, Sha256 as ShaHasher};
42use std::path::{Path, PathBuf};
43use std::sync::Arc;
44use tokio::process::Command;
45use tokio::sync::Semaphore;
46
47/// Maximum number of nested federation levels. Deep enough for any sane
48/// stack-of-stacks; shallow enough that a runaway recursion fails fast
49/// with a readable chain instead of exhausting the filesystem.
50pub const MAX_FEDERATION_DEPTH: usize = 8;
51
52/// Environment injected by the host (CLI) — everything `RepolithSync`
53/// cannot construct without inverting the crate dependency graph.
54pub trait FederationHooks: Send + Sync {
55    /// Turn a parsed child manifest into concrete actions.
56    ///
57    /// `chain` already includes the child manifest's canonical path (last
58    /// element); implementations pass it through to any nested
59    /// `RepolithSync` they construct so cycle detection stays airtight.
60    ///
61    /// # Errors
62    /// Implementation-defined (missing node source, unknown kind wiring…).
63    fn build_actions(
64        &self,
65        manifest: &Manifest,
66        stack_dir: &Path,
67        chain: &[PathBuf],
68    ) -> Result<Vec<Box<dyn Action>>, BuildError>;
69
70    /// Open (or create) the child stack's **local** cache.
71    ///
72    /// # Errors
73    /// Backend-defined (I/O, schema migration…).
74    fn open_cache(&self, stack_dir: &Path) -> Result<Box<dyn Cache>, BuildError>;
75}
76
77/// Action that syncs a child repolith stack found in the node's checkout.
78pub struct RepolithSync {
79    /// Action identifier for the plan.
80    pub id: ActionId,
81    /// The node's checkout directory — containment boundary for `manifest_rel`.
82    pub node_path: PathBuf,
83    /// Child manifest path relative to [`Self::node_path`]. `None` = `repolith.toml`.
84    pub manifest_rel: Option<PathBuf>,
85    /// Canonicalized manifest paths of every ancestor stack (cycle guard).
86    pub chain: Vec<PathBuf>,
87    /// Execution mode forwarded to the child orchestrator.
88    pub mode: ExecMode,
89    /// The federation-wide concurrency pool (parent's semaphore).
90    pub shared_sem: Arc<Semaphore>,
91    /// Host-injected wiring (action factory + cache opener).
92    pub hooks: Arc<dyn FederationHooks>,
93    /// IDs of actions that must complete before this one runs.
94    pub deps: Vec<ActionId>,
95}
96
97impl RepolithSync {
98    /// Canonicalized child manifest path, proven inside the checkout, with
99    /// cycle + depth guards applied.
100    fn resolved_manifest(&self) -> Result<PathBuf, BuildError> {
101        let rel = self
102            .manifest_rel
103            .as_deref()
104            .unwrap_or(Path::new("repolith.toml"));
105        let manifest = contain_within(&self.node_path, rel)?;
106        if let Some(pos) = self.chain.iter().position(|p| p == &manifest) {
107            let mut cycle: Vec<String> = self.chain[pos..]
108                .iter()
109                .map(|p| p.display().to_string())
110                .collect();
111            cycle.push(manifest.display().to_string());
112            return Err(BuildError::Io(format!(
113                "federation cycle: {}",
114                cycle.join(" -> ")
115            )));
116        }
117        if self.chain.len() >= MAX_FEDERATION_DEPTH {
118            return Err(BuildError::Io(format!(
119                "federation depth {} exceeds the maximum of {MAX_FEDERATION_DEPTH} (chain: {})",
120                self.chain.len() + 1,
121                self.chain
122                    .iter()
123                    .map(|p| p.display().to_string())
124                    .collect::<Vec<_>>()
125                    .join(" -> ")
126            )));
127        }
128        Ok(manifest)
129    }
130}
131
132#[async_trait]
133impl Action for RepolithSync {
134    fn id(&self) -> ActionId {
135        self.id.clone()
136    }
137
138    fn deps(&self) -> Vec<ActionId> {
139        self.deps.clone()
140    }
141
142    fn is_coordinator(&self) -> bool {
143        true
144    }
145
146    async fn input_hash(&self, ctx: &Ctx) -> Result<Sha256, BuildError> {
147        let mut h = ShaHasher::new();
148        h.update(b"repolith:manifest:");
149
150        // Pre-clone grace: when the checkout (or its manifest) does not
151        // exist yet — a sibling git-clone earlier in the same node will
152        // materialize it — hash a deterministic marker instead of failing
153        // the whole plan. The action is stale anyway (NoCachedBuild or
154        // UpstreamMoved cascade), executes after the clone, and the next
155        // sync re-hashes the real bytes (one extra, harmless re-run).
156        match self.resolved_manifest() {
157            Ok(manifest) => {
158                let bytes = tokio::fs::read(&manifest).await.map_err(|e| {
159                    BuildError::Io(format!("read manifest {}: {e}", manifest.display()))
160                })?;
161                h.update(&bytes);
162
163                // Checkout state: HEAD when the stack is a git worktree.
164                let mut head = Command::new("git");
165                head.args(["-C"])
166                    .arg(&self.node_path)
167                    .args(["rev-parse", "HEAD"]);
168                let out = tokio::select! {
169                    r = head.output() => {
170                        r.map_err(|e| BuildError::Io(format!("spawn git rev-parse: {e}")))?
171                    }
172                    () = ctx.cancel.cancelled() => return Err(BuildError::Cancelled),
173                };
174                h.update(b":checkout:");
175                if out.status.success() {
176                    h.update(&out.stdout);
177                } else {
178                    h.update(b"no-git");
179                }
180            }
181            Err(_) if !self.node_path.exists() => {
182                h.update(b"pre-clone");
183            }
184            Err(e) => return Err(e),
185        }
186        Ok(Sha256(h.finalize().into()))
187    }
188
189    async fn execute(&self, ctx: &Ctx) -> Result<BuildOutput, BuildError> {
190        // Strict at execute time — the checkout must exist now.
191        let manifest_path = self.resolved_manifest()?;
192        let stack_dir = manifest_path
193            .parent()
194            .ok_or_else(|| {
195                BuildError::Io(format!(
196                    "manifest {} has no parent",
197                    manifest_path.display()
198                ))
199            })?
200            .to_path_buf();
201
202        let src = tokio::fs::read_to_string(&manifest_path)
203            .await
204            .map_err(|e| {
205                BuildError::Io(format!("read manifest {}: {e}", manifest_path.display()))
206            })?;
207        let manifest = Manifest::from_toml(&src).map_err(|e| {
208            BuildError::Io(format!("child manifest {}: {e}", manifest_path.display()))
209        })?;
210
211        // Extended chain: ancestors + this child. Passed to the hooks so
212        // nested RepolithSync instances inherit the full lineage.
213        let mut chain = self.chain.clone();
214        chain.push(manifest_path.clone());
215
216        let actions = self.hooks.build_actions(&manifest, &stack_dir, &chain)?;
217        let cache = self.hooks.open_cache(&stack_dir)?;
218
219        let child_ctx = Ctx {
220            // Child token: cancelling the parent cancels every level below.
221            cancel: ctx.cancel.child_token(),
222            workdir: stack_dir.clone(),
223            env: ctx.env.clone(),
224        };
225
226        let mut builder = Orchestrator::builder()
227            .manifest(manifest)
228            .base_ctx(child_ctx)
229            .shared_semaphore(Arc::clone(&self.shared_sem));
230        for a in actions {
231            builder = builder.register_boxed(a);
232        }
233        let mut orch = builder
234            .cache_boxed(cache)
235            .build()
236            .map_err(|e| BuildError::Io(format!("child orchestrator: {e}")))?;
237
238        let plan = orch.compute_plan().await.map_err(|e| {
239            BuildError::Io(format!("child plan ({}): {e}", manifest_path.display()))
240        })?;
241        let events = orch.execute_plan(&plan, self.mode).await.map_err(|e| {
242            BuildError::Io(format!("child sync ({}): {e}", manifest_path.display()))
243        })?;
244
245        // output_hash: digest of the child's (id, output) pairs in stable
246        // order — identical child results yield an identical parent hash.
247        let mut pairs: Vec<(String, [u8; 32])> = events
248            .iter()
249            .filter_map(|e| match e {
250                BuildEvent::Success { id, output, .. } => Some((id.0.clone(), output.0)),
251                BuildEvent::Failed { .. } => None,
252            })
253            .collect();
254        pairs.sort();
255        let mut h = ShaHasher::new();
256        h.update(b"repolith:events:");
257        for (id, out) in &pairs {
258            h.update(id.as_bytes());
259            h.update(b"=");
260            h.update(out);
261        }
262        Ok(BuildOutput {
263            output_hash: Sha256(h.finalize().into()),
264            stdout: format!(
265                "child stack {} synced — {} action(s)",
266                stack_dir.display(),
267                pairs.len()
268            ),
269        })
270    }
271}