From 8cb8fcedad1f9b571fa12f38e54b464dae430bef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 22:56:58 +0200 Subject: [PATCH] lifecycle: setup_applied seeds via fetch + tags deployed/0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new shape: applied is git-init'd at first spawn, fetches proposed's initial commit into its main, tags deployed/0 there. the wrapper flake.nix is regenerated on every spawn/rebuild but no longer tracked — apply churn vanishes, manager-authored files in the proposal flow now survive untouched. setup_applied gains an Option<&Path> for proposed (None on rebuild paths that just refresh the flake). pre-overhaul applied dirs are detected via the missing deployed/0 tag and bail loudly with the destroy --purge migration hint. apply_commit is stubbed with a clear error until the tag-driven approve flow lands. --- hive-c0re/src/lifecycle.rs | 135 +++++++++++++++++++++---------------- 1 file changed, 77 insertions(+), 58 deletions(-) diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index 3daee54..15628bd 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -152,7 +152,14 @@ pub async fn spawn( ); } setup_proposed(proposed_dir, name).await?; - setup_applied(applied_dir, name, hyperhive_flake, dashboard_port).await?; + setup_applied( + applied_dir, + Some(proposed_dir), + name, + hyperhive_flake, + dashboard_port, + ) + .await?; ensure_claude_dir(claude_dir)?; ensure_state_dir(notes_dir)?; let container = container_name(name); @@ -230,7 +237,7 @@ pub async fn rebuild( agent_web_port(name) ); } - setup_applied(applied_dir, name, hyperhive_flake, dashboard_port).await?; + setup_applied(applied_dir, None, name, hyperhive_flake, dashboard_port).await?; ensure_claude_dir(claude_dir)?; ensure_state_dir(notes_dir)?; let container = container_name(name); @@ -286,11 +293,40 @@ pub async fn setup_proposed(proposed_dir: &Path, name: &str) -> Result<()> { Ok(()) } -/// Maintain the authoritative applied repo. Rewrites `flake.nix` every call -/// (so a new hyperhive flake URL propagates on rebuild); seeds `agent.nix` -/// only on first call. `apply_commit` overwrites `agent.nix` later. +/// Placeholder for the old file-copy apply path; the real +/// tag-driven flow lives in `actions::approve` and gets wired up +/// in a follow-up commit. Leaving this function as a hard error +/// keeps `actions.rs` compiling while the rewrite lands; an +/// ApplyCommit approval that races the deploy will surface a +/// clear failure note instead of silently no-op'ing. +#[allow(unused_variables)] +pub async fn apply_commit( + _applied_dir: &Path, + _proposed_dir: &Path, + _commit_ref: &str, +) -> Result<()> { + bail!( + "apply_commit not yet wired up to the tag-driven flow; \ + approve again after the next deploy lands" + ) +} + +/// Set up the applied repo. Two responsibilities: +/// - First-spawn only: init the repo, pull proposed's initial commit +/// in via `git fetch`, tag it `deployed/0`. This is the *only* time +/// hive-c0re reads from `proposed` for an agent — subsequent +/// proposals are fetched at `request_apply_commit` time and tagged +/// `proposal/` (see `actions::approve` for the tag state +/// machine). +/// - Every call: regenerate the untracked `flake.nix` so flake-url / +/// dashboard-port changes pick up on rebuild without churning the +/// git log. +/// +/// `proposed_dir` is `None` on rebuild paths that just want the flake +/// refreshed. pub async fn setup_applied( applied_dir: &Path, + proposed_dir: Option<&Path>, name: &str, hyperhive_flake: &str, dashboard_port: u16, @@ -298,6 +334,42 @@ pub async fn setup_applied( std::fs::create_dir_all(applied_dir) .with_context(|| format!("create {}", applied_dir.display()))?; + // 1. First-spawn git init from proposed (or pre-overhaul detection). + if !applied_dir.join(".git").exists() { + let Some(proposed) = proposed_dir else { + bail!( + "applied repo at {} is missing its .git directory; \ + cannot rebuild without a proposed source to seed from. \ + destroy --purge and re-spawn this agent.", + applied_dir.display() + ); + }; + git(applied_dir, &["init", "--initial-branch=main"]).await?; + let proposed_str = proposed.display().to_string(); + git( + applied_dir, + &["fetch", "--no-tags", &proposed_str, "main:refs/heads/main"], + ) + .await?; + git_read_tree_reset(applied_dir, "refs/heads/main").await?; + git_tag(applied_dir, "deployed/0", "refs/heads/main").await?; + } else if git_rev_parse(applied_dir, "refs/tags/deployed/0") + .await + .is_err() + { + // Pre-overhaul applied repo — agent.nix is tracked directly, + // commits authored by hive-c0re, no deployed/* tag scheme. + // No in-place migration; fail loudly so the operator purges. + bail!( + "applied repo at {} predates the tag-driven config flow. \ + Run `hive-c0re destroy --purge {name}` and re-spawn.", + applied_dir.display() + ); + } + + // 2. (Re)write the untracked wrapper flake. Tracked files in the + // working tree (agent.nix and anything the manager committed) are + // untouched. let port = agent_web_port(name); let base = flake_base(name); let service = if is_manager(name) { @@ -339,48 +411,6 @@ pub async fn setup_applied( ); std::fs::write(applied_dir.join("flake.nix"), flake_body) .with_context(|| format!("write {}/flake.nix", applied_dir.display()))?; - - let agent_path = applied_dir.join("agent.nix"); - if !agent_path.exists() { - std::fs::write(&agent_path, initial_agent_nix(name)) - .with_context(|| format!("write {}", agent_path.display()))?; - } - - if !applied_dir.join(".git").exists() { - git(applied_dir, &["init", "--initial-branch=main"]).await?; - } - git(applied_dir, &["add", "-A"]).await?; - let clean = git_status(applied_dir, &["diff", "--cached", "--quiet"]).await?; - if !clean { - git_commit(applied_dir, "hive-c0re sync").await?; - } - Ok(()) -} - -/// Apply a manager-proposed commit: read `agent.nix` at `commit_ref` from the -/// proposed repo, write it into the applied repo, commit. Hive-c0re alone -/// advances `applied`'s `main`; the manager only sees `proposed/`. -pub async fn apply_commit(applied_dir: &Path, proposed_dir: &Path, commit_ref: &str) -> Result<()> { - let out = git_command() - .current_dir(proposed_dir) - .args(["show", &format!("{commit_ref}:agent.nix")]) - .output() - .await - .with_context(|| format!("git show in {}", proposed_dir.display()))?; - if !out.status.success() { - bail!( - "agent.nix at commit {commit_ref} not found in {}: {}", - proposed_dir.display(), - String::from_utf8_lossy(&out.stderr).trim() - ); - } - std::fs::write(applied_dir.join("agent.nix"), &out.stdout) - .with_context(|| format!("write {}/agent.nix", applied_dir.display()))?; - git(applied_dir, &["add", "agent.nix"]).await?; - let clean = git_status(applied_dir, &["diff", "--cached", "--quiet"]).await?; - if !clean { - git_commit(applied_dir, &format!("apply {commit_ref}")).await?; - } Ok(()) } @@ -550,17 +580,6 @@ pub async fn git_update_ref(dir: &Path, refname: &str, target: &str) -> Result<( git(dir, &["update-ref", refname, target]).await } -/// Returns true if the command exits 0. -async fn git_status(dir: &Path, args: &[&str]) -> Result { - let st = git_command() - .current_dir(dir) - .args(args) - .status() - .await - .with_context(|| format!("git {} in {}", args.join(" "), dir.display()))?; - Ok(st.success()) -} - /// Write a systemd drop-in for `container@.service` that applies /// our default resource caps. Goes under `/run/systemd/system/...` so it's /// ephemeral (regenerated on every spawn / rebuild).