//! META INPUTS panel backend: walks `meta/flake.lock` into //! `MetaInputView` rows for the snapshot, emits the `MetaInputsChanged` //! event after lock bumps, and handles `POST /api/meta-update` (bulk //! flake-input update + rebuild ripple via the job queue). use axum::{ extract::{Form, State}, http::StatusCode, response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use crate::coordinator::Coordinator; use super::{AppState, error_response}; #[derive(Serialize, Clone, Debug)] pub struct MetaInputView { /// Input key in meta's `flake.nix` — `hyperhive`, `agent-`, etc. pub name: String, /// Full locked sha. Not displayed verbatim; the dashboard /// truncates to the first 12 chars for the chip. pub rev: String, /// Unix seconds — `locked.lastModified`. Drives the relative /// "2h ago" timestamp on each input row. pub last_modified: i64, /// `original.url` if available, for the tooltip / row meta text. #[serde(skip_serializing_if = "Option::is_none")] pub url: Option, } /// Walk `flake.lock`'s `nodes` graph from `root` and emit one /// `MetaInputView` per fetched input, at **every** depth. That /// surfaces the direct meta inputs (`hyperhive`, `agent-`), the /// agent flakes' own inputs (`agent-dmatrix/mcp-matrix`, /// `hyperhive/nixpkgs`), and any deeper transitive inputs — so the /// operator can bump any of them individually. Names are /// slash-separated paths from root, the syntax `nix flake update` /// accepts for transitive inputs. /// /// Filtering: /// - Inputs that resolve via a `follows` chain (lock value is an /// array) are skipped — they alias another node, not their own /// fetched derivation, so updating them does nothing. /// - A node is emitted only when it carries a `locked.rev`. /// - Each fetched node is walked exactly once (a `visited` set): /// the lock graph shares nodes (many flakes reference one /// nixpkgs), so without this a shared subtree re-walks per parent /// and a cycle would recurse forever. The result is a spanning /// tree — every input shown once, at its shallowest path. pub(super) fn read_meta_inputs() -> Vec { let mut out = Vec::new(); let Ok(raw) = std::fs::read_to_string(crate::paths::meta_flake_lock()) else { return out; }; let Ok(json) = serde_json::from_str::(&raw) else { return out; }; let Some(nodes) = json.get("nodes").and_then(|v| v.as_object()) else { return out; }; let Some(root_name) = json.get("root").and_then(|v| v.as_str()) else { return out; }; let mut visited = std::collections::HashSet::new(); visited.insert(root_name.to_owned()); walk_meta_inputs(nodes, root_name, "", &mut visited, &mut out); // hyperhive first, then alphabetical. String-sorting the // slash-paths puts every node directly above its own children // (`agent-foo`, `agent-foo/bar`, `agent-foo/bar/baz`), so the // result is a pre-order traversal the tree renderer can consume. out.sort_by(|a, b| match (a.name.as_str(), b.name.as_str()) { ("hyperhive", _) => std::cmp::Ordering::Less, (_, "hyperhive") => std::cmp::Ordering::Greater, _ => a.name.cmp(&b.name), }); out } fn walk_meta_inputs( nodes: &serde_json::Map, node_name: &str, prefix: &str, visited: &mut std::collections::HashSet, out: &mut Vec, ) { let Some(node) = nodes.get(node_name) else { return; }; let Some(inputs_map) = node.get("inputs").and_then(|v| v.as_object()) else { return; }; // Two passes: claim (and emit) every direct input of this node // before descending into any of them. A shallow input that a // deeper flake also references then keeps its shallow path // rather than being captured first by the deep walk. let mut to_recurse: Vec<(String, String)> = Vec::new(); for (alias, target) in inputs_map { // Inputs map value is either a string (node name) or an // array (a `follows` chain). The latter just aliases another // node — we can't `nix flake update` it directly, so skip. let serde_json::Value::String(target_name) = target else { continue; }; // Walk each fetched node once — guards shared subtrees and // cycles, and keeps the panel free of duplicate rows. if !visited.insert(target_name.clone()) { continue; } let Some(target_node) = nodes.get(target_name) else { continue; }; let path = if prefix.is_empty() { alias.clone() } else { format!("{prefix}/{alias}") }; if let Some(rev) = target_node .get("locked") .and_then(|v| v.get("rev")) .and_then(|v| v.as_str()) { let last_modified = target_node .get("locked") .and_then(|v| v.get("lastModified")) .and_then(serde_json::Value::as_i64) .unwrap_or(0); let url = target_node .get("original") .and_then(|v| v.get("url")) .and_then(|v| v.as_str()) .map(str::to_owned); out.push(MetaInputView { name: path.clone(), rev: rev.to_owned(), last_modified, url, }); } to_recurse.push((target_name.clone(), path)); } // Recurse hyperhive's subtree before any agent's — without this, // when meta's top-level `nixpkgs` is a `follows` alias the // `String` check above skips it, and the alphabetical BTreeMap // iteration descends into `agent-*` first. The agent walk then // claims `nixpkgs` at `agent-X/nixpkgs` instead of // `hyperhive/nixpkgs`, which is where the operator expects it. // Sort by the same "hyperhive first, then alpha" // priority `read_meta_inputs` uses for the final output. to_recurse.sort_by(|(a, _), (b, _)| match (a.as_str(), b.as_str()) { ("hyperhive", _) => std::cmp::Ordering::Less, (_, "hyperhive") => std::cmp::Ordering::Greater, _ => a.cmp(b), }); for (target_name, path) in to_recurse { walk_meta_inputs(nodes, &target_name, &path, visited, out); } } /// Snapshot meta/flake.lock's root inputs + emit /// `MetaInputsChanged`. Call after any mutation that bumps a lock /// (`run_meta_update`, `auto_update::rebuild_agent`). pub(crate) fn emit_meta_inputs_snapshot(coord: &Coordinator) { let inputs = read_meta_inputs(); coord.emit_dashboard_event(crate::dashboard_events::DashboardEvent::MetaInputsChanged { seq: coord.next_seq(), inputs, }); } /// Form for `POST /meta-update`. Inputs ride in as a comma-separated /// list under the `inputs` field — the JS submitter joins the /// checked boxes since axum's `Form` extractor doesn't natively /// decode repeated keys without a helper. #[derive(Deserialize, ToSchema)] pub(super) struct MetaUpdateForm { inputs: String, } /// Bulk-update selected meta flake inputs, then rebuild the affected /// agents in the background. /// /// Idempotent w.r.t. selection — choosing an input that's already at /// the latest sha is a no-op (no commit, no rebuild ripple). Returns /// immediately after queueing the work; dashboard polls for progress /// via container `pending` spinners + the meta-inputs row sha update. #[utoipa::path( post, path = "/api/meta-update", request_body(content = MetaUpdateForm, content_type = "application/x-www-form-urlencoded"), responses( (status = 200, description = "update + rebuild ripple queued", body = String), (status = 500, description = "no inputs selected"), ), tag = "meta_inputs" )] pub(super) async fn post_meta_update( State(state): State, Form(form): Form, ) -> Response { let inputs: Vec = form .inputs .split(',') .map(|s| s.trim().to_owned()) .filter(|s| !s.is_empty()) .collect(); if inputs.is_empty() { return error_response("meta-update: no inputs selected"); } // Cascade rebuild children fan out from the MetaLock node when the // lock bump lands — appended by the scheduler so they build against // the post-bump lock, and a failed bump simply fans out nothing. state .coord .job_queue .insert_job(|b| { crate::job_queue::templates::meta_update(b, inputs, None); Vec::new() }) .expect("template-declared shapes are acyclic"); state.coord.emit_rebuild_queue_snapshot(); (StatusCode::OK, "ok").into_response() } #[cfg(test)] mod tests { use super::*; #[test] fn walk_meta_inputs_keeps_nixpkgs_under_hyperhive_post_follows_refactor() { // Reproduce the shape where meta has // `nixpkgs.follows = "hyperhive/nixpkgs"` at the top level // (rendered as an array — `["hyperhive" "nixpkgs"]` — which // walk_meta_inputs skips because we can't `nix flake update` // a follows alias). The remaining top-level inputs are // `hyperhive` (string) and `agent-z` (string). Without the // hyperhive-first recursion sort, the BTreeMap alphabetical // order descends into `agent-z` first and claims // `nixpkgs` at `agent-z/nixpkgs`. let raw = r#"{ "root": "root", "version": 7, "nodes": { "root": { "inputs": { "hyperhive": "hyperhive", "nixpkgs": ["hyperhive", "nixpkgs"], "agent-z": "agent-z" } }, "hyperhive": { "inputs": { "nixpkgs": "nixpkgs" }, "locked": {"rev": "hhrev", "lastModified": 1}, "original": {"url": "git+file:///tmp/hyperhive"} }, "agent-z": { "inputs": { "nixpkgs": "nixpkgs" }, "locked": {"rev": "azrev", "lastModified": 2}, "original": {"url": "git+file:///tmp/agent-z"} }, "nixpkgs": { "locked": {"rev": "npkrev", "lastModified": 3}, "original": {"url": "github:NixOS/nixpkgs/nixos-26.05"} } } }"#; let json: serde_json::Value = serde_json::from_str(raw).unwrap(); let nodes = json.get("nodes").unwrap().as_object().unwrap(); let root_name = json.get("root").unwrap().as_str().unwrap(); let mut visited = std::collections::HashSet::new(); visited.insert(root_name.to_owned()); let mut out = Vec::new(); walk_meta_inputs(nodes, root_name, "", &mut visited, &mut out); let nixpkgs = out .iter() .find(|v| v.rev == "npkrev") .expect("nixpkgs node should be emitted exactly once"); assert_eq!( nixpkgs.name, "hyperhive/nixpkgs", "nixpkgs should be claimed under hyperhive, not under agent-z. \ got: {:?}", nixpkgs.name ); // And the agent-z path should NOT also carry a nixpkgs entry — // the spanning-tree visited set guarantees it's claimed once. assert!( !out.iter().any(|v| v.name == "agent-z/nixpkgs"), "agent-z/nixpkgs should not be emitted (already claimed under hyperhive)" ); } }