From e26143a4121a4a1bf30175782d32b643c3310bfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 23:18:17 +0200 Subject: [PATCH] dashboard: diff against applied/proposal/, prefer fetched_sha MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit approval_diff now runs git diff refs/heads/main..refs/tags/ proposal/ against the applied repo instead of cobbling a single-file diff from proposed. consequences: multi-file proposals show every change, manager amendments in proposed cannot lie about what'll be deployed, no-op proposals render an explicit '(proposal matches currently-deployed tree)'. displayed sha prefers fetched_sha (hive-c0re-vouched) and falls back to commit_ref only for the brief pre-fetch window. unified_diff helper + similar dep dropped — git diff is the source of truth now. dead-code allows on the lifecycle git helpers + approvals.set_fetched_sha come off since all are wired up. readme picks up the tag flow + /applied RO mount. --- Cargo.lock | 7 ----- README.md | 23 +++++++++++---- hive-c0re/Cargo.toml | 1 - hive-c0re/src/approvals.rs | 1 - hive-c0re/src/dashboard.rs | 59 ++++++++++++++++---------------------- hive-c0re/src/lifecycle.rs | 6 ---- 6 files changed, 42 insertions(+), 55 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eba3981..f09335f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -474,7 +474,6 @@ dependencies = [ "rusqlite", "serde", "serde_json", - "similar", "tokio", "tokio-stream", "tracing", @@ -986,12 +985,6 @@ dependencies = [ "libc", ] -[[package]] -name = "similar" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" - [[package]] name = "slab" version = "0.4.12" diff --git a/README.md b/README.md index 15d2926..614c3f0 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ host (NixOS, runs hive-c0re.service) └── nixos-containers (each bind-mounts its socket dir → /run/hive, │ credentials dir → /root/.claude, │ durable notes dir → /state; - │ manager additionally gets /agents RW) + │ manager additionally gets /agents RW + │ + /applied RO for the deployed-tag mirror) │ ├── hm1nd hive-m1nd serve : claude turn loop + │ MCP (send / recv / request_spawn / kill / start / @@ -52,10 +53,22 @@ auto-scroll with "↓ N new" pill; event history backfilled on page load; collapsible inbox + collapsible journald viewer + collapsible `agent.nix` viewer per agent on the dashboard. -Config changes flow the other way: manager edits `/agents//config/agent.nix` -(bind-mounted from the host's proposed repo) → commits → submits the sha as -an approval → operator clicks ◆ APPR0VE on the dashboard → hive-c0re copies -the file into the applied repo and `nixos-container update`s the agent. +Config changes flow the other way: manager edits files under +`/agents//config/` (`agent.nix` is the entry point, but arbitrary +sibling files in the commit are preserved) → commits → submits the sha +via `request_apply_commit`. Hive-c0re immediately fetches that commit +from the proposed repo into the applied repo and pins it as +`proposal/` — from this moment the proposal is immutable from the +manager's side. Operator clicks ◆ APPR0VE on the dashboard → hive-c0re +moves the working tree to the proposal, runs `nixos-container update`, +and either fast-forwards `applied/main` (tagging `deployed/`) or +annotates `failed/` with the build error and rolls back to the +previous deployed tree. Denials leave a `denied/` annotated tag +carrying the operator's note. The manager sees everything that +shipped (or didn't) via a read-only `/applied//.git` mirror inside +its container; `git show applied/deployed/` etc. is the audit +trail. See [`docs/approvals.md`](docs/approvals.md) for the full tag +state machine. For decisions the manager needs human signal on, `ask_operator(question, options?, multi?)` queues a free-text/checkbox/radio form on the dashboard; the answer arrives later as a `HelperEvent::OperatorAnswered` diff --git a/hive-c0re/Cargo.toml b/hive-c0re/Cargo.toml index f6b8a14..99e1874 100644 --- a/hive-c0re/Cargo.toml +++ b/hive-c0re/Cargo.toml @@ -14,7 +14,6 @@ hive-sh4re.workspace = true rusqlite.workspace = true serde.workspace = true serde_json.workspace = true -similar.workspace = true tokio.workspace = true tokio-stream.workspace = true tracing.workspace = true diff --git a/hive-c0re/src/approvals.rs b/hive-c0re/src/approvals.rs index f53aebf..abcd324 100644 --- a/hive-c0re/src/approvals.rs +++ b/hive-c0re/src/approvals.rs @@ -93,7 +93,6 @@ impl Approvals { /// Record the canonical sha hive-c0re fetched from the proposed repo /// into applied at submission time. Idempotent on identical values. - #[allow(dead_code)] // wired up by manager_server in the next commit pub fn set_fetched_sha(&self, id: i64, sha: &str) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 8942524..298ba3e 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -342,8 +342,12 @@ async fn build_approval_views(approvals: Vec) -> Vec { for a in approvals { out.push(match a.kind { hive_sh4re::ApprovalKind::ApplyCommit => { - let sha = a.commit_ref[..a.commit_ref.len().min(12)].to_owned(); - let diff = approval_diff(&a.agent, &a.commit_ref).await; + // Prefer the canonical fetched sha from applied; + // commit_ref is only the manager's claim and may be + // amended out from under us. + let displayed = a.fetched_sha.as_deref().unwrap_or(&a.commit_ref); + let sha = displayed[..displayed.len().min(12)].to_owned(); + let diff = approval_diff(&a.agent, a.id).await; ApprovalView { id: a.id, agent: a.agent, @@ -925,55 +929,40 @@ fn claude_has_session(dir: &Path) -> bool { .any(|e| e.file_type().is_ok_and(|t| t.is_file())) } -async fn approval_diff(agent: &str, commit_ref: &str) -> String { - let proposed = Coordinator::agent_proposed_dir(agent); - if !proposed.exists() { - return format!( - "(proposed dir {} does not exist — agent destroyed?)", - proposed.display() - ); +/// Multi-file unified diff between the currently-deployed tree and +/// the proposal for this approval. Runs against the applied repo +/// since the canonical proposal commit lives there (manager-side +/// amendments don't move it). Empty output means proposal == main — +/// a no-op approval. +async fn approval_diff(agent: &str, approval_id: i64) -> String { + let applied = Coordinator::agent_applied_dir(agent); + if !applied.join(".git").exists() { + return format!("(no applied git repo at {})", applied.display()); } - if !proposed.join(".git").exists() { - return format!("(no git repo at {})", proposed.display()); - } - let applied = Coordinator::agent_applied_dir(agent).join("agent.nix"); - let applied_text = std::fs::read_to_string(&applied).unwrap_or_default(); - match git_show(&proposed, commit_ref).await { - Ok(s) => unified_diff(&applied_text, &s), + let proposal_ref = format!("refs/tags/proposal/{approval_id}"); + match git_diff_main_to(&applied, &proposal_ref).await { + Ok(s) if s.is_empty() => "(proposal matches currently-deployed tree)".to_owned(), + Ok(s) => s, Err(e) => format!("(error: {e:#})"), } } -async fn git_show(proposed_dir: &Path, commit_ref: &str) -> Result { +async fn git_diff_main_to(applied_dir: &Path, target_ref: &str) -> Result { let out = lifecycle::git_command() - .current_dir(proposed_dir) - .args(["show", &format!("{commit_ref}:agent.nix")]) + .current_dir(applied_dir) + .args(["diff", &format!("refs/heads/main..{target_ref}")]) .output() .await - .with_context(|| { - format!( - "spawn `git show` in {} (HYPERHIVE_GIT={})", - proposed_dir.display(), - std::env::var("HYPERHIVE_GIT").unwrap_or_else(|_| "".into()), - ) - })?; + .with_context(|| format!("spawn `git diff` in {}", applied_dir.display()))?; if !out.status.success() { anyhow::bail!( - "git show {commit_ref}:agent.nix failed: {}", + "git diff main..{target_ref} failed: {}", String::from_utf8_lossy(&out.stderr).trim() ); } Ok(String::from_utf8_lossy(&out.stdout).into_owned()) } -fn unified_diff(applied: &str, proposed: &str) -> String { - let diff = similar::TextDiff::from_lines(applied, proposed); - diff.unified_diff() - .context_radius(3) - .header("applied", "proposed") - .to_string() -} - fn html_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index cba109a..9a1fed3 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -475,7 +475,6 @@ async fn git(dir: &Path, args: &[&str]) -> Result<()> { /// amendments / force-pushes in `src` no longer affect what gets /// built. Returns the resolved sha (which equals `sha` on success /// but normalised — short shas get expanded). -#[allow(dead_code)] // wired up by manager_server in a later commit pub async fn git_fetch_to_tag(dst: &Path, src: &Path, sha: &str, tag: &str) -> Result { let src_str = src.display().to_string(); let refspec = format!("{sha}:refs/tags/{tag}"); @@ -484,7 +483,6 @@ pub async fn git_fetch_to_tag(dst: &Path, src: &Path, sha: &str, tag: &str) -> R } /// Resolve `refname` (a tag, branch, or sha) in `dir` to its full sha. -#[allow(dead_code)] pub async fn git_rev_parse(dir: &Path, refname: &str) -> Result { let out = git_command() .current_dir(dir) @@ -505,7 +503,6 @@ pub async fn git_rev_parse(dir: &Path, refname: &str) -> Result { /// Plant a lightweight tag at `target`. Errors if the tag already /// exists — we want loud failures on id reuse, not silent /// overwrites. -#[allow(dead_code)] pub async fn git_tag(dir: &Path, name: &str, target: &str) -> Result<()> { git(dir, &["tag", name, target]).await } @@ -514,7 +511,6 @@ pub async fn git_tag(dir: &Path, name: &str, target: &str) -> Result<()> { /// `failed/` (body = build error) and `denied/` (body = /// operator note). Multi-line bodies handled via stdin so we don't /// have to escape anything. -#[allow(dead_code)] pub async fn git_tag_annotated(dir: &Path, name: &str, target: &str, body: &str) -> Result<()> { use tokio::io::AsyncWriteExt; let mut child = git_command() @@ -549,7 +545,6 @@ pub async fn git_tag_annotated(dir: &Path, name: &str, target: &str, body: &str) /// `deployed/*` while we let `nixos-container update` evaluate the /// candidate. On build failure callers reset back to HEAD; on /// success they fast-forward main to `target`. -#[allow(dead_code)] pub async fn git_read_tree_reset(dir: &Path, target: &str) -> Result<()> { git(dir, &["read-tree", "--reset", "-u", target]).await } @@ -557,7 +552,6 @@ pub async fn git_read_tree_reset(dir: &Path, target: &str) -> Result<()> { /// Hard-set a ref to `target`. Used to fast-forward `refs/heads/main` /// to the just-deployed proposal commit. Uses `update-ref`, not /// `branch -f`, so it works regardless of where HEAD currently sits. -#[allow(dead_code)] pub async fn git_update_ref(dir: &Path, refname: &str, target: &str) -> Result<()> { git(dir, &["update-ref", refname, target]).await }