diff --git a/docs/web-ui.md b/docs/web-ui.md index 9b32ad4..f3ee0c3 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -111,7 +111,11 @@ the previous process's socket release resolves itself. `agent-` rows). Checking inputs + submitting bumps the lock in `/meta/` and rebuilds the selected agents in sequence; each outcome reaches the manager as a `rebuilt` system event. - `POST /meta-update`. + `POST /meta-update`. The lock bump + rebuild ripple runs in the + background; while it does, the panel shows a pulsing "⏳ + meta-update running" banner and the update button is disabled + (snapshot field `meta_update_running`, live event + `meta_update_running`). 5. **M1ND H4S QU3STI0NS** — pending operator-targeted `ask` questions, i.e. rows with `target IS NULL` (peer-to-peer questions live in the same table but never surface here) diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 23e8fb8..f3f2e4c 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -454,11 +454,18 @@ // avoids ordering races between a same-tick destroy + purge. let tombstonesState = []; let metaInputsState = []; + // True while a dashboard-triggered meta-update (flake lock bump + + // agent rebuild ripple) runs in the background. Cold-loaded from + // `s.meta_update_running`, then flipped live by the + // `meta_update_running` event. Drives the META INPUTS panel's + // disabled "updating…" state (issue #259). + let metaUpdateRunning = false; function syncTombstonesFromSnapshot(s) { tombstonesState = (s.tombstones || []).slice(); } function syncMetaInputsFromSnapshot(s) { metaInputsState = (s.meta_inputs || []).slice(); + metaUpdateRunning = !!s.meta_update_running; } function applyTombstonesChanged(ev) { tombstonesState = (ev.tombstones || []).slice(); @@ -468,6 +475,10 @@ metaInputsState = (ev.inputs || []).slice(); renderMetaInputsFromState(); } + function applyMetaUpdateRunning(ev) { + metaUpdateRunning = !!ev.running; + renderMetaInputsFromState(); + } function renderTombstonesFromState() { renderTombstones({ tombstones: tombstonesState }); } @@ -1482,6 +1493,11 @@ root.append(el('p', { class: 'empty' }, 'meta repo not seeded yet')); return; } + if (metaUpdateRunning) { + root.append(el('p', { class: 'meta-update-running' }, + '⏳ meta-update running — flake lock bump + affected agents rebuilding. ' + + 'watch the agent cards for per-rebuild progress.')); + } const form = el('form', { method: 'POST', action: '/meta-update', @@ -1528,11 +1544,13 @@ type: 'submit', class: 'btn btn-meta-update', disabled: '', - }, '◆ UPD4TE & R3BU1LD'); + }, metaUpdateRunning ? '⏳ UPD4T1NG…' : '◆ UPD4TE & R3BU1LD'); form.append(btn); function refreshDisabled() { const any = form.querySelectorAll('input[data-meta-input]:checked').length > 0; - if (any) btn.removeAttribute('disabled'); + // Stay disabled while an update is already in flight — no + // stacking a second run on top of the rebuild ripple. + if (any && !metaUpdateRunning) btn.removeAttribute('disabled'); else btn.setAttribute('disabled', ''); } form.addEventListener('change', refreshDisabled); @@ -1857,6 +1875,7 @@ container_removed: (ev) => { applyContainerRemoved(ev); }, tombstones_changed: (ev) => { applyTombstonesChanged(ev); }, meta_inputs_changed: (ev) => { applyMetaInputsChanged(ev); }, + meta_update_running: (ev) => { applyMetaUpdateRunning(ev); }, }, // Both history backfill and live frames flow through here, so the // inbox section ends up populated correctly on first paint and diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index 2cde96e..f92f547 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -446,6 +446,17 @@ code { opacity: 0.35; cursor: not-allowed; } +/* In-progress banner for the META INPUTS panel: shown while a + dashboard-triggered meta-update runs in the background (issue #259). */ +.meta-update-running { + margin: 0 0 0.7em; + padding: 0.4em 0.7em; + border: 1px solid var(--purple); + background: rgba(203, 166, 247, 0.12); + color: var(--purple); + font-size: 0.85em; + animation: badge-pulse 1.6s ease-in-out infinite; +} .history-note { margin-left: 1.8em; margin-top: 0.2em; diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 16af4f5..d4d41ff 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -72,6 +72,14 @@ pub struct Coordinator { /// snapshot. dashboard_events: broadcast::Sender, event_seq: AtomicU64, + /// Count of dashboard-triggered `meta-update` runs currently in + /// flight. `post_meta_update` returns 200 immediately and does the + /// multi-minute `nix flake update` + agent-rebuild ripple in a + /// background task, so without this the META INPUTS panel showed no + /// sign anything was happening (issue #259). Held via + /// `MetaUpdateGuard`; a count > 0 surfaces on `/api/state` as + /// `meta_update_running` and via the `MetaUpdateRunning` event. + meta_updates_active: AtomicU64, /// Last container snapshot seen by `rescan_containers_and_emit`, /// keyed by `ContainerView.name`. The rescan diffs a fresh /// `container_view::build_all` against this map and emits one @@ -109,6 +117,33 @@ impl Drop for TransientGuard { } } +/// RAII guard for the `meta-update` in-progress flag, held for the +/// duration of a `run_meta_update` background task. Created by +/// `Coordinator::meta_update_guard`. Drop decrements the active-run +/// count; the count crossing back to 0 emits +/// `MetaUpdateRunning { running: false }`, so a concurrent pair of +/// updates only flips the dashboard flag once. +pub struct MetaUpdateGuard { + coord: Arc, +} + +impl Drop for MetaUpdateGuard { + fn drop(&mut self) { + if self + .coord + .meta_updates_active + .fetch_sub(1, Ordering::SeqCst) + == 1 + { + self.coord + .emit_dashboard_event(DashboardEvent::MetaUpdateRunning { + seq: self.coord.next_seq(), + running: false, + }); + } + } +} + #[derive(Debug, Clone, Copy)] pub enum TransientKind { /// `lifecycle::spawn` is running (nixos-container create + update + start). @@ -165,6 +200,7 @@ impl Coordinator { transient: Mutex::new(HashMap::new()), dashboard_events, event_seq: AtomicU64::new(0), + meta_updates_active: AtomicU64::new(0), last_containers: tokio::sync::Mutex::new(HashMap::new()), shutdown_tx, }) @@ -218,6 +254,31 @@ impl Coordinator { let _ = self.dashboard_events.send(event); } + /// Mark a `meta-update` as in flight and return an RAII guard that + /// clears it on drop (including drop-via-panic). The first + /// concurrent run emits `MetaUpdateRunning { running: true }`; the + /// last one to finish emits `running: false`. The dashboard's META + /// INPUTS panel reads the flag to show a disabled "updating…" + /// state while the lock bump + rebuild ripple runs (issue #259). + pub fn meta_update_guard(self: &Arc) -> MetaUpdateGuard { + if self.meta_updates_active.fetch_add(1, Ordering::SeqCst) == 0 { + self.emit_dashboard_event(DashboardEvent::MetaUpdateRunning { + seq: self.next_seq(), + running: true, + }); + } + MetaUpdateGuard { + coord: Arc::clone(self), + } + } + + /// True while at least one dashboard-triggered `meta-update` is + /// running. Surfaced on `/api/state` as `meta_update_running` so a + /// client that cold-loads mid-update sees the in-progress state. + pub fn meta_update_in_progress(&self) -> bool { + self.meta_updates_active.load(Ordering::SeqCst) > 0 + } + /// 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). diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index b0384eb..9d31dfa 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -206,6 +206,12 @@ struct StateSnapshot { /// Inputs in `meta/flake.lock` the operator can selectively /// `nix flake update`. Hyperhive first, then `agent-` rows. meta_inputs: Vec, + /// True while a dashboard-triggered `meta-update` (flake lock bump + + /// agent rebuild ripple) is running in the background. Lets a + /// client that cold-loads mid-update render the META INPUTS panel's + /// disabled "updating…" state; live transitions arrive via the + /// `MetaUpdateRunning` event (issue #259). + meta_update_running: bool, /// 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. @@ -395,6 +401,7 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J approvals, approval_history, meta_inputs: read_meta_inputs(), + meta_update_running: state.coord.meta_update_in_progress(), questions, question_history, tombstones, @@ -1495,6 +1502,10 @@ async fn post_meta_update( /// operator and manager get the same feedback they'd see from an /// auto-update / manual dashboard rebuild. async fn run_meta_update(coord: &Arc, inputs: &[String]) { + // Held for the whole run (incl. the early `return` on lock failure): + // emits `MetaUpdateRunning { running: true }` now and `false` on + // drop so the META INPUTS panel shows progress (issue #259). + let _progress = coord.meta_update_guard(); tracing::info!(?inputs, "meta-update: starting"); if let Err(e) = crate::meta::lock_update(inputs).await { tracing::warn!(error = ?e, "meta-update: lock_update failed"); diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index 32bae04..f82814d 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -194,4 +194,14 @@ pub enum DashboardEvent { seq: u64, inputs: Vec, }, + /// A dashboard-triggered `meta-update` started (`running: true`) or + /// finished (`running: false`). `post_meta_update` returns 200 + /// immediately and runs the `nix flake update` + agent-rebuild + /// ripple in a background task — this event lets the META INPUTS + /// panel show a disabled "updating…" state for that whole window + /// instead of looking idle (issue #259). Emitted by + /// `Coordinator::meta_update_guard` / `MetaUpdateGuard::drop` only + /// when the active-run count crosses 0, so concurrent updates flip + /// the flag exactly once. + MetaUpdateRunning { seq: u64, running: bool }, }