279 lines
11 KiB
Rust
279 lines
11 KiB
Rust
//! 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 crate::coordinator::Coordinator;
|
|
|
|
use super::{AppState, error_response};
|
|
|
|
#[derive(Serialize, Clone, Debug)]
|
|
pub struct MetaInputView {
|
|
/// Input key in meta's `flake.nix` — `hyperhive`, `agent-<n>`, 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<String>,
|
|
}
|
|
|
|
/// 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-<n>`), 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<MetaInputView> {
|
|
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::<serde_json::Value>(&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<String, serde_json::Value>,
|
|
node_name: &str,
|
|
prefix: &str,
|
|
visited: &mut std::collections::HashSet<String>,
|
|
out: &mut Vec<MetaInputView>,
|
|
) {
|
|
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)]
|
|
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.
|
|
pub(super) async fn post_meta_update(
|
|
State(state): State<AppState>,
|
|
Form(form): Form<MetaUpdateForm>,
|
|
) -> Response {
|
|
let inputs: Vec<String> = 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");
|
|
}
|
|
let inputs_label = inputs.join(", ");
|
|
// 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.
|
|
crate::job_queue::submit::meta_update(
|
|
&state.coord,
|
|
inputs,
|
|
crate::job_queue::Source::Manual,
|
|
format!("meta-update via dashboard ({inputs_label})"),
|
|
);
|
|
(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)"
|
|
);
|
|
}
|
|
}
|