Compare commits

...
Author SHA1 Message Date
iris
06bc7e31c0 fix(#2289): wire sync_agent failures to dashboard warning banner
sync_agent() now returns bool (false if any step fails). ensure_all()
collects the names of agents whose sync failed and raises a single
set_boot_warning with the aggregated list:

  forge: per-agent sync failed for: alice, bob (see journal for per-step
  detail)

The static_kind() leak is already used for per-org boot warnings in the
same file — the leak is bounded (one per hive-c0re boot, not per request)
so reusing it here is appropriate.

The rebuild call site in job_queue/exec.rs discards the bool return and
keeps its existing tracing::warn! lines, which is the right separation:
rebuilds are their own retry loop and don't need to post a persistent boot
warning.
2026-07-19 14:57:21 +02:00
iris
66c715842f dashboard: add hive infra containers to the logs UI agent selector
Extends GET /api/journal/{name} to also accept the four hive infra
container names (hive-ci, hive-forge, hive-gateway, hive-matrix —
hive_priv_sock::InfraContainer is the allowlist), reusing the same
journalctl -M / hive-priv delegation path already used for agent
containers. Infra containers don't run the per-agent hive daemons, so
the unit filter is skipped for them — always the full machine journal.

Frontend: the AGENT tab's agent selector now lists infra containers
in a separate optgroup (sourced from /api/state's existing
infra_containers field), and disables the unit-filter select when one
is chosen.
2026-07-19 14:49:17 +02:00
3 changed files with 96 additions and 12 deletions

View file

@ -45,8 +45,15 @@ import { createTabStrip } from '@hive/shared/tabs.js';
return 'fetched ' + (ageSecs < 5 ? 'just now' : fmtAgeSecs(ageSecs) + ' ago'); return 'fetched ' + (ageSecs < 5 ? 'just now' : fmtAgeSecs(ageSecs) + ' ago');
} }
// Populate agent selector from /api/state, then honour any `?agent=` / // Names from `state.infra_containers` (hive-ci / hive-forge / hive-gateway
// `?unit=` URL params (used by the per-agent ⋮ menu's deep-link). // / hive-matrix) — kept in sync with the fetched list so fetchAgent() can
// tell an infra container apart from an agent and skip the (inapplicable)
// unit filter for it.
let infraContainerNames = new Set();
// Populate agent selector from /api/state (agents, then the four infra
// containers in their own optgroup), then honour any `?agent=` / `?unit=`
// URL params (used by the per-agent ⋮ menu's deep-link).
async function loadAgentList() { async function loadAgentList() {
try { try {
const resp = await fetch('/api/state'); const resp = await fetch('/api/state');
@ -58,6 +65,14 @@ import { createTabStrip } from '@hive/shared/tabs.js';
for (const c of (state.containers || [])) { for (const c of (state.containers || [])) {
agentSelect.append(el('option', { value: c.name }, c.name)); agentSelect.append(el('option', { value: c.name }, c.name));
} }
infraContainerNames = new Set((state.infra_containers || []).map((c) => c.name));
if (infraContainerNames.size) {
const infraGroup = el('optgroup', { label: 'infra' });
for (const name of infraContainerNames) {
infraGroup.append(el('option', { value: name }, name));
}
agentSelect.append(infraGroup);
}
// Deep-link: honour ?agent= and ?unit= URL params. // Deep-link: honour ?agent= and ?unit= URL params.
const urlAgent = new URLSearchParams(location.search).get('agent'); const urlAgent = new URLSearchParams(location.search).get('agent');
const urlUnit = new URLSearchParams(location.search).get('unit'); const urlUnit = new URLSearchParams(location.search).get('unit');
@ -70,12 +85,24 @@ import { createTabStrip } from '@hive/shared/tabs.js';
.some((o) => o.value === urlUnit); .some((o) => o.value === urlUnit);
if (unitFound) agentUnitSelect.value = urlUnit; if (unitFound) agentUnitSelect.value = urlUnit;
} }
syncUnitSelectForSelection();
fetchAgent(); fetchAgent();
} }
} }
} catch { /**/ } } catch { /**/ }
} }
// Infra containers don't run the per-agent hive daemons, so the unit
// filter is meaningless for them — disable the selector and always fetch
// the full machine journal to avoid a picked unit silently doing nothing.
function syncUnitSelectForSelection() {
if (!agentUnitSelect) return;
const isInfra = infraContainerNames.has(agentSelect ? agentSelect.value : '');
agentUnitSelect.disabled = isInfra;
if (isInfra) agentUnitSelect.value = '';
}
if (agentSelect) agentSelect.addEventListener('change', syncUnitSelectForSelection);
let agentFetching = false; let agentFetching = false;
let agentLastFetch = 0; let agentLastFetch = 0;
async function fetchAgent() { async function fetchAgent() {
@ -86,7 +113,8 @@ import { createTabStrip } from '@hive/shared/tabs.js';
agentFetching = true; agentFetching = true;
agentOutput.textContent = 'fetching…'; agentOutput.textContent = 'fetching…';
if (agentFetchTs) agentFetchTs.hidden = true; if (agentFetchTs) agentFetchTs.hidden = true;
const unit = agentUnitSelect ? agentUnitSelect.value : ''; const isInfra = infraContainerNames.has(name);
const unit = (!isInfra && agentUnitSelect) ? agentUnitSelect.value : '';
const params = new URLSearchParams({ lines: '500' }); const params = new URLSearchParams({ lines: '500' });
if (unit) params.set('unit', unit); if (unit) params.set('unit', unit);
try { try {

View file

@ -1,10 +1,13 @@
//! Journal-read endpoints for the dashboard. //! Journal-read endpoints for the dashboard.
//! //!
//! `GET /api/journal/{name}` reads a managed container's journal via the //! `GET /api/journal/{name}` reads a managed agent container's journal, OR
//! root helper (`journalctl -M`, delegated to hive-priv since hive-c0re is //! one of the four hive infra containers (`hive-ci`, `hive-forge`,
//! unprivileged). `GET /api/journal-host` reads host-side journald, both //! `hive-gateway`, `hive-matrix` — [`hive_priv_sock::InfraContainer`] is the
//! gated by an allow-list of known units so arbitrary unit names can't be //! allowlist), via the root helper (`journalctl -M`, delegated to hive-priv
//! probed. Operator-only by virtue of the dashboard binding host-only. //! since hive-c0re is unprivileged). `GET /api/journal-host` reads
//! host-side journald, both gated by an allow-list of known units so
//! arbitrary unit names can't be probed. Operator-only by virtue of the
//! dashboard binding host-only.
use axum::{ use axum::{
extract::Path as AxumPath, extract::Path as AxumPath,
@ -33,10 +36,23 @@ pub(super) struct JournalQuery {
/// Operator-only by virtue of the dashboard being host-bound. hive-c0re /// Operator-only by virtue of the dashboard being host-bound. hive-c0re
/// runs unprivileged (privsep), so the `-M` read — which enters the /// runs unprivileged (privsep), so the `-M` read — which enters the
/// container namespace and needs root — is delegated to hive-priv. /// container namespace and needs root — is delegated to hive-priv.
///
/// `name` is either a managed agent name (`iris`, optionally already
/// carrying the `h-` prefix) or one of the four infra container names
/// (`hive-ci` / `hive-forge` / `hive-gateway` / `hive-matrix` — see
/// [`hive_priv_sock::InfraContainer`]). Infra containers don't run the
/// per-agent hive daemons, so `unit` is ignored for them — always the
/// full machine journal.
pub(super) async fn get_journal( pub(super) async fn get_journal(
AxumPath(name): AxumPath<String>, AxumPath(name): AxumPath<String>,
axum::extract::Query(q): axum::extract::Query<JournalQuery>, axum::extract::Query(q): axum::extract::Query<JournalQuery>,
) -> Result<Response, ProblemDetails> { ) -> Result<Response, ProblemDetails> {
let lines = q.lines.unwrap_or(500).min(5000);
if let Ok(infra) = name.parse::<hive_priv_sock::InfraContainer>() {
return read_journal_response(infra.unit_name(), None, lines).await;
}
// Defense-in-depth format check so weird chars never reach the // Defense-in-depth format check so weird chars never reach the
// shellout below — the `lifecycle::list()` existence check would // shellout below — the `lifecycle::list()` existence check would
// catch them anyway, but rejecting at the boundary keeps the // catch them anyway, but rejecting at the boundary keeps the
@ -54,7 +70,6 @@ pub(super) async fn get_journal(
return Err(ProblemDetails::from_status_code(StatusCode::NOT_FOUND) return Err(ProblemDetails::from_status_code(StatusCode::NOT_FOUND)
.with_detail(format!("journal: no managed container {prefixed:?}"))); .with_detail(format!("journal: no managed container {prefixed:?}")));
} }
let lines = q.lines.unwrap_or(500).min(5000);
let unit = match q.unit.as_deref().filter(|s| !s.is_empty()) { let unit = match q.unit.as_deref().filter(|s| !s.is_empty()) {
Some(u) => { Some(u) => {
// accept any of the per-container hive daemons [.service] — // accept any of the per-container hive daemons [.service] —
@ -78,8 +93,21 @@ pub(super) async fn get_journal(
} }
None => None, None => None,
}; };
read_journal_response(&prefixed, unit, lines).await
}
/// Shared `journalctl -M <machine> [-u <unit>]` shellout + response
/// formatting for [`get_journal`], factored out so the infra-container
/// branch (no `unit` filtering) and the agent-container branch (allow-listed
/// `unit` filtering) don't duplicate the priv-client call + stdout/stderr
/// combining.
async fn read_journal_response(
machine: &str,
unit: Option<String>,
lines: u32,
) -> Result<Response, ProblemDetails> {
match crate::priv_client::read_container_journal( match crate::priv_client::read_container_journal(
&prefixed, machine,
hive_priv_sock::JournalQuery { hive_priv_sock::JournalQuery {
lines, lines,
boot: true, boot: true,

View file

@ -169,9 +169,15 @@ pub(crate) fn api(token: &str) -> Result<Forgejo> {
/// ///
/// Called by both `ensure_all()` (startup sweep) and `rebuild_agent` /// Called by both `ensure_all()` (startup sweep) and `rebuild_agent`
/// (per-rebuild) so the two paths stay equivalent. /// (per-rebuild) so the two paths stay equivalent.
pub async fn sync_agent(name: &str, core_token: Option<&str>) { /// Returns `true` if all steps succeeded, `false` if any step failed. The
/// caller can use the return value to aggregate per-agent failures into a
/// dashboard warning (see [`ensure_all`]); the rebuild path ignores it and
/// relies on the journal `warn!` lines alone (a rebuild is its own retry).
pub async fn sync_agent(name: &str, core_token: Option<&str>) -> bool {
let mut ok = true;
if let Err(e) = ensure_user_for(name).await { if let Err(e) = ensure_user_for(name).await {
tracing::warn!(%name, error = ?e, "forge: ensure_user failed"); tracing::warn!(%name, error = ?e, "forge: ensure_user failed");
ok = false;
} }
// Align email to match the git user.email set by meta::render_flake // Align email to match the git user.email set by meta::render_flake
// so commits link to the agent's Forgejo profile. Best-effort; // so commits link to the agent's Forgejo profile. Best-effort;
@ -188,9 +194,11 @@ pub async fn sync_agent(name: &str, core_token: Option<&str>) {
// was down. // was down.
if let Err(e) = ensure_config_repo(name).await { if let Err(e) = ensure_config_repo(name).await {
tracing::warn!(%name, error = ?e, "forge: ensure_config_repo failed"); tracing::warn!(%name, error = ?e, "forge: ensure_config_repo failed");
ok = false;
} }
if let Err(e) = push_config(name).await { if let Err(e) = push_config(name).await {
tracing::warn!(%name, error = ?e, "forge: push_config failed"); tracing::warn!(%name, error = ?e, "forge: push_config failed");
ok = false;
} }
// Grant read-only access to core/meta and wire the `meta` remote // Grant read-only access to core/meta and wire the `meta` remote
// into the proposed repo so agents can fetch their deployment context. // into the proposed repo so agents can fetch their deployment context.
@ -198,9 +206,11 @@ pub async fn sync_agent(name: &str, core_token: Option<&str>) {
&& let Err(e) = meta_read_access(name, token).await && let Err(e) = meta_read_access(name, token).await
{ {
tracing::warn!(%name, error = ?e, "forge: ensure_meta_read_access failed"); tracing::warn!(%name, error = ?e, "forge: ensure_meta_read_access failed");
ok = false;
} }
if let Err(e) = ensure_meta_remote(name).await { if let Err(e) = ensure_meta_remote(name).await {
tracing::warn!(%name, error = ?e, "forge: ensure_meta_remote failed"); tracing::warn!(%name, error = ?e, "forge: ensure_meta_remote failed");
ok = false;
} }
// Grant read-only access to internal/docs so the agent can clone // Grant read-only access to internal/docs so the agent can clone
// the operator-curated shared skills/runbook repo. Best-effort. // the operator-curated shared skills/runbook repo. Best-effort.
@ -208,8 +218,10 @@ pub async fn sync_agent(name: &str, core_token: Option<&str>) {
&& let Err(e) = shared_docs_access(name, token).await && let Err(e) = shared_docs_access(name, token).await
{ {
tracing::warn!(%name, error = ?e, "forge: shared_docs_access failed"); tracing::warn!(%name, error = ?e, "forge: shared_docs_access failed");
ok = false;
} }
// internal/knowledge is public — no per-agent collaborator grant needed. // internal/knowledge is public — no per-agent collaborator grant needed.
ok
} }
/// The `core_token.is_some()` half of [`ensure_all`]: orgs, teams, the meta /// The `core_token.is_some()` half of [`ensure_all`]: orgs, teams, the meta
@ -346,11 +358,27 @@ pub async fn ensure_all() {
); );
return; return;
}; };
let mut sync_failed: Vec<String> = Vec::new();
for c in containers { for c in containers {
let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else { let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else {
continue; continue;
}; };
sync_agent(name, core_token.as_deref()).await; if !sync_agent(name, core_token.as_deref()).await {
sync_failed.push(name.to_owned());
}
}
if !sync_failed.is_empty() {
// Use static_kind to mint a `&'static str` key from the failed-agent
// list (bounded leak: one per hive-c0re boot, not per request).
let key = static_kind(format!("forge_sync_agent_{}", sync_failed.join("_")));
crate::warnings::set_boot_warning(
key,
"warn",
format!(
"forge: per-agent sync failed for: {} (see journal for per-step detail)",
sync_failed.join(", ")
),
);
} }
} }