feat(#1137): rich unread summary in loose ends and wake signal
- hive-sh4re: UnreadMatrix gains summary: String field (per-room breakdown) - hive-matrix-mcp/protocol: add RoomUnread struct + UnreadSummary request - hive-matrix-mcp/handlers: collect_unread() fetches per-room data; single-unread rooms include truncated last-message body + sender; multi-unread rooms carry count only - hive-matrix-mcp/wake: format_unread_summary() builds wake body from RoomUnread slice; terse one-liner for single-room/single-message, bulleted list for multi-room; always appends read-hint - hive-matrix-mcp/timeline: wake body now covers all rooms with unread at fire time, not just the triggering event; falls back to per-event teaser if notification counts haven't updated yet - hive-ag3nt/mcp: matrix_unread_summary() replaces matrix_unread_rooms(); UnreadMatrix loose end carries per-room summary lines; render shows room breakdown with sender: body for single-unread rooms
This commit is contained in:
parent
46edc635f2
commit
68e30b857c
19 changed files with 421 additions and 108 deletions
|
|
@ -501,9 +501,12 @@ fn gateway_list_users(file: &Path) -> Result<()> {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn agents_restart(socket: &Path, name: &str) -> Result<()> {
|
||||
let resp = hive_c0re::client::request(socket, hive_sh4re::HostRequest::Restart {
|
||||
name: name.to_owned(),
|
||||
})
|
||||
let resp = hive_c0re::client::request(
|
||||
socket,
|
||||
hive_sh4re::HostRequest::Restart {
|
||||
name: name.to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("connect to daemon socket {}", socket.display()))?;
|
||||
if resp.ok {
|
||||
|
|
|
|||
|
|
@ -121,7 +121,11 @@ pub enum MessageEvent {
|
|||
/// `recv_blocking_batch` for the target agent but is not stored,
|
||||
/// not re-delivered on restart, and not shown in message history.
|
||||
/// Used for bash task completion notifications.
|
||||
Ping { to: String, from: String, body: String },
|
||||
Ping {
|
||||
to: String,
|
||||
from: String,
|
||||
body: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Per-recipient in-memory bookkeeping for the deliver-then-ack
|
||||
|
|
@ -423,11 +427,7 @@ impl Broker {
|
|||
// Transient ping — not sqlite-backed. Return it directly as
|
||||
// a Delivery with id=0 (sentinel: never pushed to unacked_ids
|
||||
// so ack_turn silently ignores it).
|
||||
Ok(Ok(MessageEvent::Ping {
|
||||
to,
|
||||
from,
|
||||
body,
|
||||
})) if to == recipient => {
|
||||
Ok(Ok(MessageEvent::Ping { to, from, body })) if to == recipient => {
|
||||
// Also drain any real sqlite messages that may have landed
|
||||
// concurrently; prepend the ping so the agent sees both.
|
||||
let mut batch = self.recv_batch(recipient, max.saturating_sub(1))?;
|
||||
|
|
|
|||
|
|
@ -1856,7 +1856,8 @@ async fn get_build_log_stream(
|
|||
stderr_append: prog.stderr_append,
|
||||
status: prog.status,
|
||||
done,
|
||||
}) && tx.send(Ok(Event::default().data(json))).await.is_err() {
|
||||
}) && tx.send(Ok(Event::default().data(json))).await.is_err()
|
||||
{
|
||||
return; // browser disconnected
|
||||
}
|
||||
if done {
|
||||
|
|
@ -2582,7 +2583,10 @@ async fn post_capabilities(
|
|||
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
||||
return reject;
|
||||
}
|
||||
let known: Vec<&str> = hive_sh4re::Capability::ALL.iter().map(|c| c.as_str()).collect();
|
||||
let known: Vec<&str> = hive_sh4re::Capability::ALL
|
||||
.iter()
|
||||
.map(|c| c.as_str())
|
||||
.collect();
|
||||
for cap in &body.caps {
|
||||
if !known.contains(&cap.as_str()) {
|
||||
return error_response(&format!("unknown capability: {cap}"));
|
||||
|
|
@ -2724,7 +2728,9 @@ async fn post_start(State(state): State<AppState>, AxumPath(name): AxumPath<Stri
|
|||
async fn post_update_all(State(state): State<AppState>) -> Response {
|
||||
let containers = lifecycle::list().await.unwrap_or_default();
|
||||
for container in containers {
|
||||
let Some(logical) = container.strip_prefix(lifecycle::AGENT_PREFIX).map(str::to_owned)
|
||||
let Some(logical) = container
|
||||
.strip_prefix(lifecycle::AGENT_PREFIX)
|
||||
.map(str::to_owned)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -115,7 +115,11 @@ pub fn agent_network_ip(name: &str, subnet_cidr: &str) -> Option<String> {
|
|||
}
|
||||
let base_u32 = u32::from_be_bytes([octets[0], octets[1], octets[2], octets[3]]);
|
||||
// Mask off host bits to get the true network address.
|
||||
let mask = if prefix_len == 0 { 0u32 } else { !0u32 << (32 - prefix_len) };
|
||||
let mask = if prefix_len == 0 {
|
||||
0u32
|
||||
} else {
|
||||
!0u32 << (32 - prefix_len)
|
||||
};
|
||||
let network_base = base_u32 & mask;
|
||||
let host_count: u32 = 1u32.checked_shl(32 - prefix_len).unwrap_or(0);
|
||||
// `.0` = network, `.1` = bridge gateway, last = broadcast → 3 reserved.
|
||||
|
|
@ -625,7 +629,9 @@ async fn prebuild_toplevel(name: &str, flake_ref: &str) -> Result<()> {
|
|||
|
||||
pub async fn list() -> Result<Vec<String>> {
|
||||
let stdout = crate::priv_client::list_containers().await?;
|
||||
Ok(stdout.lines().map(str::trim)
|
||||
Ok(stdout
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| line.starts_with(AGENT_PREFIX))
|
||||
.map(str::to_owned)
|
||||
.collect())
|
||||
|
|
@ -1025,7 +1031,8 @@ pub async fn git_update_ref(dir: &Path, refname: &str, target: &str) -> Result<(
|
|||
/// our default resource caps. Goes under `/run/systemd/system/...` so it's
|
||||
/// ephemeral (regenerated on every spawn / rebuild).
|
||||
async fn set_resource_limits(container: &str) -> Result<()> {
|
||||
crate::priv_client::write_resource_limits(container, DEFAULT_MEMORY_MAX, DEFAULT_CPU_QUOTA).await
|
||||
crate::priv_client::write_resource_limits(container, DEFAULT_MEMORY_MAX, DEFAULT_CPU_QUOTA)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn systemd_daemon_reload() -> Result<()> {
|
||||
|
|
@ -1076,9 +1083,21 @@ fn bind_child_agent_dirs(child: &str, binds: &mut Vec<BindMount>) {
|
|||
for dir in [&state_dir, &harness_dir, &config_dir] {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
binds.push(BindMount { host_path: state_dir, container_path: format!("/agents/{child}/state"), read_only: false });
|
||||
binds.push(BindMount { host_path: harness_dir, container_path: format!("/agents/{child}/harness"), read_only: false });
|
||||
binds.push(BindMount { host_path: config_dir, container_path: format!("/agents/{child}/config"), read_only: false });
|
||||
binds.push(BindMount {
|
||||
host_path: state_dir,
|
||||
container_path: format!("/agents/{child}/state"),
|
||||
read_only: false,
|
||||
});
|
||||
binds.push(BindMount {
|
||||
host_path: harness_dir,
|
||||
container_path: format!("/agents/{child}/harness"),
|
||||
read_only: false,
|
||||
});
|
||||
binds.push(BindMount {
|
||||
host_path: config_dir,
|
||||
container_path: format!("/agents/{child}/config"),
|
||||
read_only: false,
|
||||
});
|
||||
}
|
||||
|
||||
async fn set_nspawn_flags(
|
||||
|
|
@ -1103,25 +1122,49 @@ async fn set_nspawn_flags(
|
|||
let claude_mount = container_claude_mount(agent_name);
|
||||
|
||||
let mut binds: Vec<BindMount> = vec![
|
||||
BindMount { host_path: runtime_dir.to_string_lossy().into_owned(), container_path: CONTAINER_RUNTIME_MOUNT.to_owned(), read_only: false },
|
||||
BindMount { host_path: claude_dir.to_string_lossy().into_owned(), container_path: claude_mount, read_only: false },
|
||||
BindMount { host_path: HOST_SHARED_ROOT.to_owned(), container_path: CONTAINER_SHARED_MOUNT.to_owned(), read_only: false },
|
||||
BindMount {
|
||||
host_path: runtime_dir.to_string_lossy().into_owned(),
|
||||
container_path: CONTAINER_RUNTIME_MOUNT.to_owned(),
|
||||
read_only: false,
|
||||
},
|
||||
BindMount {
|
||||
host_path: claude_dir.to_string_lossy().into_owned(),
|
||||
container_path: claude_mount,
|
||||
read_only: false,
|
||||
},
|
||||
BindMount {
|
||||
host_path: HOST_SHARED_ROOT.to_owned(),
|
||||
container_path: CONTAINER_SHARED_MOUNT.to_owned(),
|
||||
read_only: false,
|
||||
},
|
||||
];
|
||||
|
||||
// Own state, harness, and config dirs — same for every agent including
|
||||
// the manager. Config is RO: an agent must not edit its own config; changes
|
||||
// only ever flow through the approval queue.
|
||||
binds.push(BindMount { host_path: notes_dir.to_string_lossy().into_owned(), container_path: format!("/agents/{agent_name}/state"), read_only: false });
|
||||
binds.push(BindMount {
|
||||
host_path: notes_dir.to_string_lossy().into_owned(),
|
||||
container_path: format!("/agents/{agent_name}/state"),
|
||||
read_only: false,
|
||||
});
|
||||
if let Some(state_parent) = notes_dir.parent() {
|
||||
let harness_dir = state_parent.join("harness");
|
||||
if !harness_dir.exists() {
|
||||
let _ = std::fs::create_dir_all(&harness_dir);
|
||||
}
|
||||
binds.push(BindMount { host_path: harness_dir.to_string_lossy().into_owned(), container_path: format!("/agents/{agent_name}/harness"), read_only: false });
|
||||
binds.push(BindMount {
|
||||
host_path: harness_dir.to_string_lossy().into_owned(),
|
||||
container_path: format!("/agents/{agent_name}/harness"),
|
||||
read_only: false,
|
||||
});
|
||||
}
|
||||
let own_config = format!("{HOST_AGENTS_ROOT}/{agent_name}/config");
|
||||
std::fs::create_dir_all(&own_config).with_context(|| format!("create {own_config}"))?;
|
||||
binds.push(BindMount { host_path: own_config, container_path: format!("/agents/{agent_name}/config"), read_only: true });
|
||||
binds.push(BindMount {
|
||||
host_path: own_config,
|
||||
container_path: format!("/agents/{agent_name}/config"),
|
||||
read_only: true,
|
||||
});
|
||||
|
||||
// Topology-driven child mounts: every direct child of this agent gets
|
||||
// its state, harness, and config dirs bind-mounted RW so the parent
|
||||
|
|
@ -1152,8 +1195,16 @@ async fn set_nspawn_flags(
|
|||
// fires first (e.g. cold start with no agents).
|
||||
std::fs::create_dir_all(HOST_META_ROOT)
|
||||
.with_context(|| format!("create {HOST_META_ROOT}"))?;
|
||||
binds.push(BindMount { host_path: HOST_APPLIED_ROOT.to_owned(), container_path: CONTAINER_MANAGER_APPLIED_MOUNT.to_owned(), read_only: true });
|
||||
binds.push(BindMount { host_path: HOST_META_ROOT.to_owned(), container_path: crate::meta::CONTAINER_MANAGER_META_MOUNT.to_owned(), read_only: true });
|
||||
binds.push(BindMount {
|
||||
host_path: HOST_APPLIED_ROOT.to_owned(),
|
||||
container_path: CONTAINER_MANAGER_APPLIED_MOUNT.to_owned(),
|
||||
read_only: true,
|
||||
});
|
||||
binds.push(BindMount {
|
||||
host_path: HOST_META_ROOT.to_owned(),
|
||||
container_path: crate::meta::CONTAINER_MANAGER_META_MOUNT.to_owned(),
|
||||
read_only: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Web-socket subdir: bind-mount `/run/hive-agent/<name>/` into the
|
||||
|
|
@ -1174,7 +1225,11 @@ async fn set_nspawn_flags(
|
|||
} else if let Err(e) = crate::priv_client::chmod_socket_dir(agent_name, 0o777).await {
|
||||
tracing::warn!(%agent_name, error = ?e, "chmod socket dir failed");
|
||||
}
|
||||
binds.push(BindMount { host_path: socket_dir.to_string_lossy().into_owned(), container_path: socket_dir.to_string_lossy().into_owned(), read_only: false });
|
||||
binds.push(BindMount {
|
||||
host_path: socket_dir.to_string_lossy().into_owned(),
|
||||
container_path: socket_dir.to_string_lossy().into_owned(),
|
||||
read_only: false,
|
||||
});
|
||||
|
||||
// Network isolation: when HIVE_NETWORK_ISOLATION=1 is set (by the
|
||||
// hive-network.nix module's `isolateContainers` option), flip the
|
||||
|
|
@ -1382,7 +1437,11 @@ mod tests {
|
|||
let ip = agent_network_ip("alice", "10.42.0.0/24").expect("should produce an IP");
|
||||
let octets: Vec<u8> = ip.split('.').map(|o| o.parse().unwrap()).collect();
|
||||
assert_eq!(&octets[..3], &[10, 42, 0], "wrong /24 prefix");
|
||||
assert!(octets[3] >= 2 && octets[3] <= 254, "host byte {}", octets[3]);
|
||||
assert!(
|
||||
octets[3] >= 2 && octets[3] <= 254,
|
||||
"host byte {}",
|
||||
octets[3]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1414,7 +1473,7 @@ mod tests {
|
|||
assert!(agent_network_ip("alice", "notanip/24").is_none());
|
||||
assert!(agent_network_ip("alice", "10.0.0.0/33").is_none()); // prefix > 32
|
||||
assert!(agent_network_ip("alice", "10.0.0.0/31").is_none()); // too small
|
||||
assert!(agent_network_ip("alice", "10.0.0.0").is_none()); // no prefix
|
||||
assert!(agent_network_ip("alice", "10.0.0.0").is_none()); // no prefix
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1424,8 +1483,10 @@ mod tests {
|
|||
// after host-bit masking.
|
||||
let from_bridge = agent_network_ip("alice", "10.42.0.1/24");
|
||||
let from_canonical = agent_network_ip("alice", "10.42.0.0/24");
|
||||
assert_eq!(from_bridge, from_canonical,
|
||||
"bridge-IP and canonical-network form should normalize to the same result");
|
||||
assert_eq!(
|
||||
from_bridge, from_canonical,
|
||||
"bridge-IP and canonical-network form should normalize to the same result"
|
||||
);
|
||||
// Result must still be in .2-.254.
|
||||
let ip = from_bridge.unwrap();
|
||||
let last: u8 = ip.rsplit('.').next().unwrap().parse().unwrap();
|
||||
|
|
|
|||
|
|
@ -299,9 +299,14 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc<Coordinator>) -> ManagerResp
|
|||
let result = match agent.as_deref() {
|
||||
Some("*") => {
|
||||
// Hive-wide query requires query_agent_state capability.
|
||||
if !crate::capabilities::has_cap(MANAGER_AGENT, hive_sh4re::Capability::QueryAgentState) {
|
||||
if !crate::capabilities::has_cap(
|
||||
MANAGER_AGENT,
|
||||
hive_sh4re::Capability::QueryAgentState,
|
||||
) {
|
||||
return ManagerResponse::Err {
|
||||
message: "query_agent_state capability required for hive-wide loose ends".into(),
|
||||
message:
|
||||
"query_agent_state capability required for hive-wide loose ends"
|
||||
.into(),
|
||||
};
|
||||
}
|
||||
crate::loose_ends::hive_wide(coord)
|
||||
|
|
|
|||
|
|
@ -365,7 +365,10 @@ pub async fn commit_capabilities(agent: &str, caps: &[String]) -> Result<()> {
|
|||
/// logged as warnings and don't propagate: the topology write already
|
||||
/// succeeded, and `sync_agents` will pick up any un-committed change
|
||||
/// on the next run as a safety net.
|
||||
pub async fn commit_topology(child: &str, new_parent: Option<&str>) -> std::result::Result<(), String> {
|
||||
pub async fn commit_topology(
|
||||
child: &str,
|
||||
new_parent: Option<&str>,
|
||||
) -> std::result::Result<(), String> {
|
||||
let _guard = META_LOCK.lock().await;
|
||||
crate::topology::set_parent(child, new_parent)?;
|
||||
let dir = meta_dir();
|
||||
|
|
@ -374,11 +377,7 @@ pub async fn commit_topology(child: &str, new_parent: Option<&str>) -> std::resu
|
|||
if has_staged_changes(&dir).await? {
|
||||
git_commit(
|
||||
&dir,
|
||||
&format!(
|
||||
"topology: {} → {}",
|
||||
child,
|
||||
new_parent.unwrap_or("<root>")
|
||||
),
|
||||
&format!("topology: {} → {}", child, new_parent.unwrap_or("<root>")),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
|
@ -526,7 +525,10 @@ where
|
|||
out.push_str(" nixpkgs-unstable.follows = \"hyperhive/nixpkgs-unstable\";\n");
|
||||
} else {
|
||||
let _ = writeln!(out, " nixpkgs.url = \"{nixpkgs_flake}\";");
|
||||
let _ = writeln!(out, " nixpkgs-unstable.url = \"{nixpkgs_unstable_flake}\";");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" nixpkgs-unstable.url = \"{nixpkgs_unstable_flake}\";"
|
||||
);
|
||||
let _ = writeln!(out, " hyperhive.url = \"{hyperhive_flake}\";");
|
||||
out.push_str(" hyperhive.inputs.nixpkgs.follows = \"nixpkgs\";\n");
|
||||
out.push_str(" hyperhive.inputs.nixpkgs-unstable.follows = \"nixpkgs-unstable\";\n");
|
||||
|
|
|
|||
|
|
@ -219,7 +219,8 @@ async fn rename_manager_container(coord: &Arc<Coordinator>) {
|
|||
// Move rootfs if it exists (may be absent for ephemeral containers).
|
||||
let old_rootfs = std::path::PathBuf::from("/var/lib/nixos-containers/root");
|
||||
let new_rootfs = std::path::PathBuf::from("/var/lib/nixos-containers/h-root");
|
||||
if old_rootfs.exists() && !new_rootfs.exists()
|
||||
if old_rootfs.exists()
|
||||
&& !new_rootfs.exists()
|
||||
&& let Err(e) = std::fs::rename(&old_rootfs, &new_rootfs)
|
||||
{
|
||||
tracing::warn!(error = ?e, "migration phase 5: rename rootfs failed (non-fatal)");
|
||||
|
|
|
|||
|
|
@ -277,7 +277,16 @@ impl RebuildQueue {
|
|||
reason: String,
|
||||
parent_id: Option<u64>,
|
||||
) -> u64 {
|
||||
self.enqueue_full(kind, agent, source, reason, parent_id, Vec::new(), None, None)
|
||||
self.enqueue_full(
|
||||
kind,
|
||||
agent,
|
||||
source,
|
||||
reason,
|
||||
parent_id,
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Same as `enqueue` but carries an `inputs` payload — used by
|
||||
|
|
@ -1304,14 +1313,20 @@ mod tests {
|
|||
Some(meta),
|
||||
);
|
||||
// The two Rebuilds have different parent_ids — must NOT dedup.
|
||||
assert_ne!(sweep_rebuild, cascade_rebuild,
|
||||
"cascade rebuild must be distinct from startup-sweep rebuild");
|
||||
assert_ne!(
|
||||
sweep_rebuild, cascade_rebuild,
|
||||
"cascade rebuild must be distinct from startup-sweep rebuild"
|
||||
);
|
||||
let snap = q.snapshot();
|
||||
let rebuilds: Vec<_> = snap.iter()
|
||||
let rebuilds: Vec<_> = snap
|
||||
.iter()
|
||||
.filter(|e| e.kind == QueueKind::Rebuild && e.agent == "alice")
|
||||
.collect();
|
||||
assert_eq!(rebuilds.len(), 2,
|
||||
"both rebuilds must be present in the queue");
|
||||
assert_eq!(
|
||||
rebuilds.len(),
|
||||
2,
|
||||
"both rebuilds must be present in the queue"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -72,8 +72,10 @@ fn write(map: &BTreeMap<String, Vec<String>>) -> std::io::Result<()> {
|
|||
/// Returns `Ok(())` when all names are known, or `Err` listing the
|
||||
/// unrecognised names so callers can surface a useful error message.
|
||||
pub fn validate_groups(groups: &[String]) -> anyhow::Result<()> {
|
||||
let valid: std::collections::BTreeSet<&str> =
|
||||
hive_sh4re::ToolGroup::ALL.iter().map(|g| g.as_str()).collect();
|
||||
let valid: std::collections::BTreeSet<&str> = hive_sh4re::ToolGroup::ALL
|
||||
.iter()
|
||||
.map(|g| g.as_str())
|
||||
.collect();
|
||||
let unknown: Vec<&str> = groups
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
|
|
|
|||
Loading…
Reference in a new issue