phase 8 step 2: approval-gated spawn + dashboard spinner

This commit is contained in:
müde 2026-05-15 12:53:13 +02:00
parent a42fdb3a5c
commit c59fa8541c
10 changed files with 382 additions and 90 deletions

View file

@ -42,6 +42,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
.route("/approve/{id}", post(post_approve))
.route("/deny/{id}", post(post_deny))
.route("/destroy/{name}", post(post_destroy))
.route("/request-spawn", post(post_request_spawn))
.route("/send", post(post_send))
.route("/messages/stream", get(messages_stream))
.with_state(AppState { coord });
@ -62,15 +63,26 @@ async fn index(headers: HeaderMap, State(state): State<AppState>) -> Html<String
let hostname = host.split(':').next().unwrap_or(host).to_owned();
let containers = lifecycle::list().await.unwrap_or_default();
let transient = state.coord.transient_snapshot();
let approvals = gc_orphans(
&state.coord,
state.coord.approvals.pending().unwrap_or_default(),
);
let approvals_html = render_approvals(&approvals).await;
// Auto-refresh the dashboard root while there's a spawn in flight, so the
// operator sees the new agent show up in the container list without
// having to reload manually. 2s is a reasonable poll interval for
// nixos-container create + start, which usually finishes in <30s.
let refresh = if transient.is_empty() {
String::new()
} else {
"<meta http-equiv=\"refresh\" content=\"2\">".to_owned()
};
Html(format!(
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>hyperhive // h1ve-c0re</title>\n{STYLE}\n</head>\n<body>\n{BANNER}\n{containers}\n{talk}\n{approvals_html}\n{MSG_FLOW}\n{FOOTER}\n{MSG_FLOW_JS}\n</body>\n</html>\n",
containers = render_containers(&containers, &hostname),
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>hyperhive // h1ve-c0re</title>\n{refresh}\n{STYLE}\n</head>\n<body>\n{BANNER}\n{containers}\n{talk}\n{approvals_html}\n{MSG_FLOW}\n{FOOTER}\n{MSG_FLOW_JS}\n</body>\n</html>\n",
containers = render_containers(&containers, &transient, &hostname),
talk = render_talk(&containers),
))
}
@ -112,7 +124,7 @@ async fn messages_stream(
}
async fn post_approve(State(state): State<AppState>, AxumPath(id): AxumPath<i64>) -> Response {
match actions::approve(&state.coord, id).await {
match actions::approve(state.coord.clone(), id).await {
Ok(()) => Redirect::to("/").into_response(),
Err(e) => error_response(&format!("approve {id} failed: {e:#}")),
}
@ -125,6 +137,32 @@ async fn post_deny(State(state): State<AppState>, AxumPath(id): AxumPath<i64>) -
}
}
#[derive(Deserialize)]
struct RequestSpawnForm {
name: String,
}
async fn post_request_spawn(
State(state): State<AppState>,
Form(form): Form<RequestSpawnForm>,
) -> Response {
let name = form.name.trim().to_owned();
if name.is_empty() {
return error_response("spawn: `name` required");
}
match state
.coord
.approvals
.submit_kind(&name, hive_sh4re::ApprovalKind::Spawn, "")
{
Ok(id) => {
tracing::info!(%id, %name, "operator: spawn approval queued via dashboard");
Redirect::to("/").into_response()
}
Err(e) => error_response(&format!("request-spawn {name} failed: {e:#}")),
}
}
async fn post_destroy(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
match actions::destroy(&state.coord, &name).await {
Ok(()) => Redirect::to("/").into_response(),
@ -143,11 +181,36 @@ fn error_response(message: &str) -> Response {
.into_response()
}
fn render_containers(containers: &[String], hostname: &str) -> String {
fn render_containers(
containers: &[String],
transient: &std::collections::HashMap<String, crate::coordinator::TransientState>,
hostname: &str,
) -> String {
let mut out = String::from(
"<h2>◆ C0NTAINERS ◆</h2>\n<div class=\"divider\">══════════════════════════════════════════════════════════════</div>\n",
);
if containers.is_empty() {
out.push_str("<form method=\"POST\" action=\"/request-spawn\" class=\"spawnform\">\n <input name=\"name\" placeholder=\"new agent name (≤9 chars)\" maxlength=\"9\" required autocomplete=\"off\">\n <button type=\"submit\" class=\"btn btn-spawn\">◆ R3QU3ST SP4WN</button>\n</form>\n<p class=\"meta\">spawn requests queue as approvals. operator approves below to actually create the container.</p>\n");
// Render in-flight spawns first so the operator sees feedback immediately.
if !transient.is_empty() {
out.push_str("<ul>\n");
for (name, state) in transient {
// Skip names that already exist in `containers` (race: spawn finished
// between transient set and list refresh).
if containers.iter().any(|c| c == &format!("h-{name}")) {
continue;
}
let secs = state.since.elapsed().as_secs();
let label = match state.kind {
crate::coordinator::TransientKind::Spawning => "spawning…",
};
let _ = writeln!(
out,
"<li><span class=\"glyph spinner\">◐</span> <span class=\"agent\">{name}</span> <span class=\"role role-pending\">{label}</span> <span class=\"meta\">nixos-container create + start ({secs}s)</span></li>",
);
}
out.push_str("</ul>\n");
}
if containers.is_empty() && transient.is_empty() {
out.push_str("<p class=\"empty\">▓ no managed containers ▓</p>\n");
return out;
}
@ -180,15 +243,27 @@ async fn render_approvals(approvals: &[Approval]) -> String {
}
out.push_str("<ul class=\"approvals\">\n");
for a in approvals {
let sha_short = &a.commit_ref[..a.commit_ref.len().min(12)];
let diff = approval_diff(&a.agent, &a.commit_ref).await;
let _ = writeln!(
out,
"<li>\n <div class=\"row\"><span class=\"glyph\">→</span> <span class=\"id\">#{id}</span> <span class=\"agent\">{agent}</span> <code>{sha_short}</code>\n <form method=\"POST\" action=\"/approve/{id}\" class=\"inline\"><button class=\"btn btn-approve\" type=\"submit\">◆ APPR0VE</button></form>\n <form method=\"POST\" action=\"/deny/{id}\" class=\"inline\"><button class=\"btn btn-deny\" type=\"submit\">DENY</button></form>\n </div>\n <details><summary>diff vs applied</summary><pre class=\"diff\">{diff}</pre></details>\n</li>",
id = a.id,
agent = a.agent,
diff = html_escape(&diff),
);
match a.kind {
hive_sh4re::ApprovalKind::ApplyCommit => {
let sha_short = &a.commit_ref[..a.commit_ref.len().min(12)];
let diff = approval_diff(&a.agent, &a.commit_ref).await;
let _ = writeln!(
out,
"<li>\n <div class=\"row\"><span class=\"glyph\">→</span> <span class=\"id\">#{id}</span> <span class=\"agent\">{agent}</span> <span class=\"kind\">apply</span> <code>{sha_short}</code>\n <form method=\"POST\" action=\"/approve/{id}\" class=\"inline\"><button class=\"btn btn-approve\" type=\"submit\">◆ APPR0VE</button></form>\n <form method=\"POST\" action=\"/deny/{id}\" class=\"inline\"><button class=\"btn btn-deny\" type=\"submit\">DENY</button></form>\n </div>\n <details><summary>diff vs applied</summary><pre class=\"diff\">{diff}</pre></details>\n</li>",
id = a.id,
agent = a.agent,
diff = html_escape(&diff),
);
}
hive_sh4re::ApprovalKind::Spawn => {
let _ = writeln!(
out,
"<li>\n <div class=\"row\"><span class=\"glyph\">⊕</span> <span class=\"id\">#{id}</span> <span class=\"agent\">{agent}</span> <span class=\"kind kind-spawn\">spawn</span> <span class=\"meta\">new sub-agent — container will be created on approve</span>\n <form method=\"POST\" action=\"/approve/{id}\" class=\"inline\"><button class=\"btn btn-approve\" type=\"submit\">◆ APPR0VE</button></form>\n <form method=\"POST\" action=\"/deny/{id}\" class=\"inline\"><button class=\"btn btn-deny\" type=\"submit\">DENY</button></form>\n </div>\n</li>",
id = a.id,
agent = a.agent,
);
}
}
}
out.push_str("</ul>\n");
out
@ -220,6 +295,11 @@ fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<Approval> {
approvals
.into_iter()
.filter(|a| {
// Spawn approvals are for not-yet-existent agents; the proposed
// dir is supposed to be missing.
if matches!(a.kind, hive_sh4re::ApprovalKind::Spawn) {
return true;
}
if Coordinator::agent_proposed_dir(&a.agent).exists() {
true
} else {
@ -435,6 +515,38 @@ const STYLE: &str = r#"
.btn-deny { color: var(--red); border-color: var(--red); }
.btn-destroy { color: var(--red); border-color: var(--red); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-talk { color: var(--cyan); border-color: var(--cyan); }
.btn-spawn { color: var(--amber); border-color: var(--amber); }
.spawnform { display: flex; gap: 0.6em; align-items: stretch; margin: 0.5em 0; }
.spawnform input {
font-family: inherit;
font-size: 1em;
background: var(--bg-elev);
color: var(--fg);
border: 1px solid var(--border);
padding: 0.4em 0.6em;
flex: 1;
}
.spawnform input::placeholder { color: var(--muted); }
.spawnform input:focus { outline: 1px solid var(--purple); }
.role-pending { color: var(--amber); border-color: var(--amber); }
.kind {
display: inline-block;
margin-left: 0.4em;
padding: 0.05em 0.5em;
border: 1px solid var(--purple-dim);
color: var(--purple-dim);
border-radius: 2px;
font-size: 0.75em;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.kind-spawn { color: var(--amber); border-color: var(--amber); }
.spinner {
display: inline-block;
animation: spin 1s linear infinite;
color: var(--amber);
}
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.talkform {
display: flex;
gap: 0.6em;