dashboard: diff against applied/proposal/<id>, prefer fetched_sha

approval_diff now runs git diff refs/heads/main..refs/tags/
proposal/<id> 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.
This commit is contained in:
müde 2026-05-15 23:18:17 +02:00
parent fc61cb9310
commit e26143a412
6 changed files with 42 additions and 55 deletions

7
Cargo.lock generated
View file

@ -474,7 +474,6 @@ dependencies = [
"rusqlite", "rusqlite",
"serde", "serde",
"serde_json", "serde_json",
"similar",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tracing", "tracing",
@ -986,12 +985,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "similar"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"

View file

@ -26,7 +26,8 @@ host (NixOS, runs hive-c0re.service)
└── nixos-containers (each bind-mounts its socket dir → /run/hive, └── nixos-containers (each bind-mounts its socket dir → /run/hive,
│ credentials dir → /root/.claude, │ credentials dir → /root/.claude,
│ durable notes dir → /state; │ 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 + ├── hm1nd hive-m1nd serve : claude turn loop +
│ MCP (send / recv / request_spawn / kill / start / │ 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 load; collapsible inbox + collapsible journald viewer + collapsible
`agent.nix` viewer per agent on the dashboard. `agent.nix` viewer per agent on the dashboard.
Config changes flow the other way: manager edits `/agents/<name>/config/agent.nix` Config changes flow the other way: manager edits files under
(bind-mounted from the host's proposed repo) → commits → submits the sha as `/agents/<name>/config/` (`agent.nix` is the entry point, but arbitrary
an approval → operator clicks ◆ APPR0VE on the dashboard → hive-c0re copies sibling files in the commit are preserved) → commits → submits the sha
the file into the applied repo and `nixos-container update`s the agent. via `request_apply_commit`. Hive-c0re immediately fetches that commit
from the proposed repo into the applied repo and pins it as
`proposal/<id>` — 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/<id>`) or
annotates `failed/<id>` with the build error and rolls back to the
previous deployed tree. Denials leave a `denied/<id>` annotated tag
carrying the operator's note. The manager sees everything that
shipped (or didn't) via a read-only `/applied/<n>/.git` mirror inside
its container; `git show applied/deployed/<id>` 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, For decisions the manager needs human signal on, `ask_operator(question,
options?, multi?)` queues a free-text/checkbox/radio form on the options?, multi?)` queues a free-text/checkbox/radio form on the
dashboard; the answer arrives later as a `HelperEvent::OperatorAnswered` dashboard; the answer arrives later as a `HelperEvent::OperatorAnswered`

View file

@ -14,7 +14,6 @@ hive-sh4re.workspace = true
rusqlite.workspace = true rusqlite.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
similar.workspace = true
tokio.workspace = true tokio.workspace = true
tokio-stream.workspace = true tokio-stream.workspace = true
tracing.workspace = true tracing.workspace = true

View file

@ -93,7 +93,6 @@ impl Approvals {
/// Record the canonical sha hive-c0re fetched from the proposed repo /// Record the canonical sha hive-c0re fetched from the proposed repo
/// into applied at submission time. Idempotent on identical values. /// 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<()> { pub fn set_fetched_sha(&self, id: i64, sha: &str) -> Result<()> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
conn.execute( conn.execute(

View file

@ -342,8 +342,12 @@ async fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
for a in approvals { for a in approvals {
out.push(match a.kind { out.push(match a.kind {
hive_sh4re::ApprovalKind::ApplyCommit => { hive_sh4re::ApprovalKind::ApplyCommit => {
let sha = a.commit_ref[..a.commit_ref.len().min(12)].to_owned(); // Prefer the canonical fetched sha from applied;
let diff = approval_diff(&a.agent, &a.commit_ref).await; // 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 { ApprovalView {
id: a.id, id: a.id,
agent: a.agent, 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())) .any(|e| e.file_type().is_ok_and(|t| t.is_file()))
} }
async fn approval_diff(agent: &str, commit_ref: &str) -> String { /// Multi-file unified diff between the currently-deployed tree and
let proposed = Coordinator::agent_proposed_dir(agent); /// the proposal for this approval. Runs against the applied repo
if !proposed.exists() { /// since the canonical proposal commit lives there (manager-side
return format!( /// amendments don't move it). Empty output means proposal == main —
"(proposed dir {} does not exist — agent destroyed?)", /// a no-op approval.
proposed.display() 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() { let proposal_ref = format!("refs/tags/proposal/{approval_id}");
return format!("(no git repo at {})", proposed.display()); match git_diff_main_to(&applied, &proposal_ref).await {
} Ok(s) if s.is_empty() => "(proposal matches currently-deployed tree)".to_owned(),
let applied = Coordinator::agent_applied_dir(agent).join("agent.nix"); Ok(s) => s,
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),
Err(e) => format!("(error: {e:#})"), Err(e) => format!("(error: {e:#})"),
} }
} }
async fn git_show(proposed_dir: &Path, commit_ref: &str) -> Result<String> { async fn git_diff_main_to(applied_dir: &Path, target_ref: &str) -> Result<String> {
let out = lifecycle::git_command() let out = lifecycle::git_command()
.current_dir(proposed_dir) .current_dir(applied_dir)
.args(["show", &format!("{commit_ref}:agent.nix")]) .args(["diff", &format!("refs/heads/main..{target_ref}")])
.output() .output()
.await .await
.with_context(|| { .with_context(|| format!("spawn `git diff` in {}", applied_dir.display()))?;
format!(
"spawn `git show` in {} (HYPERHIVE_GIT={})",
proposed_dir.display(),
std::env::var("HYPERHIVE_GIT").unwrap_or_else(|_| "<unset>".into()),
)
})?;
if !out.status.success() { if !out.status.success() {
anyhow::bail!( anyhow::bail!(
"git show {commit_ref}:agent.nix failed: {}", "git diff main..{target_ref} failed: {}",
String::from_utf8_lossy(&out.stderr).trim() String::from_utf8_lossy(&out.stderr).trim()
); );
} }
Ok(String::from_utf8_lossy(&out.stdout).into_owned()) 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 { fn html_escape(s: &str) -> String {
s.replace('&', "&amp;") s.replace('&', "&amp;")
.replace('<', "&lt;") .replace('<', "&lt;")

View file

@ -475,7 +475,6 @@ async fn git(dir: &Path, args: &[&str]) -> Result<()> {
/// amendments / force-pushes in `src` no longer affect what gets /// amendments / force-pushes in `src` no longer affect what gets
/// built. Returns the resolved sha (which equals `sha` on success /// built. Returns the resolved sha (which equals `sha` on success
/// but normalised — short shas get expanded). /// 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<String> { pub async fn git_fetch_to_tag(dst: &Path, src: &Path, sha: &str, tag: &str) -> Result<String> {
let src_str = src.display().to_string(); let src_str = src.display().to_string();
let refspec = format!("{sha}:refs/tags/{tag}"); 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. /// 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<String> { pub async fn git_rev_parse(dir: &Path, refname: &str) -> Result<String> {
let out = git_command() let out = git_command()
.current_dir(dir) .current_dir(dir)
@ -505,7 +503,6 @@ pub async fn git_rev_parse(dir: &Path, refname: &str) -> Result<String> {
/// Plant a lightweight tag at `target`. Errors if the tag already /// Plant a lightweight tag at `target`. Errors if the tag already
/// exists — we want loud failures on id reuse, not silent /// exists — we want loud failures on id reuse, not silent
/// overwrites. /// overwrites.
#[allow(dead_code)]
pub async fn git_tag(dir: &Path, name: &str, target: &str) -> Result<()> { pub async fn git_tag(dir: &Path, name: &str, target: &str) -> Result<()> {
git(dir, &["tag", name, target]).await git(dir, &["tag", name, target]).await
} }
@ -514,7 +511,6 @@ pub async fn git_tag(dir: &Path, name: &str, target: &str) -> Result<()> {
/// `failed/<id>` (body = build error) and `denied/<id>` (body = /// `failed/<id>` (body = build error) and `denied/<id>` (body =
/// operator note). Multi-line bodies handled via stdin so we don't /// operator note). Multi-line bodies handled via stdin so we don't
/// have to escape anything. /// have to escape anything.
#[allow(dead_code)]
pub async fn git_tag_annotated(dir: &Path, name: &str, target: &str, body: &str) -> Result<()> { pub async fn git_tag_annotated(dir: &Path, name: &str, target: &str, body: &str) -> Result<()> {
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
let mut child = git_command() 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 /// `deployed/*` while we let `nixos-container update` evaluate the
/// candidate. On build failure callers reset back to HEAD; on /// candidate. On build failure callers reset back to HEAD; on
/// success they fast-forward main to `target`. /// success they fast-forward main to `target`.
#[allow(dead_code)]
pub async fn git_read_tree_reset(dir: &Path, target: &str) -> Result<()> { pub async fn git_read_tree_reset(dir: &Path, target: &str) -> Result<()> {
git(dir, &["read-tree", "--reset", "-u", target]).await 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` /// Hard-set a ref to `target`. Used to fast-forward `refs/heads/main`
/// to the just-deployed proposal commit. Uses `update-ref`, not /// to the just-deployed proposal commit. Uses `update-ref`, not
/// `branch -f`, so it works regardless of where HEAD currently sits. /// `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<()> { 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
} }