dashboard: forge-linked config + approval card + 3-way diff base
- forge nix option moves to hyperhive.forge.enable, defaults true;
hive-c0re imports the forge module so it's on by default.
- drop the agent.nix container-row viewer + /api/agent-config; link
to the agent-configs forge repo instead.
- restructure pending approvals into a card (identity header /
what-changed body / decision actions) with a link to the proposal
commit on the forge.
- diff opens in the side panel with a 3-way base toggle: vs applied
(running) / vs last-approved / vs previous proposal, served by the
new /api/approval-diff/{id}?base= endpoint.
This commit is contained in:
parent
0c62bbf1cd
commit
49f4e9cc89
5 changed files with 305 additions and 134 deletions
|
|
@ -54,11 +54,11 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
|||
.route("/cancel-question/{id}", post(post_cancel_question))
|
||||
.route("/purge-tombstone/{name}", post(post_purge_tombstone))
|
||||
.route("/api/journal/{name}", get(get_journal))
|
||||
.route("/api/approval-diff/{id}", get(get_approval_diff))
|
||||
.route("/api/state-file", get(get_state_file))
|
||||
.route("/api/reminders", get(api_reminders))
|
||||
.route("/cancel-reminder/{id}", post(post_cancel_reminder))
|
||||
.route("/retry-reminder/{id}", post(post_retry_reminder))
|
||||
.route("/api/agent-config/{name}", get(get_agent_config))
|
||||
.route("/request-spawn", post(post_request_spawn))
|
||||
.route("/op-send", post(post_op_send))
|
||||
.route("/meta-update", post(post_meta_update))
|
||||
|
|
@ -195,6 +195,10 @@ struct StateSnapshot {
|
|||
/// Inputs in `meta/flake.lock` the operator can selectively
|
||||
/// `nix flake update`. Hyperhive first, then `agent-<n>` rows.
|
||||
meta_inputs: Vec<MetaInputView>,
|
||||
/// Whether the hive-forge container is up. When true the dashboard
|
||||
/// links each container's config + each approval's commit into the
|
||||
/// forge's `agent-configs` repos.
|
||||
forge_present: bool,
|
||||
}
|
||||
|
||||
/// `OpQuestion` + computed `question_refs` / `answer_refs`. Built
|
||||
|
|
@ -381,6 +385,7 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
|
|||
question_history,
|
||||
tombstones,
|
||||
port_conflicts,
|
||||
forge_present: crate::forge::is_present().await,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -932,30 +937,6 @@ async fn get_journal(
|
|||
}
|
||||
}
|
||||
|
||||
/// Show the current `agent.nix` from the applied repo — the file
|
||||
/// the container actually builds against. Read-only; the manager
|
||||
/// can't influence what this returns (that path goes through the
|
||||
/// approval queue).
|
||||
async fn get_agent_config(AxumPath(name): AxumPath<String>) -> Response {
|
||||
let logical = strip_container_prefix(&name);
|
||||
// Constrain to managed containers — same shape as the journal
|
||||
// endpoint, prevents arbitrary filesystem reads.
|
||||
let live = lifecycle::list().await.unwrap_or_default();
|
||||
let prefixed = if logical == lifecycle::MANAGER_NAME {
|
||||
logical.clone()
|
||||
} else {
|
||||
format!("{}{logical}", lifecycle::AGENT_PREFIX)
|
||||
};
|
||||
if !live.iter().any(|c| c == &prefixed) {
|
||||
return error_response(&format!("agent-config: no managed container {prefixed:?}"));
|
||||
}
|
||||
let path = Coordinator::agent_applied_dir(&logical).join("agent.nix");
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(body) => ([("content-type", "text/plain; charset=utf-8")], body).into_response(),
|
||||
Err(e) => error_response(&format!("read {}: {e}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct StateFileQuery {
|
||||
path: String,
|
||||
|
|
@ -1801,26 +1782,118 @@ pub(crate) async fn approval_diff(agent: &str, approval_id: i64) -> String {
|
|||
return format!("(no applied git repo at {})", applied.display());
|
||||
}
|
||||
let proposal_ref = format!("refs/tags/proposal/{approval_id}");
|
||||
match git_diff_main_to(&applied, &proposal_ref).await {
|
||||
match git_diff_refs(&applied, "refs/heads/main", &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_diff_main_to(applied_dir: &Path, target_ref: &str) -> Result<String> {
|
||||
async fn git_diff_refs(applied_dir: &Path, base_ref: &str, target_ref: &str) -> Result<String> {
|
||||
let out = lifecycle::git_command()
|
||||
.current_dir(applied_dir)
|
||||
.args(["diff", &format!("refs/heads/main..{target_ref}")])
|
||||
.args(["diff", &format!("{base_ref}..{target_ref}")])
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("spawn `git diff` in {}", applied_dir.display()))?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"git diff main..{target_ref} failed: {}",
|
||||
"git diff {base_ref}..{target_ref} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
/// Numeric ids of `<prefix>/<n>` tags in the applied repo (e.g.
|
||||
/// `proposal/3` → `3`). Unparseable suffixes are skipped. Used to
|
||||
/// resolve the `approved` / `previous` diff bases for an approval.
|
||||
async fn tag_ids(applied_dir: &Path, prefix: &str) -> Vec<i64> {
|
||||
let Ok(out) = lifecycle::git_command()
|
||||
.current_dir(applied_dir)
|
||||
.args(["tag", "-l", &format!("{prefix}/*")])
|
||||
.output()
|
||||
.await
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !out.status.success() {
|
||||
return Vec::new();
|
||||
}
|
||||
let strip = format!("{prefix}/");
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.filter_map(|l| l.trim().strip_prefix(&strip))
|
||||
.filter_map(|s| s.parse::<i64>().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DiffBaseQuery {
|
||||
/// `applied` (running tree — default), `approved` (most recent
|
||||
/// earlier approved proposal), or `previous` (the prior queued
|
||||
/// proposal for this agent).
|
||||
base: Option<String>,
|
||||
}
|
||||
|
||||
/// On-demand unified diff for one `ApplyCommit` approval against a
|
||||
/// chosen base. `applied` = `applied/main` (what's running);
|
||||
/// `approved` = the most recent earlier `approved/<n>` tag (the last
|
||||
/// proposal the operator OK'd, even if its build then failed);
|
||||
/// `previous` = the prior queued `proposal/<n>` (the incremental
|
||||
/// delta when the manager chains proposals). Returns the raw diff
|
||||
/// text — the dashboard classifies lines client-side.
|
||||
async fn get_approval_diff(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<i64>,
|
||||
axum::extract::Query(q): axum::extract::Query<DiffBaseQuery>,
|
||||
) -> Response {
|
||||
let base = q.base.as_deref().unwrap_or("applied");
|
||||
let approval = match state.coord.approvals.get(id) {
|
||||
Ok(Some(a)) => a,
|
||||
Ok(None) => return error_response(&format!("approval {id} not found")),
|
||||
Err(e) => return error_response(&format!("approval {id}: {e:#}")),
|
||||
};
|
||||
if !matches!(approval.kind, hive_sh4re::ApprovalKind::ApplyCommit) {
|
||||
return error_response("spawn approvals carry no commit to diff");
|
||||
}
|
||||
let applied = Coordinator::agent_applied_dir(&approval.agent);
|
||||
if !applied.join(".git").exists() {
|
||||
return plain_text(format!("(no applied git repo at {})", applied.display()));
|
||||
}
|
||||
let target = format!("refs/tags/proposal/{id}");
|
||||
let base_ref = match base {
|
||||
"applied" => Some("refs/heads/main".to_owned()),
|
||||
"approved" => {
|
||||
let ids = tag_ids(&applied, "approved").await;
|
||||
ids.into_iter()
|
||||
.filter(|&n| n != id)
|
||||
.max()
|
||||
.map(|n| format!("refs/tags/approved/{n}"))
|
||||
}
|
||||
"previous" => {
|
||||
let ids = tag_ids(&applied, "proposal").await;
|
||||
ids.into_iter()
|
||||
.filter(|&n| n < id)
|
||||
.max()
|
||||
.map(|n| format!("refs/tags/proposal/{n}"))
|
||||
}
|
||||
other => return error_response(&format!("unknown diff base {other:?}")),
|
||||
};
|
||||
let Some(base_ref) = base_ref else {
|
||||
return plain_text(match base {
|
||||
"approved" => "(no earlier approved proposal to diff against)".to_owned(),
|
||||
_ => "(no previous proposal to diff against)".to_owned(),
|
||||
});
|
||||
};
|
||||
match git_diff_refs(&applied, &base_ref, &target).await {
|
||||
Ok(s) if s.is_empty() => plain_text("(identical — no changes vs this base)".to_owned()),
|
||||
Ok(s) => plain_text(s),
|
||||
Err(e) => error_response(&format!("git diff: {e:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn plain_text(body: String) -> Response {
|
||||
(StatusCode::OK, body).into_response()
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue