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}