host.sock: push a live agent-status stream instead of poll-only
This commit is contained in:
parent
2f7d3e02e9
commit
3871749da9
3 changed files with 181 additions and 27 deletions
|
|
@ -96,6 +96,38 @@ pub struct ContainerView {
|
||||||
pub memory_max: String,
|
pub memory_max: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<ContainerView> for hive_sh4re::container::AgentStatusRow {
|
||||||
|
/// The one place a `ContainerView` becomes the wire row `host.sock`
|
||||||
|
/// serves it as — `handle_agent_status`'s poll path and the
|
||||||
|
/// `SubscribeAgentStatus` push path both go through this, so the two
|
||||||
|
/// can never quietly diverge on which fields make the cut.
|
||||||
|
///
|
||||||
|
/// `pending_reminders` has no source here: reminders are agent-local
|
||||||
|
/// now, and c0re has no cross-agent visibility into pending counts
|
||||||
|
/// anymore. Stubbed to `0` rather than dropping the wire field
|
||||||
|
/// outright — leaves `hivectl status`/the dashboard column intact
|
||||||
|
/// syntactically, just always empty, until iris's frontend follow-up
|
||||||
|
/// decides whether to drop the column entirely. `port`, `cpu_quota`,
|
||||||
|
/// `memory_max` have no equivalent on the row at all and are simply
|
||||||
|
/// not carried over.
|
||||||
|
fn from(v: ContainerView) -> Self {
|
||||||
|
Self {
|
||||||
|
name: v.name,
|
||||||
|
running: v.running,
|
||||||
|
failed: v.failed,
|
||||||
|
needs_update: v.needs_update,
|
||||||
|
needs_login: v.needs_login,
|
||||||
|
deployed_sha: v.deployed_sha,
|
||||||
|
pending_reminders: 0,
|
||||||
|
parent: v.parent,
|
||||||
|
paused: v.paused,
|
||||||
|
active_model: v.active_model,
|
||||||
|
status_text: v.status_text,
|
||||||
|
status_set_at: v.status_set_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the full container list. Wraps `lifecycle::list()` and
|
/// Build the full container list. Wraps `lifecycle::list()` and
|
||||||
/// resolves every per-agent attribute the dashboard surfaces.
|
/// resolves every per-agent attribute the dashboard surfaces.
|
||||||
///
|
///
|
||||||
|
|
@ -468,4 +500,53 @@ mod tests {
|
||||||
let ok = lock(r#""a":"a""#, r#""a":{"locked":{"rev":"abc"}}"#);
|
let ok = lock(r#""a":"a""#, r#""a":{"locked":{"rev":"abc"}}"#);
|
||||||
assert_eq!(parse_locked_revs(&ok).len(), 1);
|
assert_eq!(parse_locked_revs(&ok).len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `From` impl both `handle_agent_status`'s poll path and
|
||||||
|
/// `stream_agent_status`'s push path go through — pins the field
|
||||||
|
/// mapping so the two can't quietly diverge, and that `port` /
|
||||||
|
/// `cpu_quota` / `memory_max` (no equivalent on the row) are dropped
|
||||||
|
/// on purpose rather than by omission.
|
||||||
|
#[test]
|
||||||
|
fn agent_status_row_carries_every_field_the_row_has_and_drops_the_rest() {
|
||||||
|
use super::ContainerView;
|
||||||
|
use chrono::{TimeZone, Utc};
|
||||||
|
use hive_sh4re::container::AgentStatusRow;
|
||||||
|
|
||||||
|
let view = ContainerView {
|
||||||
|
name: "alice".to_owned(),
|
||||||
|
container: "h-alice".to_owned(),
|
||||||
|
port: 7000,
|
||||||
|
running: true,
|
||||||
|
failed: false,
|
||||||
|
needs_update: true,
|
||||||
|
needs_login: false,
|
||||||
|
deployed_sha: Some("abc123def456".to_owned()),
|
||||||
|
parent: Some("bob".to_owned()),
|
||||||
|
active_model: Some("claude-opus".to_owned()),
|
||||||
|
status_text: Some("shipping".to_owned()),
|
||||||
|
status_set_at: Some(Utc.timestamp_opt(1_700_000_000, 0).unwrap()),
|
||||||
|
paused: true,
|
||||||
|
cpu_quota: "400%".to_owned(),
|
||||||
|
memory_max: "8G".to_owned(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let row = AgentStatusRow::from(view);
|
||||||
|
assert_eq!(row.name, "alice");
|
||||||
|
assert!(row.running);
|
||||||
|
assert!(!row.failed);
|
||||||
|
assert!(row.needs_update);
|
||||||
|
assert!(!row.needs_login);
|
||||||
|
assert_eq!(row.deployed_sha.as_deref(), Some("abc123def456"));
|
||||||
|
assert_eq!(row.parent.as_deref(), Some("bob"));
|
||||||
|
assert!(row.paused);
|
||||||
|
assert_eq!(row.active_model.as_deref(), Some("claude-opus"));
|
||||||
|
assert_eq!(row.status_text.as_deref(), Some("shipping"));
|
||||||
|
assert_eq!(
|
||||||
|
row.status_set_at,
|
||||||
|
Some(Utc.timestamp_opt(1_700_000_000, 0).unwrap())
|
||||||
|
);
|
||||||
|
// No source for reminders on this side (see the `From` impl's doc
|
||||||
|
// comment) — always the stub, never left uninitialised.
|
||||||
|
assert_eq!(row.pending_reminders, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,17 +63,43 @@ async fn handle(stream: UnixStream, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let resp = match serde_json::from_str::<HostRequest>(line.trim()) {
|
let req = match serde_json::from_str::<HostRequest>(line.trim()) {
|
||||||
Ok(req) => dispatch(&req, coord.clone()).await,
|
Ok(req) => req,
|
||||||
Err(e) => HostResponse::error(format!("parse error: {e}")),
|
Err(e) => {
|
||||||
|
write_response(
|
||||||
|
&mut write,
|
||||||
|
&HostResponse::error(format!("parse error: {e}")),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let mut payload = serde_json::to_string(&resp)?;
|
// The one verb that answers with more than one response — see its
|
||||||
payload.push('\n');
|
// doc comment. Hands `write` over and stops reading further
|
||||||
write.write_all(payload.as_bytes()).await?;
|
// requests on this connection; a client wanting anything else
|
||||||
write.flush().await?;
|
// opens a fresh one.
|
||||||
|
if matches!(req, HostRequest::SubscribeAgentStatus) {
|
||||||
|
return stream_agent_status(write, coord).await;
|
||||||
|
}
|
||||||
|
let resp = dispatch(&req, coord.clone()).await;
|
||||||
|
write_response(&mut write, &resp).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serialize one `HostResponse` as a JSON line and flush it. Shared by the
|
||||||
|
/// ordinary one-response-per-request path and `stream_agent_status`'s
|
||||||
|
/// multi-response one, so the two can't quietly drift on framing.
|
||||||
|
async fn write_response(
|
||||||
|
write: &mut tokio::net::unix::OwnedWriteHalf,
|
||||||
|
resp: &HostResponse,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut payload = serde_json::to_string(resp)?;
|
||||||
|
payload.push('\n');
|
||||||
|
write.write_all(payload.as_bytes()).await?;
|
||||||
|
write.flush().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(
|
#[allow(
|
||||||
clippy::too_many_lines,
|
clippy::too_many_lines,
|
||||||
reason = "flat one-arm-per-HostRequest-variant router; each arm just \
|
reason = "flat one-arm-per-HostRequest-variant router; each arm just \
|
||||||
|
|
@ -159,6 +185,13 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
||||||
// see the request's doc comment for why the client asks.
|
// see the request's doc comment for why the client asks.
|
||||||
HostRequest::AgentExists { name } => HostResponse::agent_exists(agent_exists(name)?),
|
HostRequest::AgentExists { name } => HostResponse::agent_exists(agent_exists(name)?),
|
||||||
HostRequest::AgentStatus => handle_agent_status(&coord).await,
|
HostRequest::AgentStatus => handle_agent_status(&coord).await,
|
||||||
|
// Intercepted in `handle()` before a request ever reaches
|
||||||
|
// `dispatch` — see `stream_agent_status`'s doc comment. This
|
||||||
|
// arm exists only so the match stays exhaustive; reaching it
|
||||||
|
// would mean a future caller invoked `dispatch` directly.
|
||||||
|
HostRequest::SubscribeAgentStatus => {
|
||||||
|
HostResponse::error("SubscribeAgentStatus is handled by the connection loop")
|
||||||
|
}
|
||||||
// The hive domain + per-surface public URLs are injected into
|
// The hive domain + per-surface public URLs are injected into
|
||||||
// c0re's service env by the hyperhive module; surface them so the
|
// c0re's service env by the hyperhive module; surface them so the
|
||||||
// operator CLI can fill in this hive's own identity (the
|
// operator CLI can fill in this hive's own identity (the
|
||||||
|
|
@ -368,30 +401,54 @@ async fn handle_agent_status(coord: &Arc<Coordinator>) -> HostResponse {
|
||||||
let rows = crate::container_view::build_all(&coord.hive_env())
|
let rows = crate::container_view::build_all(&coord.hive_env())
|
||||||
.await
|
.await
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|v| hive_sh4re::container::AgentStatusRow {
|
.map(hive_sh4re::container::AgentStatusRow::from)
|
||||||
name: v.name,
|
|
||||||
running: v.running,
|
|
||||||
failed: v.failed,
|
|
||||||
needs_update: v.needs_update,
|
|
||||||
needs_login: v.needs_login,
|
|
||||||
deployed_sha: v.deployed_sha,
|
|
||||||
// Reminders are agent-local now; c0re has no cross-agent
|
|
||||||
// visibility into pending counts anymore. Stubbed
|
|
||||||
// to 0 rather than deleting the wire field outright — leaves
|
|
||||||
// `hivectl status`/the dashboard column intact syntactically,
|
|
||||||
// just always empty, until iris's frontend follow-up decides
|
|
||||||
// whether to drop the column entirely.
|
|
||||||
pending_reminders: 0,
|
|
||||||
parent: v.parent,
|
|
||||||
paused: v.paused,
|
|
||||||
active_model: v.active_model,
|
|
||||||
status_text: v.status_text,
|
|
||||||
status_set_at: v.status_set_at,
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
HostResponse::agent_statuses(rows)
|
HostResponse::agent_statuses(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `SubscribeAgentStatus` — ack once, then push one single-row
|
||||||
|
/// [`HostResponse::agent_statuses`] per agent-status change for as long as
|
||||||
|
/// the client stays connected. Consumes `write` (rather than borrowing it,
|
||||||
|
/// like every other handler here) because this is the one verb that keeps
|
||||||
|
/// writing after its own response, so `handle()` hands over ownership and
|
||||||
|
/// stops reading further requests on this connection once it calls this.
|
||||||
|
///
|
||||||
|
/// Rides the same `Coordinator.dashboard_events` broadcast channel the
|
||||||
|
/// dashboard's own SSE route reads (`dashboard/state_snapshot.rs`) —
|
||||||
|
/// `SetStatus` already triggers a `rescan_containers_and_emit` on every
|
||||||
|
/// status change (`socket_server/mod.rs::handle_set_status`), so that
|
||||||
|
/// channel already carries every event this needs; no separate plumbing.
|
||||||
|
async fn stream_agent_status(
|
||||||
|
mut write: tokio::net::unix::OwnedWriteHalf,
|
||||||
|
coord: Arc<Coordinator>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut events = coord.dashboard_subscribe();
|
||||||
|
write_response(&mut write, &HostResponse::success()).await?;
|
||||||
|
loop {
|
||||||
|
match events.recv().await {
|
||||||
|
Ok(crate::dashboard_events::DashboardEvent::ContainerStateChanged {
|
||||||
|
container,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
let row = hive_sh4re::container::AgentStatusRow::from(container);
|
||||||
|
write_response(&mut write, &HostResponse::agent_statuses(vec![row])).await?;
|
||||||
|
}
|
||||||
|
// Every other event kind on this channel is dashboard-only
|
||||||
|
// (approvals, broker traffic, the queue, …) — nothing this
|
||||||
|
// subscriber asked for.
|
||||||
|
Ok(_) => {}
|
||||||
|
// Best-effort, same contract the dashboard's own live channel
|
||||||
|
// already has (see `HostRequest::SubscribeAgentStatus`'s doc
|
||||||
|
// comment) — a slow reader drops updates rather than stalling
|
||||||
|
// the broadcaster for everyone else on the channel.
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
|
||||||
|
tracing::warn!(skipped, "agent-status subscribe: receiver lagged");
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => return Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Matrix provisioning handlers
|
// Matrix provisioning handlers
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -206,6 +206,22 @@ pub enum HostRequest {
|
||||||
/// pending reminders) — the `hivectl list-agents` roster view.
|
/// pending reminders) — the `hivectl list-agents` roster view.
|
||||||
/// Reuses the dashboard's per-agent `ContainerView` aggregation.
|
/// Reuses the dashboard's per-agent `ContainerView` aggregation.
|
||||||
AgentStatus,
|
AgentStatus,
|
||||||
|
/// Turn this connection into a live push feed of agent-status changes,
|
||||||
|
/// so a polling loop against [`HostRequest::AgentStatus`] is no longer
|
||||||
|
/// the only way to notice one. The one verb on this socket that answers
|
||||||
|
/// with more than one response: the server acks with a bare
|
||||||
|
/// [`HostResponse::success()`] first, then keeps the connection open
|
||||||
|
/// and writes one further [`HostResponse::agent_statuses`] (always a
|
||||||
|
/// single-row `Vec`) per changed agent, for as long as the client stays
|
||||||
|
/// connected. No further requests are read on this connection after
|
||||||
|
/// this one — open a fresh connection for anything else.
|
||||||
|
///
|
||||||
|
/// Best-effort, same contract the dashboard's own live channel already
|
||||||
|
/// has: a slow reader can miss updates rather than back-pressuring the
|
||||||
|
/// daemon, so a client that cares about the *current* state after a gap
|
||||||
|
/// should still fall back to a fresh [`HostRequest::AgentStatus`] poll,
|
||||||
|
/// not assume the stream alone is authoritative.
|
||||||
|
SubscribeAgentStatus,
|
||||||
/// Report this hive's canonical DNS domain
|
/// Report this hive's canonical DNS domain
|
||||||
/// (`services.hyperhive.domain`) plus the browser-facing home /
|
/// (`services.hyperhive.domain`) plus the browser-facing home /
|
||||||
/// forge / matrix URLs, daemon-sourced so custom forge/matrix
|
/// forge / matrix URLs, daemon-sourced so custom forge/matrix
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue