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
|
|
@ -634,7 +634,51 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const APPROVAL_TAB_KEY = 'hyperhive:approvals:tab';
|
const APPROVAL_TAB_KEY = 'hyperhive:approvals:tab';
|
||||||
function renderApprovals(s) {
|
// Derived approval state — cold-loaded from /api/state, then mutated
|
||||||
|
// live by `approval_added` / `approval_resolved` dashboard events.
|
||||||
|
// `pending` is the open queue (newest-first); `history` is the last
|
||||||
|
// 30 resolved rows.
|
||||||
|
const APPROVAL_HISTORY_LIMIT = 30;
|
||||||
|
const approvalsState = { pending: [], history: [] };
|
||||||
|
function syncApprovalsFromSnapshot(s) {
|
||||||
|
approvalsState.pending = (s.approvals || []).slice();
|
||||||
|
approvalsState.history = (s.approval_history || []).slice();
|
||||||
|
}
|
||||||
|
function applyApprovalAdded(ev) {
|
||||||
|
// Upsert by id so a snapshot that already included the row (cold
|
||||||
|
// load + event lands at the same tick) doesn't double it.
|
||||||
|
const existing = approvalsState.pending.findIndex((a) => a.id === ev.id);
|
||||||
|
const row = {
|
||||||
|
id: ev.id,
|
||||||
|
agent: ev.agent,
|
||||||
|
kind: ev.approval_kind,
|
||||||
|
sha_short: ev.sha_short || null,
|
||||||
|
diff: ev.diff || null,
|
||||||
|
description: ev.description || null,
|
||||||
|
};
|
||||||
|
if (existing >= 0) approvalsState.pending[existing] = row;
|
||||||
|
else approvalsState.pending.push(row);
|
||||||
|
renderApprovals();
|
||||||
|
}
|
||||||
|
function applyApprovalResolved(ev) {
|
||||||
|
// Drop from pending; prepend to history (newest-first), cap at 30.
|
||||||
|
approvalsState.pending = approvalsState.pending.filter((a) => a.id !== ev.id);
|
||||||
|
approvalsState.history.unshift({
|
||||||
|
id: ev.id,
|
||||||
|
agent: ev.agent,
|
||||||
|
kind: ev.approval_kind,
|
||||||
|
sha_short: ev.sha_short || null,
|
||||||
|
status: ev.status,
|
||||||
|
resolved_at: ev.resolved_at,
|
||||||
|
note: ev.note || null,
|
||||||
|
description: ev.description || null,
|
||||||
|
});
|
||||||
|
if (approvalsState.history.length > APPROVAL_HISTORY_LIMIT) {
|
||||||
|
approvalsState.history.length = APPROVAL_HISTORY_LIMIT;
|
||||||
|
}
|
||||||
|
renderApprovals();
|
||||||
|
}
|
||||||
|
function renderApprovals() {
|
||||||
const root = $('approvals-section');
|
const root = $('approvals-section');
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
|
|
||||||
|
|
@ -655,7 +699,8 @@
|
||||||
);
|
);
|
||||||
root.append(spawn);
|
root.append(spawn);
|
||||||
|
|
||||||
const history = s.approval_history || [];
|
const pending = approvalsState.pending;
|
||||||
|
const history = approvalsState.history;
|
||||||
const active = localStorage.getItem(APPROVAL_TAB_KEY) || 'pending';
|
const active = localStorage.getItem(APPROVAL_TAB_KEY) || 'pending';
|
||||||
const tabs = el('div', { class: 'approval-tabs' });
|
const tabs = el('div', { class: 'approval-tabs' });
|
||||||
const pendingTab = el(
|
const pendingTab = el(
|
||||||
|
|
@ -664,7 +709,7 @@
|
||||||
type: 'button',
|
type: 'button',
|
||||||
class: 'approval-tab' + (active === 'pending' ? ' active' : ''),
|
class: 'approval-tab' + (active === 'pending' ? ' active' : ''),
|
||||||
},
|
},
|
||||||
`pending · ${s.approvals.length}`,
|
`pending · ${pending.length}`,
|
||||||
);
|
);
|
||||||
const historyTab = el(
|
const historyTab = el(
|
||||||
'button',
|
'button',
|
||||||
|
|
@ -676,11 +721,11 @@
|
||||||
);
|
);
|
||||||
pendingTab.addEventListener('click', () => {
|
pendingTab.addEventListener('click', () => {
|
||||||
localStorage.setItem(APPROVAL_TAB_KEY, 'pending');
|
localStorage.setItem(APPROVAL_TAB_KEY, 'pending');
|
||||||
renderApprovals(s);
|
renderApprovals();
|
||||||
});
|
});
|
||||||
historyTab.addEventListener('click', () => {
|
historyTab.addEventListener('click', () => {
|
||||||
localStorage.setItem(APPROVAL_TAB_KEY, 'history');
|
localStorage.setItem(APPROVAL_TAB_KEY, 'history');
|
||||||
renderApprovals(s);
|
renderApprovals();
|
||||||
});
|
});
|
||||||
tabs.append(pendingTab, historyTab);
|
tabs.append(pendingTab, historyTab);
|
||||||
root.append(tabs);
|
root.append(tabs);
|
||||||
|
|
@ -690,12 +735,12 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!s.approvals.length) {
|
if (!pending.length) {
|
||||||
root.append(el('p', { class: 'empty' }, 'queue empty'));
|
root.append(el('p', { class: 'empty' }, 'queue empty'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ul = el('ul', { class: 'approvals' });
|
const ul = el('ul', { class: 'approvals' });
|
||||||
for (const a of s.approvals) {
|
for (const a of pending) {
|
||||||
const li = el('li');
|
const li = el('li');
|
||||||
const row = el('div', { class: 'row' });
|
const row = el('div', { class: 'row' });
|
||||||
if (a.kind === 'apply_commit') {
|
if (a.kind === 'apply_commit') {
|
||||||
|
|
@ -954,7 +999,12 @@
|
||||||
renderTombstones(s);
|
renderTombstones(s);
|
||||||
renderQuestions(s);
|
renderQuestions(s);
|
||||||
renderInbox();
|
renderInbox();
|
||||||
renderApprovals(s);
|
// Sync the derived approvals store from the snapshot, then
|
||||||
|
// render. Live `approval_added` / `approval_resolved` events
|
||||||
|
// mutate the store directly and call renderApprovals() without
|
||||||
|
// a snapshot refetch.
|
||||||
|
syncApprovalsFromSnapshot(s);
|
||||||
|
renderApprovals();
|
||||||
renderMetaInputs(s);
|
renderMetaInputs(s);
|
||||||
restoreOpenDetails(openDetails);
|
restoreOpenDetails(openDetails);
|
||||||
notifyDeltas(s);
|
notifyDeltas(s);
|
||||||
|
|
@ -1014,6 +1064,11 @@
|
||||||
renderers: {
|
renderers: {
|
||||||
sent: (ev, api) => renderMsg(ev, api, '→'),
|
sent: (ev, api) => renderMsg(ev, api, '→'),
|
||||||
delivered: (ev, api) => renderMsg(ev, api, '✓'),
|
delivered: (ev, api) => renderMsg(ev, api, '✓'),
|
||||||
|
// Mutation events update derived state and trigger a
|
||||||
|
// section re-render — no terminal log row (the terminal is
|
||||||
|
// for broker traffic, not state-change chatter).
|
||||||
|
approval_added: (ev) => { applyApprovalAdded(ev); },
|
||||||
|
approval_resolved: (ev) => { applyApprovalResolved(ev); },
|
||||||
},
|
},
|
||||||
// Both history backfill and live frames flow through here, so the
|
// Both history backfill and live frames flow through here, so the
|
||||||
// inbox section ends up populated correctly on first paint and
|
// inbox section ends up populated correctly on first paint and
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,29 @@ fn finish_approval(
|
||||||
sha: approval.fetched_sha.clone(),
|
sha: approval.fetched_sha.clone(),
|
||||||
tag: terminal_tag.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
|
// For spawn/rebuild approvals, also surface the underlying action so
|
||||||
// the manager knows whether the container actually came up. The
|
// the manager knows whether the container actually came up. The
|
||||||
// ApprovalResolved event already carries the same `ok` signal but
|
// 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 {
|
coord.notify_manager(&HelperEvent::ApprovalResolved {
|
||||||
id: a.id,
|
id: a.id,
|
||||||
agent: a.agent,
|
agent: a.agent,
|
||||||
|
|
@ -390,6 +420,15 @@ pub async fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()
|
||||||
sha,
|
sha,
|
||||||
tag,
|
tag,
|
||||||
});
|
});
|
||||||
|
coord.emit_approval_resolved(
|
||||||
|
id,
|
||||||
|
&agent_owned,
|
||||||
|
approval_kind,
|
||||||
|
sha_short,
|
||||||
|
"denied",
|
||||||
|
note.map(String::from),
|
||||||
|
description,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,63 @@ impl Coordinator {
|
||||||
let _ = self.dashboard_events.send(event);
|
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> {
|
pub fn register_agent(self: &Arc<Self>, name: &str) -> Result<PathBuf> {
|
||||||
// Idempotent: drop any existing listener so re-registration (e.g. on rebuild,
|
// 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.
|
// or after a hive-c0re restart cleared /run/hyperhive) gets a fresh socket.
|
||||||
|
|
|
||||||
|
|
@ -1177,6 +1177,12 @@ async fn post_request_spawn(
|
||||||
{
|
{
|
||||||
Ok(id) => {
|
Ok(id) => {
|
||||||
tracing::info!(%id, %name, "operator: spawn approval queued via dashboard");
|
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()
|
Redirect::to("/").into_response()
|
||||||
}
|
}
|
||||||
Err(e) => error_response(&format!("request-spawn {name} failed: {e:#}")),
|
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() {
|
if Coordinator::agent_proposed_dir(&a.agent).exists() {
|
||||||
true
|
true
|
||||||
} else {
|
} 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");
|
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
|
false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -1407,7 +1427,12 @@ fn claude_has_session(dir: &Path) -> bool {
|
||||||
/// since the canonical proposal commit lives there (manager-side
|
/// since the canonical proposal commit lives there (manager-side
|
||||||
/// amendments don't move it). Empty output means proposal == main —
|
/// amendments don't move it). Empty output means proposal == main —
|
||||||
/// a no-op approval.
|
/// 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);
|
let applied = Coordinator::agent_applied_dir(agent);
|
||||||
if !applied.join(".git").exists() {
|
if !applied.join(".git").exists() {
|
||||||
return format!("(no applied git repo at {})", applied.display());
|
return format!("(no applied git repo at {})", applied.display());
|
||||||
|
|
|
||||||
|
|
@ -44,4 +44,37 @@ pub enum DashboardEvent {
|
||||||
body: String,
|
body: String,
|
||||||
at: i64,
|
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) => {
|
Ok(id) => {
|
||||||
tracing::info!(%id, %name, "spawn approval queued");
|
tracing::info!(%id, %name, "spawn approval queued");
|
||||||
|
coord.emit_approval_added(id, name, "spawn", None, None, description.clone());
|
||||||
ManagerResponse::Ok
|
ManagerResponse::Ok
|
||||||
}
|
}
|
||||||
Err(e) => ManagerResponse::Err {
|
Err(e) => ManagerResponse::Err {
|
||||||
|
|
@ -382,7 +383,17 @@ async fn submit_apply_commit(
|
||||||
// dashboard reflects it instead of leaving a phantom
|
// dashboard reflects it instead of leaving a phantom
|
||||||
// pending entry. The note doubles as the operator-visible
|
// pending entry. The note doubles as the operator-visible
|
||||||
// explanation of why the approval can't be approved.
|
// 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:#}"));
|
return Err(anyhow::anyhow!("git_fetch_to_tag: {e:#}"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -390,6 +401,19 @@ async fn submit_apply_commit(
|
||||||
.approvals
|
.approvals
|
||||||
.set_fetched_sha(id, &sha)
|
.set_fetched_sha(id, &sha)
|
||||||
.map_err(|e| anyhow::anyhow!("persist fetched_sha: {e:#}"))?;
|
.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))
|
Ok((id, sha))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue