lifecycle: setup_applied seeds via fetch + tags deployed/0

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.
This commit is contained in:
müde 2026-05-15 22:56:58 +02:00
parent 63ef69674b
commit 8cb8fcedad

View file

@ -152,7 +152,14 @@ pub async fn spawn(
); );
} }
setup_proposed(proposed_dir, name).await?; 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_claude_dir(claude_dir)?;
ensure_state_dir(notes_dir)?; ensure_state_dir(notes_dir)?;
let container = container_name(name); let container = container_name(name);
@ -230,7 +237,7 @@ pub async fn rebuild(
agent_web_port(name) 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_claude_dir(claude_dir)?;
ensure_state_dir(notes_dir)?; ensure_state_dir(notes_dir)?;
let container = container_name(name); let container = container_name(name);
@ -286,11 +293,40 @@ pub async fn setup_proposed(proposed_dir: &Path, name: &str) -> Result<()> {
Ok(()) Ok(())
} }
/// Maintain the authoritative applied repo. Rewrites `flake.nix` every call /// Placeholder for the old file-copy apply path; the real
/// (so a new hyperhive flake URL propagates on rebuild); seeds `agent.nix` /// tag-driven flow lives in `actions::approve` and gets wired up
/// only on first call. `apply_commit` overwrites `agent.nix` later. /// 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/<id>` (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( pub async fn setup_applied(
applied_dir: &Path, applied_dir: &Path,
proposed_dir: Option<&Path>,
name: &str, name: &str,
hyperhive_flake: &str, hyperhive_flake: &str,
dashboard_port: u16, dashboard_port: u16,
@ -298,6 +334,42 @@ pub async fn setup_applied(
std::fs::create_dir_all(applied_dir) std::fs::create_dir_all(applied_dir)
.with_context(|| format!("create {}", applied_dir.display()))?; .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 port = agent_web_port(name);
let base = flake_base(name); let base = flake_base(name);
let service = if is_manager(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) std::fs::write(applied_dir.join("flake.nix"), flake_body)
.with_context(|| format!("write {}/flake.nix", applied_dir.display()))?; .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(()) 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 git(dir, &["update-ref", refname, target]).await
} }
/// Returns true if the command exits 0.
async fn git_status(dir: &Path, args: &[&str]) -> Result<bool> {
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@<container>.service` that applies /// Write a systemd drop-in for `container@<container>.service` that applies
/// our default resource caps. Goes under `/run/systemd/system/...` so it's /// our default resource caps. Goes under `/run/systemd/system/...` so it's
/// ephemeral (regenerated on every spawn / rebuild). /// ephemeral (regenerated on every spawn / rebuild).