dashboard: approval_added / approval_resolved mutation events + client derived state
This commit is contained in:
parent
291f1fce42
commit
56d615b51f
6 changed files with 244 additions and 11 deletions
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ¬e);
|
||||
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))
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue