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:
parent
fc61cb9310
commit
e26143a412
6 changed files with 42 additions and 55 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -342,8 +342,12 @@ async fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
|
|||
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<String> {
|
||||
async fn git_diff_main_to(applied_dir: &Path, target_ref: &str) -> Result<String> {
|
||||
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(|_| "<unset>".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('<', "<")
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
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<String> {
|
||||
let out = git_command()
|
||||
.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
|
||||
/// 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/<id>` (body = build error) and `denied/<id>` (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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue