gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 815Rust 56Shell 37Markdown 291229 Definiciones

⎇ 9fe1e010e21199c12896ff156f85884a83f04ce6 crates/takana-agent/src/orchestrator.rs Rust · 689 líneas · 27.4 KB Historia Quién tocó qué crudo

paquetería: zsh se puede instalar — los parches múltiples viajaban unidos y el target_bin era una adivinanza 9fe1e010e · Sergio ·

1//! El bucle agéntico. Ver `docs/08-ai-integration.md` §2.
2//!
3//! `Orchestrator::run(intent)` ejecuta:
4//!
5//! ```text
6//!   plan    → translator.translate(intent, ctx) → Swm
7//!   schema  → swm.verify_schema()
8//!   base    → swm.verify_base(local) → debe ser Ok
9//!   build   → para cada source_patch: AgentClient.compile() vía el bus  (o saltar si --no-bus)
10//!   try     → takana_overlay::try_overlay()                              (o usar --prefix)
11//!   apply   → takana-core::apply para config_edit/file_drop + hydrate para source_patch
12//!   verify  → spot-checks post-condición sobre disco
13//!   propose → devuelve Proposal con overlay_id + log de checks
14//! ```
15//!
16//! La IA NUNCA ejecuta `takana commit`. El humano lo hace tras revisar el `Proposal`.
17
18use std::path::{Path, PathBuf};
19use std::time::Duration;
20
21use takana_core::apply::{apply_config_edit, apply_file_drop, rebase_path};
22use takana_core::proto::RecipeInline;
23use takana_core::swm::{Mutation, Swm};
24use takana_core::BaseRef;
25use takana_overlay::OverlayId;
26
27use crate::client::AgentClient;
28use crate::translator::{IntentTranslator, SystemContext};
29use crate::{Error, Result};
30
31/// Cómo aplica el orquestador las mutaciones al sistema.
32#[derive(Debug, Clone)]
33pub enum ApplyTarget {
34    /// Abre un overlay sobre los targets por defecto del FHS (requiere root). Las
35    /// mutaciones caen sobre las rutas absolutas del `.swm`.
36    Overlay { state_root: PathBuf },
37    /// Re-rootea cada path bajo `prefix`. No abre overlay — útil para tests offline y
38    /// para staging en otro filesystem.
39    Prefix { prefix: PathBuf },
40}
41
42/// Cómo se construyen las `source_patch`. `ViaBus` requiere un `hammerd` corriendo;
43/// `Skip` ignora las source_patch (apropiado para escenarios sin lab).
44#[derive(Debug, Clone)]
45pub enum CompileMode {
46    /// Llama a `Compile` por el bus y espera `BuildReady`. Hidrata localmente desde el
47    /// store si está configurado.
48    ViaBus {
49        sock: PathBuf,
50        store: PathBuf,
51        timeout: Duration,
52    },
53    /// Salta cualquier `source_patch`. Se anota en `Proposal.skipped_source_patches`.
54    Skip,
55}
56
57pub struct Orchestrator<T: IntentTranslator> {
58    translator: T,
59    base: BaseRef,
60    apply: ApplyTarget,
61    compile: CompileMode,
62}
63
64impl<T: IntentTranslator> Orchestrator<T> {
65    pub fn new(translator: T, base: BaseRef, apply: ApplyTarget, compile: CompileMode) -> Self {
66        Self { translator, base, apply, compile }
67    }
68
69    pub fn run(&self, intent: &str) -> Result<Proposal> {
70        // 1. PLAN
71        let ctx = SystemContext {
72            base: self.base.clone(),
73            extras: serde_json::Value::Null,
74        };
75        let swm = self.translator.translate(intent, &ctx)?;
76
77        // 2. SCHEMA
78        swm.verify_schema()?;
79
80        // 3. BASE
81        match swm.verify_base(&self.base) {
82            takana_core::BaseCompat::Ok => {}
83            other => {
84                return Err(Error::Orchestrate(format!(
85                    "base incompatible: {other:?}"
86                )));
87            }
88        }
89
90        // 4. TRY (overlay) — recolectamos el id si lo abrimos; en modo Prefix no hay id.
91        let (overlay_id, prefix): (Option<OverlayId>, Option<PathBuf>) = match &self.apply {
92            ApplyTarget::Overlay { state_root } => {
93                std::fs::create_dir_all(state_root)?;
94                let id = takana_overlay::try_overlay(&[], state_root)?;
95                (Some(id), None)
96            }
97            ApplyTarget::Prefix { prefix } => {
98                std::fs::create_dir_all(prefix)?;
99                (None, Some(prefix.clone()))
100            }
101        };
102
103        // 5. APPLY — recorremos las mutaciones en orden. En source_patch, dispatch al bus
104        // o skip; en config_edit/file_drop, primitivas de takana-core::apply.
105        let mut applied = AppliedCounts::default();
106        let mut checks: Vec<VerifyCheck> = Vec::new();
107        let mut skipped_source_patches = 0usize;
108
109        let mut maybe_client: Option<AgentClient> = None;
110        if matches!(self.compile, CompileMode::ViaBus { .. })
111            && swm.mutations.iter().any(|m| matches!(m, Mutation::SourcePatch { .. }))
112        {
113            if let CompileMode::ViaBus { sock, .. } = &self.compile {
114                maybe_client = Some(AgentClient::connect(sock).map_err(Error::Client)?);
115            }
116        }
117
118        for m in &swm.mutations {
119            match m {
120                Mutation::ConfigEdit { file, inline_diff } => {
121                    let target = rebase_path(file, prefix.as_deref());
122                    apply_config_edit(&target, inline_diff)?;
123                    applied.config_edit += 1;
124                    checks.push(VerifyCheck::pass(format!(
125                        "config_edit aplicado a {}",
126                        target.display()
127                    )));
128                }
129                Mutation::FileDrop { path, content_hash, content_b64, content_url } => {
130                    if let Some(_url) = content_url {
131                        return Err(Error::Orchestrate(format!(
132                            "file_drop con content_url no soportado en Fase 6: {path}"
133                        )));
134                    }
135                    let b64 = content_b64.as_deref().ok_or_else(|| {
136                        Error::Orchestrate(format!(
137                            "file_drop {path} no trae content_b64 (verify_schema falló)"
138                        ))
139                    })?;
140                    let target = rebase_path(path, prefix.as_deref());
141                    apply_file_drop(&target, b64, content_hash)?;
142                    applied.file_drop += 1;
143                    checks.push(VerifyCheck::pass(format!(
144                        "file_drop escrito en {} (hash verificado)",
145                        target.display()
146                    )));
147                }
148                Mutation::InitRule { action, service, command } => {
149                    let rules_dir =
150                        rebase_path(takana_core::apply::INIT_RULES_DIR, prefix.as_deref());
151                    let p = takana_core::apply::apply_init_rule(
152                        &rules_dir, action, service, command,
153                    )
154                    .map_err(|e| Error::Orchestrate(format!("init_rule '{action} {service}': {e}")))?;
155                    applied.init_rule += 1;
156                    checks.push(VerifyCheck::pass(format!(
157                        "init_rule '{action} {service}' → {}",
158                        p.display()
159                    )));
160                }
161                Mutation::SourcePatch {
162                    repo,
163                    commit,
164                    tarball,
165                    sha256,
166                    strip_components: _,
167                    patch,
168                    patches,
169                    patch_url: _,
170                    build,
171                    target_bin,
172                    expected_hash,
173                    deps: _,
174                    evidence, // [H1c] viaja al lab; el veredicto vuelve en BuildReady
175                    slots: _,  // [H4b] topología del sistema; la consume el gate de install, no el build
176                } => {
177                    let CompileMode::ViaBus { store, timeout, .. } = &self.compile else {
178                        skipped_source_patches += 1;
179                        checks.push(VerifyCheck::warn(format!(
180                            "source_patch {target_bin} omitido (CompileMode::Skip)"
181                        )));
182                        continue;
183                    };
184                    let name = Path::new(target_bin)
185                        .file_name()
186                        .and_then(|s| s.to_str())
187                        .unwrap_or("swm-bin")
188                        .to_string();
189                    // El proto del bus (`RecipeInline.patch`) transporta UN parche. Con dos o más
190                    // no se puede reproducir por esta vía: mandarlos concatenados sellaría en otra
191                    // dirección (ver `Mutation::SourcePatch::patches`), así que se corta diciéndolo
192                    // en vez de compilar algo que va a fallar contra el `expected_hash`.
193                    let patch_inline = match (patches.len(), patch) {
194                        (0, p) => p.clone(),
195                        (1, _) => Some(patches[0].clone()),
196                        (n, _) => {
197                            return Err(Error::Orchestrate(format!(
198                                "source_patch {target_bin}: el .swm trae {n} parches y el bus de \
199                                 compilación sólo transporta uno; reproducilo con `takana apply` / \
200                                 `takana install`, que los materializan uno a uno"
201                            )))
202                        }
203                    };
204                    let recipe = RecipeInline {
205                        name: name.clone(),
206                        repo: repo.clone(),
207                        commit: commit.clone(),
208                        tarball: tarball.clone(),
209                        sha256: sha256.clone(),
210                        patch: patch_inline,
211                        compiler: build.compiler.clone(),
212                        target: build.target.clone(),
213                        link: build.link.clone(),
214                        flags: build.flags.clone(),
215                        evidence: evidence.clone(),
216                    };
217                    let client = maybe_client.as_mut().expect("client conectado arriba");
218                    let outcome = client
219                        .compile(recipe, *timeout)
220                        .map_err(Error::Client)?;
221                    let artifact = outcome.artifact;
222                    if let Some(expected) = expected_hash {
223                        let want = expected.strip_prefix("b3:").unwrap_or(expected);
224                        let got = artifact.strip_prefix("b3:").unwrap_or(&artifact);
225                        if want != got {
226                            return Err(Error::Orchestrate(format!(
227                                "source_patch {target_bin}: expected_hash no coincide \
228                                 (declarado=b3:{want}, build=b3:{got})"
229                            )));
230                        }
231                    }
232                    // [H1c] VERIFY con evidencia: el lab ya ejecutó la evidencia declarada tras
233                    // sellar el artefacto y nos devolvió el veredicto. Es el gate "no proponer":
234                    // si algún check falló, NO hidratamos (el artefacto queda sellado en el store,
235                    // pero no toca el sistema) y abortamos con el detalle.
236                    if let Some(verdict) = &outcome.verdict {
237                        for c in &verdict.checks {
238                            if !c.passed {
239                                checks.push(VerifyCheck::fail(format!(
240                                    "evidencia [{}] {}: {}",
241                                    c.kind.as_str(),
242                                    c.cmd,
243                                    c.detail
244                                )));
245                            }
246                        }
247                        if !verdict.all_passed {
248                            let n_fail = verdict.checks.iter().filter(|c| !c.passed).count();
249                            return Err(Error::Orchestrate(format!(
250                                "source_patch {target_bin}: {n_fail}/{} check(s) de evidencia \
251                                 fallaron ⇒ no se propone (artefacto {artifact} sellado pero \
252                                 NO hidratado)",
253                                verdict.checks.len()
254                            )));
255                        }
256                        let lvl = verdict.max_level.map(|k| k.as_str()).unwrap_or("—");
257                        checks.push(VerifyCheck::pass(format!(
258                            "evidencia: {} check(s) pasan (verificado hasta el estrato '{lvl}')",
259                            verdict.checks.len()
260                        )));
261                    }
262
263                    // Hidratamos localmente desde el store (sin pasar por el bus, evita un
264                    // segundo Inject que duplicaría trabajo).
265                    let store = takana_core::Store::open(store)?;
266                    let artifact_dir = store
267                        .find_by_hash(&artifact)
268                        .map_err(|e| Error::Orchestrate(format!("find_by_hash: {e}")))?;
269                    let into = rebase_path("/", prefix.as_deref());
270                    let report = hammer_build_run_hydrate(&artifact_dir, &into)?;
271                    let abs = rebase_path(target_bin, prefix.as_deref());
272                    if !abs.exists() {
273                        return Err(Error::Orchestrate(format!(
274                            "source_patch declara target_bin={target_bin} pero no quedó en {}",
275                            abs.display()
276                        )));
277                    }
278                    applied.source_patch += 1;
279                    checks.push(VerifyCheck::pass(format!(
280                        "source_patch {target_bin}: build {artifact}, {} archivo(s) hidratado(s)",
281                        report
282                    )));
283                }
284            }
285        }
286
287        Ok(Proposal {
288            intent: intent.to_string(),
289            swm,
290            overlay_id: overlay_id.map(|o| o.as_str().to_string()),
291            prefix: prefix.clone(),
292            applied,
293            skipped_source_patches,
294            checks,
295            repair_chain: Vec::new(),
296        })
297    }
298
299    /// Variante con auto-reparación. Tras cada aplicación, si la política trae un
300    /// `event_sock`, abre un cliente del bus y espera `crash_window` para ver si
301    /// llega un `Crashed`. Si llega, traduce `(service, code)` a una nueva intención
302    /// con `policy.format_intent` y vuelve a entrar al bucle, hasta `max_attempts`.
303    ///
304    /// El `Proposal` devuelto siempre lleva la cadena completa de intentos en
305    /// `repair_chain`. Si el último intento se estabilizó (sin crash dentro de la
306    /// ventana), su `triggered_crash` es `None`. Si se agotaron los intentos con un
307    /// crash todavía pendiente, la última entrada lo refleja — el humano lee la cadena
308    /// y decide si commit, discard o seguir manualmente.
309    pub fn run_with_repair(&self, intent: &str, policy: &RepairPolicy) -> Result<Proposal> {
310        let max = policy.max_attempts.max(1);
311        let mut chain: Vec<RepairAttempt> = Vec::new();
312        let mut current = intent.to_string();
313        let mut last: Option<Proposal> = None;
314
315        for attempt in 0..max {
316            let p = self.run(&current)?;
317            let overlay_id = p.overlay_id.clone();
318
319            let crash = wait_for_crash(policy);
320            chain.push(RepairAttempt {
321                attempt,
322                intent: current.clone(),
323                overlay_id,
324                triggered_crash: crash.clone(),
325            });
326            last = Some(p);
327
328            match crash {
329                None => break, // estabilizado
330                Some(c) => {
331                    current = (policy.format_intent)(&c.service, c.code);
332                }
333            }
334        }
335
336        let mut p = last.expect("max_attempts >= 1, así que hubo al menos un run");
337        p.repair_chain = chain;
338        Ok(p)
339    }
340}
341
342/// Conecta al bus y drena eventos hasta encontrar un `Crashed` dentro de `crash_window`.
343/// Devuelve `None` si:
344/// - la política no tiene socket;
345/// - la conexión falla (caso "no hay bus") — el bucle decide entonces "estabilizado";
346/// - expira la ventana sin recibir Crashed.
347fn wait_for_crash(policy: &RepairPolicy) -> Option<CrashInfo> {
348    let Some(sock) = &policy.event_sock else { return None };
349    let client = match AgentClient::connect(sock) {
350        Ok(c) => c,
351        Err(e) => {
352            tracing::debug!(error = %e, "auto-repair: no pude conectar al bus, ignoro");
353            return None;
354        }
355    };
356    let deadline = std::time::Instant::now() + policy.crash_window;
357    // Drenamos primero la cola asíncrona ya acumulada (eventos llegados durante el `run`).
358    for ev in client.drain_async() {
359        if let takana_core::proto::Event::Crashed { service, code } = ev {
360            return Some(CrashInfo { service, code });
361        }
362    }
363    while let Some(remaining) = deadline.checked_duration_since(std::time::Instant::now()) {
364        match client.next_async(remaining) {
365            Ok(takana_core::proto::Event::Crashed { service, code }) => {
366                return Some(CrashInfo { service, code });
367            }
368            Ok(_other) => continue,
369            Err(_) => break, // timeout o cierre
370        }
371    }
372    None
373}
374
375/// Stub: `takana-build` no es dependencia directa de `takana-agent` para no arrastrar el
376/// lab a clientes que sólo quieran hablar con el bus. Implementamos la hidratación local
377/// con `std::fs::hard_link` directamente porque es trivial. Si el árbol del artefacto crece
378/// (symlinks, perms más finos), promovemos a `takana-build::run_hydrate` añadiendo la dep
379/// de manera condicional.
380fn hammer_build_run_hydrate(artifact_dir: &Path, target_fhs: &Path) -> Result<usize> {
381    let mut files = 0usize;
382    walk(artifact_dir, &mut |rel, kind| {
383        let dst = target_fhs.join(rel);
384        match kind {
385            EntryKind::Dir => {
386                std::fs::create_dir_all(&dst)?;
387            }
388            EntryKind::Symlink(target) => {
389                if let Some(parent) = dst.parent() {
390                    std::fs::create_dir_all(parent)?;
391                }
392                let _ = std::fs::remove_file(&dst);
393                std::os::unix::fs::symlink(&target, &dst)?;
394                files += 1;
395            }
396            EntryKind::File(src) => {
397                if let Some(parent) = dst.parent() {
398                    std::fs::create_dir_all(parent)?;
399                }
400                let _ = std::fs::remove_file(&dst);
401                std::fs::hard_link(&src, &dst).or_else(|_| {
402                    // Cross-fs ⇒ caemos a copia. No optimizamos: takana-agent espera
403                    // mismo FS; sólo damos el fallback como cinturón de seguridad.
404                    std::fs::copy(&src, &dst).map(|_| ())
405                })?;
406                files += 1;
407            }
408        }
409        Ok::<(), std::io::Error>(())
410    })?;
411    Ok(files)
412}
413
414enum EntryKind {
415    Dir,
416    Symlink(PathBuf),
417    File(PathBuf),
418}
419
420fn walk<F>(root: &Path, f: &mut F) -> std::io::Result<()>
421where
422    F: FnMut(&Path, EntryKind) -> std::io::Result<()>,
423{
424    fn inner<F>(root: &Path, cur: &Path, f: &mut F) -> std::io::Result<()>
425    where
426        F: FnMut(&Path, EntryKind) -> std::io::Result<()>,
427    {
428        for entry in std::fs::read_dir(cur)? {
429            let entry = entry?;
430            let src = entry.path();
431            let rel = src.strip_prefix(root).unwrap();
432            let ft = entry.file_type()?;
433            if ft.is_symlink() {
434                let target = std::fs::read_link(&src)?;
435                f(rel, EntryKind::Symlink(target))?;
436            } else if ft.is_dir() {
437                f(rel, EntryKind::Dir)?;
438                inner(root, &src, f)?;
439            } else if ft.is_file() {
440                f(rel, EntryKind::File(src.clone()))?;
441            }
442        }
443        Ok(())
444    }
445    if !root.is_dir() {
446        return Err(std::io::Error::new(
447            std::io::ErrorKind::NotFound,
448            format!("artifact_dir no existe: {}", root.display()),
449        ));
450    }
451    inner(root, root, f)
452}
453
454#[derive(Debug, Default, Clone, serde::Serialize)]
455pub struct AppliedCounts {
456    pub source_patch: usize,
457    pub config_edit: usize,
458    pub init_rule: usize,
459    pub file_drop: usize,
460}
461
462#[derive(Debug, Clone, serde::Serialize)]
463pub struct VerifyCheck {
464    pub level: CheckLevel,
465    pub msg: String,
466}
467
468#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
469#[serde(rename_all = "kebab-case")]
470pub enum CheckLevel {
471    Pass,
472    Warn,
473    Fail,
474}
475
476impl VerifyCheck {
477    pub fn pass(msg: impl Into<String>) -> Self {
478        Self { level: CheckLevel::Pass, msg: msg.into() }
479    }
480    pub fn warn(msg: impl Into<String>) -> Self {
481        Self { level: CheckLevel::Warn, msg: msg.into() }
482    }
483    /// [H1c] Un check que FALLÓ. Hoy lo produce el gate de evidencia: la mutación no se propone.
484    pub fn fail(msg: impl Into<String>) -> Self {
485        Self { level: CheckLevel::Fail, msg: msg.into() }
486    }
487}
488
489/// El resultado del bucle. El humano lo lee para decidir `takana commit <overlay_id>` (si
490/// está en modo Overlay) o `takana discard`.
491#[derive(Debug, Clone, serde::Serialize)]
492pub struct Proposal {
493    pub intent: String,
494    pub swm: Swm,
495    /// `Some` si abrimos un overlay. En modo Prefix, `None`.
496    pub overlay_id: Option<String>,
497    pub prefix: Option<PathBuf>,
498    pub applied: AppliedCounts,
499    pub skipped_source_patches: usize,
500    pub checks: Vec<VerifyCheck>,
501    /// Si la propuesta provino del bucle de auto-reparación, la cadena completa de
502    /// intentos. La última entrada corresponde a este `Proposal`. Para corridas sin
503    /// auto-reparación queda vacío.
504    #[serde(default, skip_serializing_if = "Vec::is_empty")]
505    pub repair_chain: Vec<RepairAttempt>,
506}
507
508/// Un intento dentro del bucle de auto-reparación. Sirve para que el humano vea
509/// cómo evolucionó la intención: original → reparación A → reparación B …
510#[derive(Debug, Clone, serde::Serialize)]
511pub struct RepairAttempt {
512    pub attempt: u32,
513    pub intent: String,
514    /// `Some` cuando el ciclo abrió un overlay; preservado para que el humano pueda hacer
515    /// `takana discard <id>` sobre intentos intermedios si lo desea.
516    pub overlay_id: Option<String>,
517    /// Si tras este intento llegó un `Crashed`, el servicio y código. `None` ⇒ estabilizado.
518    pub triggered_crash: Option<CrashInfo>,
519}
520
521#[derive(Debug, Clone, serde::Serialize)]
522pub struct CrashInfo {
523    pub service: String,
524    pub code: i32,
525}
526
527/// Política del bucle de auto-reparación. La IA NUNCA decide por sí sola seguir
528/// reintentando: la política la fija el llamador (CLI o cliente) y acota el blast-radius.
529#[derive(Clone)]
530pub struct RepairPolicy {
531    /// Máximo de intentos en total (incluyendo el primero). Mínimo efectivo: 1.
532    pub max_attempts: u32,
533    /// Ventana de espera tras aplicar para detectar un `Crashed` por el bus.
534    pub crash_window: Duration,
535    /// Socket del bus de eventos. Si `None`, el orquestador no escucha y devuelve
536    /// el `Proposal` tras la primera aplicación (equivalente a `run`).
537    pub event_sock: Option<PathBuf>,
538    /// Convierte un crash `(service, code)` en la próxima intención. Por defecto:
539    /// `"repair service <s> crashed code <c>"`.
540    pub format_intent: fn(&str, i32) -> String,
541}
542
543impl std::fmt::Debug for RepairPolicy {
544    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
545        f.debug_struct("RepairPolicy")
546            .field("max_attempts", &self.max_attempts)
547            .field("crash_window", &self.crash_window)
548            .field("event_sock", &self.event_sock)
549            .field("format_intent", &"fn(&str, i32) -> String")
550            .finish()
551    }
552}
553
554pub fn default_crash_intent(service: &str, code: i32) -> String {
555    format!("repair service {service} crashed code {code}")
556}
557
558impl Default for RepairPolicy {
559    fn default() -> Self {
560        Self {
561            max_attempts: 3,
562            crash_window: Duration::from_secs(5),
563            event_sock: None,
564            format_intent: default_crash_intent,
565        }
566    }
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572    use crate::translator::MockTranslator;
573    use takana_core::swm::{Base, Mutation};
574    use std::collections::BTreeMap;
575
576    fn baseref() -> BaseRef {
577        BaseRef { distro_version: "2026-06-06".into(), pins: BTreeMap::new() }
578    }
579
580    fn swm_config_only() -> Swm {
581        Swm {
582            swm_version: 1,
583            base: Base { distro_version: "2026-06-06".into(), pins: BTreeMap::new() },
584            mutations: vec![Mutation::ConfigEdit {
585                file: "/etc/network.conf".into(),
586                inline_diff: "- DHCP=yes\n+ STATIC=1\n".into(),
587            }],
588            signature: None,
589        }
590    }
591
592    #[test]
593    fn orchestrator_applies_config_edit_to_prefix() {
594        let d = tempfile::tempdir().unwrap();
595        let prefix = d.path().join("prefix");
596        std::fs::create_dir_all(prefix.join("etc")).unwrap();
597        std::fs::write(prefix.join("etc/network.conf"), "DHCP=yes\nMTU=1500\n").unwrap();
598
599        let t = MockTranslator::empty().with_intent("set static ip", swm_config_only());
600        let o = Orchestrator::new(
601            t,
602            baseref(),
603            ApplyTarget::Prefix { prefix: prefix.clone() },
604            CompileMode::Skip,
605        );
606        let p = o.run("set static ip").unwrap();
607        assert_eq!(p.applied.config_edit, 1);
608        assert!(p.overlay_id.is_none());
609        assert_eq!(p.checks.len(), 1);
610        assert_eq!(p.checks[0].level, CheckLevel::Pass);
611
612        let got = std::fs::read_to_string(prefix.join("etc/network.conf")).unwrap();
613        assert_eq!(got, "STATIC=1\nMTU=1500\n");
614    }
615
616    #[test]
617    fn orchestrator_rejects_base_mismatch() {
618        let mut swm = swm_config_only();
619        swm.base.distro_version = "2027-01-01".into();
620        let t = MockTranslator::empty().with_intent("x", swm);
621        let d = tempfile::tempdir().unwrap();
622        let o = Orchestrator::new(
623            t,
624            baseref(),
625            ApplyTarget::Prefix { prefix: d.path().to_path_buf() },
626            CompileMode::Skip,
627        );
628        let err = o.run("x").unwrap_err().to_string();
629        assert!(err.contains("base incompatible"), "{err}");
630    }
631
632    #[test]
633    fn orchestrator_skips_source_patch_with_warn() {
634        let swm = Swm {
635            swm_version: 1,
636            base: Base { distro_version: "2026-06-06".into(), pins: BTreeMap::new() },
637            mutations: vec![Mutation::SourcePatch {
638                repo: Some("git://x".into()),
639                commit: Some("abc".into()),
640                tarball: None,
641                sha256: None,
642                strip_components: None,
643                patch: None,
644                patches: Vec::new(),
645                patch_url: None,
646                build: takana_core::swm::SwmBuild {
647                    compiler: "zig-cc".into(),
648                    target: "x86_64-linux-musl".into(),
649                    link: "static".into(),
650                    flags: vec![],
651                    phases: Default::default(),
652                    zig_version: None,
653                    strip_debug: None,
654                },
655                target_bin: "/bin/x".into(),
656                expected_hash: None,
657                deps: Default::default(),
658                evidence: Default::default(),
659                slots: Default::default(),
660            }],
661            signature: None,
662        };
663        let t = MockTranslator::empty().with_intent("compile x", swm);
664        let d = tempfile::tempdir().unwrap();
665        let o = Orchestrator::new(
666            t,
667            baseref(),
668            ApplyTarget::Prefix { prefix: d.path().to_path_buf() },
669            CompileMode::Skip,
670        );
671        let p = o.run("compile x").unwrap();
672        assert_eq!(p.skipped_source_patches, 1);
673        assert_eq!(p.checks[0].level, CheckLevel::Warn);
674    }
675
676    #[test]
677    fn orchestrator_translates_unknown_intent_errors() {
678        let t = MockTranslator::empty();
679        let d = tempfile::tempdir().unwrap();
680        let o = Orchestrator::new(
681            t,
682            baseref(),
683            ApplyTarget::Prefix { prefix: d.path().to_path_buf() },
684            CompileMode::Skip,
685        );
686        let err = o.run("haz lo que tú quieras").unwrap_err().to_string();
687        assert!(err.contains("no reconocida"), "{err}");
688    }
689}