diff --git a/crates/hammer-agent/src/orchestrator.rs b/crates/hammer-agent/src/orchestrator.rs index 2d7cba8b..de4be4ce 100644 --- a/crates/hammer-agent/src/orchestrator.rs +++ b/crates/hammer-agent/src/orchestrator.rs @@ -154,6 +154,8 @@ impl Orchestrator { Mutation::SourcePatch { repo, commit, + tarball, + sha256, patch, patch_url: _, build, @@ -176,6 +178,8 @@ impl Orchestrator { name: name.clone(), repo: repo.clone(), commit: commit.clone(), + tarball: tarball.clone(), + sha256: sha256.clone(), patch: patch.clone(), compiler: build.compiler.clone(), target: build.target.clone(), @@ -567,8 +571,10 @@ mod tests { swm_version: 1, base: Base { distro_version: "2026-06-06".into(), pins: BTreeMap::new() }, mutations: vec![Mutation::SourcePatch { - repo: "git://x".into(), - commit: "abc".into(), + repo: Some("git://x".into()), + commit: Some("abc".into()), + tarball: None, + sha256: None, patch: None, patch_url: None, build: hammer_core::swm::SwmBuild { diff --git a/crates/hammer-agent/src/translator_claude.rs b/crates/hammer-agent/src/translator_claude.rs index 0f146d2f..7a033ff6 100644 --- a/crates/hammer-agent/src/translator_claude.rs +++ b/crates/hammer-agent/src/translator_claude.rs @@ -146,6 +146,8 @@ const SYSTEM_PROMPT: &str = r#"Eres un traductor que convierte intenciones human 3. source_patch — compila desde fuente y la inyecta. {"type":"source_patch","repo":"git://...","commit":"","build":{"compiler":"zig-cc","target":"x86_64-linux-musl","link":"static","flags":[]},"target_bin":"/usr/bin/foo"} + - Origen git (repo+commit) O tarball (tarball+sha256), exactamente uno; p. ej. + {"type":"source_patch","tarball":"https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz","sha256":"","build":{...},"target_bin":"/usr/bin/grep"} - `target_bin` ruta absoluta del binario producido. 4. init_rule — registra un servicio. diff --git a/crates/hammer-agent/tests/client_against_stub_bus.rs b/crates/hammer-agent/tests/client_against_stub_bus.rs index 000580e4..009530be 100644 --- a/crates/hammer-agent/tests/client_against_stub_bus.rs +++ b/crates/hammer-agent/tests/client_against_stub_bus.rs @@ -84,7 +84,10 @@ fn stub_serve(stream: UnixStream, rx_async: std::sync::mpsc::Receiver) { let response = match cmd { Command::Compile { recipe } => Event::BuildReady { recipe: recipe.name.clone(), - artifact: format!("b3:stub-{}", recipe.commit), + artifact: format!( + "b3:stub-{}", + recipe.commit.as_deref().or(recipe.sha256.as_deref()).unwrap_or("nosrc") + ), }, Command::Query { what, .. } => Event::QueryResult { what, @@ -133,8 +136,10 @@ fn client_handshake_compile_and_async_modified() { let recipe = RecipeInline { name: "grep".into(), - repo: "git://example/grep.git".into(), - commit: "abc123".into(), + repo: Some("git://example/grep.git".into()), + commit: Some("abc123".into()), + tarball: None, + sha256: None, patch: None, compiler: "zig-cc".into(), target: "x86_64-linux-musl".into(), diff --git a/crates/hammer-build/src/swm_bridge.rs b/crates/hammer-build/src/swm_bridge.rs index f88cf643..511dce87 100644 --- a/crates/hammer-build/src/swm_bridge.rs +++ b/crates/hammer-build/src/swm_bridge.rs @@ -27,33 +27,54 @@ pub fn build_source_patch( store: &Store, scratch_root: Option<&Path>, ) -> hammer_core::Result { - let (repo, commit, patch, patch_url, build_cfg, target_bin, expected) = match mutation { - Mutation::SourcePatch { - repo, - commit, - patch, - patch_url, - build, - target_bin, - expected_hash, - } => (repo, commit, patch, patch_url, build, target_bin, expected_hash), - _ => { - return Err(hammer_core::Error::Recipe( - "build_source_patch: la mutación no es 'source_patch'".into(), - )); - } - }; + let (repo, commit, tarball, sha256, patch, patch_url, build_cfg, target_bin, expected) = + match mutation { + Mutation::SourcePatch { + repo, + commit, + tarball, + sha256, + patch, + patch_url, + build, + target_bin, + expected_hash, + } => ( + repo, commit, tarball, sha256, patch, patch_url, build, target_bin, expected_hash, + ), + _ => { + return Err(hammer_core::Error::Recipe( + "build_source_patch: la mutación no es 'source_patch'".into(), + )); + } + }; + + // git xor tarball — misma regla que el schema (`swm_source_kind`). + let source_kind = hammer_core::swm::swm_source_kind( + repo.as_deref(), + commit.as_deref(), + tarball.as_deref(), + sha256.as_deref(), + ) + .map_err(hammer_core::Error::Recipe)?; let scratch = scratch_root.unwrap_or(&cfg.work_root).to_path_buf(); let recipe_dir = scratch.join("swm-recipes"); std::fs::create_dir_all(&recipe_dir)?; - // Materializamos el patch a disco bajo un nombre derivado del commit (estable: si el .swm - // se reaplica, reusamos el mismo archivo y el hash de la receta no depende de + // Clave estable del origen para nombrar artefactos a disco: el commit (git) o el + // sha256 (tarball). Idéntica reaplicación ⇒ mismo nombre ⇒ hash de receta determinista. + let source_key: &str = match &source_kind { + hammer_core::SourceKind::Git { commit, .. } => commit, + hammer_core::SourceKind::Tarball { sha256, .. } => sha256, + }; + + // Materializamos el patch a disco bajo un nombre derivado de la clave de origen (estable: + // si el .swm se reaplica, reusamos el mismo archivo y el hash de la receta no depende de // aleatoriedad). Origen: inline (`patch`) o remoto (`patch_url`); el schema garantiza que // no vengan ambos. let mut patches: Vec = Vec::new(); - let patch_path = recipe_dir.join(format!("{commit}.patch")); + let patch_path = recipe_dir.join(format!("{source_key}.patch")); if let Some(text) = patch { std::fs::write(&patch_path, text.as_bytes())?; patches.push(patch_path.to_string_lossy().into_owned()); @@ -64,14 +85,7 @@ pub fn build_source_patch( } let name = derive_name(target_bin); - let recipe = synthesize_recipe( - &name, - commit, - repo, - build_cfg, - patches, - &recipe_dir, - )?; + let recipe = synthesize_recipe(&name, &source_kind, build_cfg, patches, &recipe_dir)?; let hash = build(&recipe, cfg, store)?; if let Some(want) = expected { @@ -102,24 +116,33 @@ fn derive_name(target_bin: &str) -> String { fn synthesize_recipe( name: &str, - commit: &str, - repo: &str, + source: &hammer_core::SourceKind<'_>, build_cfg: &SwmBuild, patches: Vec, base_dir: &Path, ) -> hammer_core::Result { let compiler = parse_compiler(&build_cfg.compiler)?; let link = parse_link(&build_cfg.link)?; + // Sección `[source]` y sufijo de versión según el modo de origen. El validador de + // `Source::kind` ya garantiza git xor tarball; acá sólo emitimos el TOML correspondiente. + let (version_key, source_block) = match source { + hammer_core::SourceKind::Git { repo, commit } => ( + *commit, + format!("repo = \"{repo}\"\ncommit = \"{commit}\"\n"), + ), + hammer_core::SourceKind::Tarball { url, sha256 } => ( + *sha256, + format!("tarball = \"{url}\"\nsha256 = \"{sha256}\"\n"), + ), + }; // Construimos la receta vía TOML para reusar las defaults y el validador de `Source`. let toml_text = format!( r#" name = "{name}" -version = "swm-{commit_short}" +version = "swm-{version_short}" [source] -repo = "{repo}" -commit = "{commit}" - +{source_block} [build] compiler = "{compiler}" target = "{target}" @@ -127,9 +150,8 @@ link = "{link}" flags = [] "#, name = name, - commit_short = &commit[..commit.len().min(12)], - repo = repo, - commit = commit, + version_short = &version_key[..version_key.len().min(12)], + source_block = source_block, compiler = compiler.as_str(), target = build_cfg.target, link = link.as_str(), @@ -199,8 +221,10 @@ mod tests { let d = tempfile::tempdir().unwrap(); let r = synthesize_recipe( "grep", - "a1b2c3d4e5f6a7b8c9", - "git://example/grep.git", + &hammer_core::SourceKind::Git { + repo: "git://example/grep.git", + commit: "a1b2c3d4e5f6a7b8c9", + }, &fake_swm_build(), vec![], d.path(), @@ -218,6 +242,31 @@ mod tests { } } + #[test] + fn synthesize_recipe_tarball() { + let d = tempfile::tempdir().unwrap(); + let r = synthesize_recipe( + "grep", + &hammer_core::SourceKind::Tarball { + url: "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz", + sha256: "abcdef0123456789", + }, + &fake_swm_build(), + vec![], + d.path(), + ) + .unwrap(); + assert_eq!(r.name, "grep"); + assert!(r.version.starts_with("swm-abcdef012345")); + match r.source.kind().unwrap() { + hammer_core::SourceKind::Tarball { url, sha256 } => { + assert_eq!(url, "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz"); + assert_eq!(sha256, "abcdef0123456789"); + } + _ => panic!("modo tarball esperado"), + } + } + #[test] fn parse_compiler_and_link() { assert_eq!(parse_compiler("zig-cc").unwrap(), Compiler::ZigCc); @@ -249,8 +298,10 @@ mod tests { std::fs::write(&patch_src, b"--- a\n+++ b\n").unwrap(); let commit = "deadbeefcafe"; let m = Mutation::SourcePatch { - repo: "git://x".into(), - commit: commit.into(), + repo: Some("git://x".into()), + commit: Some(commit.into()), + tarball: None, + sha256: None, patch: None, patch_url: Some(format!("file://{}", patch_src.display())), build: fake_swm_build(), diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index 7f69d9fb..3a208779 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -1054,25 +1054,14 @@ fn build_export_mutations( .map(|p| p.display().to_string()) .unwrap_or_else(|| "/".into()); - // Convertimos la Recipe a los campos que SourcePatch necesita. - let (repo, commit) = match recipe.source.kind() { + // Convertimos la Recipe a los campos de origen que SourcePatch necesita + // (git xor tarball — ambos se modelan ya, sin caer a file_drop). + let (repo, commit, tarball, sha256) = match recipe.source.kind() { Ok(hammer_core::SourceKind::Git { repo, commit }) => { - (repo.to_string(), commit.to_string()) + (Some(repo.to_string()), Some(commit.to_string()), None, None) } Ok(hammer_core::SourceKind::Tarball { url, sha256 }) => { - // SourcePatch sólo modela el modo git. Para tarballs caemos al - // fallback file_drop por evento — no perdemos completeness, sólo - // provenance fina. El humano lo ve por stderr. - eprintln!( - "warning: artefacto {art_hash} proviene de tarball ({url} sha256={sha256}); \ - `source_patch` aún no modela tarballs, hago file_drop" - ); - for p in &paths { - if let Some(ev) = last_by_path.get(p) { - fallback.push((p.clone(), *ev)); - } - } - continue; + (None, None, Some(url.to_string()), Some(sha256.to_string())) } Err(_) => { stats.warnings += 1; @@ -1105,6 +1094,8 @@ fn build_export_mutations( mutations.push(hammer_core::Mutation::SourcePatch { repo, commit, + tarball, + sha256, patch: patch_inline, patch_url: None, build: hammer_core::SwmBuild { @@ -1613,8 +1604,8 @@ flags = ["--enable-foo"] expected_hash, .. } => { - assert_eq!(repo, "git://example/grep.git"); - assert_eq!(commit, "deadbeef"); + assert_eq!(repo.as_deref(), Some("git://example/grep.git")); + assert_eq!(commit.as_deref(), Some("deadbeef")); assert_eq!(build.compiler, "zig-cc"); assert_eq!(build.link, "static"); assert_eq!(build.flags, vec!["--enable-foo".to_string()]); diff --git a/crates/hammer-core/src/proto.rs b/crates/hammer-core/src/proto.rs index 7afa8afa..d6733efd 100644 --- a/crates/hammer-core/src/proto.rs +++ b/crates/hammer-core/src/proto.rs @@ -63,8 +63,17 @@ pub enum Command { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RecipeInline { pub name: String, - pub repo: String, - pub commit: String, + // Origen git (modo histórico) **o** tarball — exactamente uno, igual que + // `swm::Mutation::SourcePatch`. `Option` con `serde(default)` para compat con clientes + // que sólo mandan `repo`+`commit`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub commit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tarball: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sha256: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub patch: Option, #[serde(default = "default_compiler")] @@ -208,8 +217,10 @@ mod tests { let c = Command::Compile { recipe: RecipeInline { name: "grep".into(), - repo: "git://x/grep.git".into(), - commit: "abc".into(), + repo: Some("git://x/grep.git".into()), + commit: Some("abc".into()), + tarball: None, + sha256: None, patch: None, compiler: "zig-cc".into(), target: "x86_64-linux-musl".into(), diff --git a/crates/hammer-core/src/swm.rs b/crates/hammer-core/src/swm.rs index da39ef41..3cd9a439 100644 --- a/crates/hammer-core/src/swm.rs +++ b/crates/hammer-core/src/swm.rs @@ -70,8 +70,19 @@ pub struct PinDiff { #[serde(tag = "type", rename_all = "snake_case")] pub enum Mutation { SourcePatch { - repo: String, - commit: String, + // Origen git (modo histórico) **o** tarball — exactamente uno, igual que + // [`crate::recipe::Source`]. Antes sólo se modelaba git y los tarballs caían a + // `file_drop`; ahora `hammer export` los reconstruye como source_patch. Los campos + // son `Option` (con `serde(default)`) para que los `.swm` git existentes —que sólo + // traen `repo`+`commit`— sigan parseando sin cambios. + #[serde(default, skip_serializing_if = "Option::is_none")] + repo: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + commit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + tarball: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + sha256: Option, #[serde(default, skip_serializing_if = "Option::is_none")] patch: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -200,18 +211,58 @@ impl Swm { } } +/// Resuelve el modo de origen de un `source_patch` (git **xor** tarball) desde sus campos +/// opcionales, con la misma regla que [`crate::recipe::Source::kind`]. Centraliza la +/// validación para que `verify_schema`, el lab y el export compartan una sola fuente de verdad. +pub fn swm_source_kind<'a>( + repo: Option<&'a str>, + commit: Option<&'a str>, + tarball: Option<&'a str>, + sha256: Option<&'a str>, +) -> Result, String> { + use crate::recipe::SourceKind; + match (repo, commit, tarball, sha256) { + (Some(repo), Some(commit), None, None) => Ok(SourceKind::Git { repo, commit }), + (None, None, Some(url), Some(sha256)) => Ok(SourceKind::Tarball { url, sha256 }), + (Some(_), Some(_), Some(_), _) | (Some(_), Some(_), _, Some(_)) => { + Err("source_patch: usa repo+commit O tarball+sha256, no ambos".into()) + } + _ => Err("source_patch: faltan campos; necesito (repo+commit) o (tarball+sha256)".into()), + } +} + impl Mutation { /// Sanity por mutación. Cada `type` tiene precondiciones distintas; la validación cruzada /// (p. ej. "el `target_bin` apunta a un dir gestionado") la decide quien aplica, no el /// schema. pub fn verify_schema(&self) -> Result<(), String> { + use crate::recipe::SourceKind; match self { - Mutation::SourcePatch { repo, commit, patch, patch_url, target_bin, .. } => { - if repo.is_empty() { - return Err("source_patch: 'repo' vacío".into()); - } - if commit.is_empty() { - return Err("source_patch: 'commit' vacío".into()); + Mutation::SourcePatch { + repo, commit, tarball, sha256, patch, patch_url, target_bin, .. + } => { + match swm_source_kind( + repo.as_deref(), + commit.as_deref(), + tarball.as_deref(), + sha256.as_deref(), + )? { + SourceKind::Git { repo, commit } => { + if repo.is_empty() { + return Err("source_patch: 'repo' vacío".into()); + } + if commit.is_empty() { + return Err("source_patch: 'commit' vacío".into()); + } + } + SourceKind::Tarball { url, sha256 } => { + if url.is_empty() { + return Err("source_patch: 'tarball' vacío".into()); + } + if sha256.is_empty() { + return Err("source_patch: 'sha256' vacío".into()); + } + } } if patch.is_some() && patch_url.is_some() { return Err( @@ -422,8 +473,10 @@ mutations: swm_version: 1, base: base_v("x", &[]), mutations: vec![Mutation::SourcePatch { - repo: "git://x".into(), - commit: "abc".into(), + repo: Some("git://x".into()), + commit: Some("abc".into()), + tarball: None, + sha256: None, patch: None, patch_url: None, build: SwmBuild { @@ -439,4 +492,74 @@ mutations: }; swm.verify_schema().unwrap(); } + + #[test] + fn verify_schema_source_patch_tarball() { + let swm = Swm { + swm_version: 1, + base: base_v("x", &[]), + mutations: vec![Mutation::SourcePatch { + repo: None, + commit: None, + tarball: Some("https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz".into()), + sha256: Some("deadbeef".into()), + patch: None, + patch_url: None, + build: SwmBuild { + compiler: "zig-cc".into(), + target: "x86_64-linux-musl".into(), + link: "static".into(), + flags: vec![], + }, + target_bin: "/bin/grep".into(), + expected_hash: None, + }], + signature: None, + }; + swm.verify_schema().unwrap(); + } + + #[test] + fn verify_schema_source_patch_rechaza_git_y_tarball_mezclados() { + let m = Mutation::SourcePatch { + repo: Some("git://x".into()), + commit: Some("abc".into()), + tarball: Some("https://x/a.tar.gz".into()), + sha256: Some("deadbeef".into()), + patch: None, + patch_url: None, + build: SwmBuild { + compiler: "zig-cc".into(), + target: "x86_64-linux-musl".into(), + link: "static".into(), + flags: vec![], + }, + target_bin: "/bin/x".into(), + expected_hash: None, + }; + let err = m.verify_schema().unwrap_err(); + assert!(err.contains("no ambos"), "mensaje inesperado: {err}"); + } + + #[test] + fn verify_schema_source_patch_rechaza_sin_origen() { + let m = Mutation::SourcePatch { + repo: None, + commit: None, + tarball: None, + sha256: None, + patch: None, + patch_url: None, + build: SwmBuild { + compiler: "zig-cc".into(), + target: "x86_64-linux-musl".into(), + link: "static".into(), + flags: vec![], + }, + target_bin: "/bin/x".into(), + expected_hash: None, + }; + let err = m.verify_schema().unwrap_err(); + assert!(err.contains("faltan campos"), "mensaje inesperado: {err}"); + } } diff --git a/crates/hammerd/src/bus.rs b/crates/hammerd/src/bus.rs index 7a538f81..0f2e98f6 100644 --- a/crates/hammerd/src/bus.rs +++ b/crates/hammerd/src/bus.rs @@ -362,6 +362,8 @@ fn run_compile(recipe: RecipeInline, store_root: PathBuf, tx: Sender) { let mutation = hammer_core::swm::Mutation::SourcePatch { repo: recipe.repo, commit: recipe.commit, + tarball: recipe.tarball, + sha256: recipe.sha256, patch: recipe.patch, patch_url: None, build: hammer_core::swm::SwmBuild { @@ -493,8 +495,10 @@ caps = ["query", "compile", "inject", "inject-real", "init"] t_of(&Command::Compile { recipe: RecipeInline { name: "x".into(), - repo: "g".into(), - commit: "c".into(), + repo: Some("g".into()), + commit: Some("c".into()), + tarball: None, + sha256: None, patch: None, compiler: "zig-cc".into(), target: "t".into(), diff --git a/crates/hammerd/tests/bus_e2e.rs b/crates/hammerd/tests/bus_e2e.rs index 6a80c8b0..12799879 100644 --- a/crates/hammerd/tests/bus_e2e.rs +++ b/crates/hammerd/tests/bus_e2e.rs @@ -138,8 +138,10 @@ fn missing_cap_returns_error_no_cap() { &Command::Compile { recipe: RecipeInline { name: "x".into(), - repo: "git://x".into(), - commit: "abc".into(), + repo: Some("git://x".into()), + commit: Some("abc".into()), + tarball: None, + sha256: None, patch: None, compiler: "zig-cc".into(), target: "x86_64-linux-musl".into(), diff --git a/docs/10-roadmap.md b/docs/10-roadmap.md index 4cd1f8f5..af9c1bc9 100644 --- a/docs/10-roadmap.md +++ b/docs/10-roadmap.md @@ -94,8 +94,10 @@ pre-requisito de validación. - [x] Provenance en `export`: mapa artefacto→receta vía sidecar `.hammer/recipe.toml` que `hammer-build::build` escribe dentro del artefacto antes de sellar. `hammer export` agrupa eventos por `artifact_hash` y emite UN `source_patch` por grupo cuya receta - sea recuperable; lo demás cae al fallback `file_drop`. Source `git` modelado; - `tarball` cae a file_drop con warning (pendiente extender SourcePatch). + sea recuperable; lo demás cae al fallback `file_drop`. Source `git` **y `tarball`** + modelados: `Mutation::SourcePatch` (y `RecipeInline`) llevan campos `repo`/`commit` + **xor** `tarball`/`sha256` (resueltos por `swm::swm_source_kind`, misma regla que + `recipe::Source::kind`); `hammer export` reconstruye ambos sin caer a `file_drop`. - [x] Firma `signature` (ed25519) y `TrustStore` local. `hammer_core::sign`: `KeyPair` (genera/carga/escribe claves), `TrustStore::load(dir)` (lee `*.ed25519.pub`), `Swm::verify_signature(&trust) → SigStatus` (`trusted`/`unknown-key`/`bad-sig`/