dashboard: approval_added / approval_resolved mutation events + client derived state

This commit is contained in:
müde 2026-05-17 13:30:25 +02:00
parent 291f1fce42
commit 56d615b51f
6 changed files with 244 additions and 11 deletions

View file

@ -114,6 +114,29 @@ fn finish_approval(
sha: approval.fetched_sha.clone(),
tag: terminal_tag.clone(),
});
// Phase 5b: also fire on the dashboard event channel so the
// browser moves the row out of pending into history without a
// snapshot refetch. `approved` rows that succeed get the
// approval's logged resolved_at indirectly via `now_unix()`;
// failures already wrote it via mark_failed above.
let approval_kind = match approval.kind {
ApprovalKind::Spawn => "spawn",
ApprovalKind::ApplyCommit => "apply_commit",
};
let sha_short = approval
.fetched_sha
.as_deref()
.map(|s| s[..s.len().min(12)].to_owned());
let status_str = if ok { "approved" } else { "failed" };
coord.emit_approval_resolved(
approval.id,
&approval.agent,
approval_kind,
sha_short,
status_str,
note.clone(),
approval.description.clone(),
);
// For spawn/rebuild approvals, also surface the underlying action so
// the manager knows whether the container actually came up. The
// ApprovalResolved event already carries the same `ok` signal but
@ -381,6 +404,13 @@ pub async fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()
}
}
}
let approval_kind = match a.kind {
ApprovalKind::Spawn => "spawn",
ApprovalKind::ApplyCommit => "apply_commit",
};
let sha_short = sha.as_deref().map(|s| s[..s.len().min(12)].to_owned());
let description = a.description.clone();
let agent_owned = a.agent.clone();
coord.notify_manager(&HelperEvent::ApprovalResolved {
id: a.id,
agent: a.agent,
@ -390,6 +420,15 @@ pub async fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()
sha,
tag,
});
coord.emit_approval_resolved(
id,
&agent_owned,
approval_kind,
sha_short,
"denied",
note.map(String::from),
description,
);
}
Ok(())
}

View file

@ -161,6 +161,63 @@ impl Coordinator {
let _ = self.dashboard_events.send(event);
}
/// Emit `ApprovalAdded` immediately after the row is inserted in
/// sqlite. Caller passes the diff text it already computed (or
/// `None` for spawn approvals which carry no diff).
pub fn emit_approval_added(
&self,
id: i64,
agent: &str,
approval_kind: &'static str,
sha_short: Option<String>,
diff: Option<String>,
description: Option<String>,
) {
self.emit_dashboard_event(DashboardEvent::ApprovalAdded {
seq: self.next_seq(),
id,
agent: agent.to_owned(),
approval_kind,
sha_short,
diff,
description,
});
}
/// Emit `ApprovalResolved` after `mark_approved` / `mark_denied` /
/// `mark_failed` lands. `resolved_at` is stamped from the system
/// clock here so call sites don't repeat the conversion; if you
/// already have an authoritative timestamp from the db update,
/// the tiny skew between "row updated" and "event emitted" is
/// presentation-only and doesn't matter to clients.
pub fn emit_approval_resolved(
&self,
id: i64,
agent: &str,
approval_kind: &'static str,
sha_short: Option<String>,
status: &'static str,
note: Option<String>,
description: Option<String>,
) {
let resolved_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0);
self.emit_dashboard_event(DashboardEvent::ApprovalResolved {
seq: self.next_seq(),
id,
agent: agent.to_owned(),
approval_kind,
sha_short,
status,
resolved_at,
note,
description,
});
}
pub fn register_agent(self: &Arc<Self>, name: &str) -> Result<PathBuf> {
// Idempotent: drop any existing listener so re-registration (e.g. on rebuild,
// or after a hive-c0re restart cleared /run/hyperhive) gets a fresh socket.

View file

@ -1177,6 +1177,12 @@ async fn post_request_spawn(
{
Ok(id) => {
tracing::info!(%id, %name, "operator: spawn approval queued via dashboard");
// Phase 5b: notify the dashboard event channel so live
// subscribers can append the row without a snapshot
// refetch. Spawn approvals carry no diff/sha.
state
.coord
.emit_approval_added(id, &name, "spawn", None, None, None);
Redirect::to("/").into_response()
}
Err(e) => error_response(&format!("request-spawn {name} failed: {e:#}")),
@ -1381,8 +1387,22 @@ fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<Approval> {
if Coordinator::agent_proposed_dir(&a.agent).exists() {
true
} else {
let _ = coord.approvals.mark_failed(a.id, "agent state dir missing");
let note = "agent state dir missing";
let _ = coord.approvals.mark_failed(a.id, note);
tracing::info!(id = a.id, agent = %a.agent, "auto-failed orphan approval");
let sha_short = a
.fetched_sha
.as_deref()
.map(|s| s[..s.len().min(12)].to_owned());
coord.emit_approval_resolved(
a.id,
&a.agent,
"apply_commit",
sha_short,
"failed",
Some(note.to_owned()),
a.description.clone(),
);
false
}
})
@ -1407,7 +1427,12 @@ fn claude_has_session(dir: &Path) -> bool {
/// 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 {
///
/// `pub(crate)` so the manager-socket handler can pre-compute the
/// diff once at submission time and embed it in the `ApprovalAdded`
/// dashboard event (instead of forcing the dashboard to wait a
/// `/api/state` cycle to see the diff for newly-queued approvals).
pub(crate) 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());

View file

@ -44,4 +44,37 @@ pub enum DashboardEvent {
body: String,
at: i64,
},
/// A new approval landed in the pending queue. Payload carries
/// enough to render the dashboard row without a `/api/state`
/// refetch (`diff` is the raw unified diff text, same shape the
/// snapshot ships).
///
/// The approval's own kind (`"apply_commit"` / `"spawn"`) lives on
/// `approval_kind` rather than `kind` because the latter is taken
/// by the serde tag identifying which `DashboardEvent` variant
/// this is.
ApprovalAdded {
seq: u64,
id: i64,
agent: String,
approval_kind: &'static str,
sha_short: Option<String>,
diff: Option<String>,
description: Option<String>,
},
/// A pending approval transitioned to a terminal state
/// (approved / denied / failed). Clients move the row out of the
/// pending list and into history.
ApprovalResolved {
seq: u64,
id: i64,
agent: String,
approval_kind: &'static str,
sha_short: Option<String>,
/// `"approved"` / `"denied"` / `"failed"`.
status: &'static str,
resolved_at: i64,
note: Option<String>,
description: Option<String>,
},
}

View file

@ -157,6 +157,7 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc<Coordinator>) -> ManagerResp
) {
Ok(id) => {
tracing::info!(%id, %name, "spawn approval queued");
coord.emit_approval_added(id, name, "spawn", None, None, description.clone());
ManagerResponse::Ok
}
Err(e) => ManagerResponse::Err {
@ -382,7 +383,17 @@ async fn submit_apply_commit(
// dashboard reflects it instead of leaving a phantom
// pending entry. The note doubles as the operator-visible
// explanation of why the approval can't be approved.
let _ = coord.approvals.mark_failed(id, &format!("{e:#}"));
let note = format!("{e:#}");
let _ = coord.approvals.mark_failed(id, &note);
coord.emit_approval_resolved(
id,
agent,
"apply_commit",
None,
"failed",
Some(note),
description.map(str::to_owned),
);
return Err(anyhow::anyhow!("git_fetch_to_tag: {e:#}"));
}
};
@ -390,6 +401,19 @@ async fn submit_apply_commit(
.approvals
.set_fetched_sha(id, &sha)
.map_err(|e| anyhow::anyhow!("persist fetched_sha: {e:#}"))?;
// Phase 5b: surface the new pending approval on the dashboard
// event channel. Compute the diff once here so live subscribers
// get a fully-formed row without a snapshot refetch.
let sha_short = sha[..sha.len().min(12)].to_owned();
let diff = crate::dashboard::approval_diff(agent, id).await;
coord.emit_approval_added(
id,
agent,
"apply_commit",
Some(sha_short),
Some(diff),
description.map(str::to_owned),
);
Ok((id, sha))
}