From 6084fb6329f92da0475eb5c08994add3c0b2cdf7 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 18 Oct 2026 19:06:36 +0200 Subject: [PATCH] move the send and turn row markers into hive-types --- Cargo.lock | 1 + hive-agent/src/term_msg.rs | 2 +- hive-sh4re/src/stream_enrich.rs | 14 +++-- hive-sh4re/src/term_msg.rs | 35 ------------- hive-types/README.md | 3 ++ hive-types/src/lib.rs | 5 +- hive-types/src/term_msg.rs | 72 ++++++++++++++++++++++++++ swarm-controller/Cargo.toml | 4 ++ swarm-controller/src/message_stream.rs | 26 +++++++++- 9 files changed, 119 insertions(+), 43 deletions(-) create mode 100644 hive-types/src/term_msg.rs diff --git a/Cargo.lock b/Cargo.lock index 375f1664..f6252caa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4869,6 +4869,7 @@ dependencies = [ "backon", "base64", "bytes", + "chrono", "forgejo-api", "futures-util", "getrandom 0.3.4", diff --git a/hive-agent/src/term_msg.rs b/hive-agent/src/term_msg.rs index 55553c95..d3db1179 100644 --- a/hive-agent/src/term_msg.rs +++ b/hive-agent/src/term_msg.rs @@ -11,7 +11,7 @@ //! both a text block and a `tool_use` block produces two rows. pub use hive_sh4re::term_msg::{ClassifyCtx, Level, TermMsg, iso8601_utc}; -use hive_sh4re::term_msg::{TURN_ICON, TURN_SUMMARY_PREFIX}; +use hive_types::term_msg::{TURN_ICON, TURN_SUMMARY_PREFIX}; use crate::events::LiveEvent; diff --git a/hive-sh4re/src/stream_enrich.rs b/hive-sh4re/src/stream_enrich.rs index 67ac545d..05beff1e 100644 --- a/hive-sh4re/src/stream_enrich.rs +++ b/hive-sh4re/src/stream_enrich.rs @@ -15,7 +15,8 @@ //! `mcp__hyperhive__send`'s row say, which tools get an expandable body) //! didn't change; only the shape it gets packed into did. -use crate::term_msg::{BodyFormat, ClassifyCtx, Level, SEND_ICON, SEND_SUMMARY_PREFIX, TermMsg}; +use crate::term_msg::{BodyFormat, ClassifyCtx, Level, TermMsg}; +use hive_types::term_msg::{SEND_ICON, SEND_SUMMARY_PREFIX}; use serde_json::{Value, json}; /// Classify one raw claude stream-json line into zero or more terminal rows. @@ -1346,13 +1347,16 @@ mod tests { })); assert_eq!(row.icon.as_deref(), Some(SEND_ICON)); assert_eq!(row.summary.contains(" · 3L"), lines, "{}", row.summary); - assert_eq!(crate::term_msg::send_recipient(&row.summary), Some("iris")); + assert_eq!( + hive_types::term_msg::send_recipient(&row.summary), + Some("iris") + ); } - assert_eq!(crate::term_msg::send_recipient("send* →"), None); - assert_eq!(crate::term_msg::send_recipient("Read(x)"), None); + assert_eq!(hive_types::term_msg::send_recipient("send* →"), None); + assert_eq!(hive_types::term_msg::send_recipient("Read(x)"), None); // A suffix lookalike in the name is not stripped. assert_eq!( - crate::term_msg::send_recipient("send* → a · xL"), + hive_types::term_msg::send_recipient("send* → a · xL"), Some("a · xL") ); } diff --git a/hive-sh4re/src/term_msg.rs b/hive-sh4re/src/term_msg.rs index 429e45be..d896934c 100644 --- a/hive-sh4re/src/term_msg.rs +++ b/hive-sh4re/src/term_msg.rs @@ -128,41 +128,6 @@ pub fn iso8601_utc(unix_seconds: i64) -> String { .to_rfc3339_opts(SecondsFormat::Secs, true) } -/// Icon of the row an agent's `send` tool call makes. -pub const SEND_ICON: &str = "📤"; - -/// What a `send` row's summary starts with; the recipient follows. A -/// multi-line body appends ` · L`, which [`send_recipient`] drops. -pub const SEND_SUMMARY_PREFIX: &str = "send* → "; - -/// Icon of the row a turn started by a message makes. -pub const TURN_ICON: &str = "◆"; - -/// What a turn-start row's summary starts with; the sender follows. -pub const TURN_SUMMARY_PREFIX: &str = "TURN ← "; - -/// The recipient a `send` row's summary names, without the line-count suffix. -#[must_use] -pub fn send_recipient(summary: &str) -> Option<&str> { - let rest = summary.strip_prefix(SEND_SUMMARY_PREFIX)?; - Some(match rest.rsplit_once(" · ") { - Some((to, lines)) - if lines - .strip_suffix('L') - .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) => - { - to - } - _ => rest, - }) -} - -/// The sender a turn-start row's summary names. -#[must_use] -pub fn turn_sender(summary: &str) -> Option<&str> { - summary.strip_prefix(TURN_SUMMARY_PREFIX) -} - /// Per-connection/per-request classification state. A live SSE stream keeps /// one of these alive for the connection's lifetime — `tool_use` id → name /// correlation, so a `tool_result` can tell it's answering a `recv` call and diff --git a/hive-types/README.md b/hive-types/README.md index 819ca9cb..9a057917 100644 --- a/hive-types/README.md +++ b/hive-types/README.md @@ -14,6 +14,9 @@ Foundational shared **newtypes** for the hyperhive workspace. A zero-dependency `Ident` (rather than bare `String`) gets serde-validated parsing **at the socket boundary for free**: a malformed name is rejected at deserialize time instead of flowing into a handler that has to re-validate by hand. +- **`term_msg`** — the icons and summary prefixes of a terminal row that sent a + message or started a turn from one, and the parsers that read the recipient + or sender back out. The crate is deliberately a leaf with the smallest possible dependency footprint so anything in the workspace can depend on it. See `docs/process/conventions.md` diff --git a/hive-types/src/lib.rs b/hive-types/src/lib.rs index f769777f..0ef2e23d 100644 --- a/hive-types/src/lib.rs +++ b/hive-types/src/lib.rs @@ -1,4 +1,5 @@ -//! Foundational shared types for the hyperhive workspace: [`Ident`] and [`Permission`]. +//! Foundational shared types for the hyperhive workspace: [`Ident`], [`Permission`] +//! and the [`term_msg`] row markers. //! //! A zero-dependency (bar `serde`) leaf crate so every wire-type crate //! (`hive-sh4re`, `hive-host-sock`, `hive-core-agent-sock`) and both binaries @@ -10,6 +11,8 @@ mod permission; pub use permission::Permission; +pub mod term_msg; + /// The environment variable nix uses to hand this process the blacklist of /// names that already mean something to the message layer. /// diff --git a/hive-types/src/term_msg.rs b/hive-types/src/term_msg.rs new file mode 100644 index 00000000..4a4bf6e6 --- /dev/null +++ b/hive-types/src/term_msg.rs @@ -0,0 +1,72 @@ +//! The icons and summary prefixes that mark a terminal row as a sent message +//! or a message-started turn, and the parsers that read the peer back out. +//! +//! The classifier in `hive-sh4re` builds the summaries and +//! `swarm-controller`'s message stream parses them, so both sides name the +//! same constants and the same parsers rather than each spelling the format. + +/// Icon of the row an agent's `send` tool call makes. +pub const SEND_ICON: &str = "📤"; + +/// What a `send` row's summary starts with; the recipient follows. A +/// multi-line body appends ` · L`, which [`send_recipient`] drops. +pub const SEND_SUMMARY_PREFIX: &str = "send* → "; + +/// Icon of the row a turn started by a message makes. +pub const TURN_ICON: &str = "◆"; + +/// What a turn-start row's summary starts with; the sender follows. +pub const TURN_SUMMARY_PREFIX: &str = "TURN ← "; + +/// The recipient a `send` row's summary names, without the line-count suffix. +#[must_use] +pub fn send_recipient(summary: &str) -> Option<&str> { + let rest = summary.strip_prefix(SEND_SUMMARY_PREFIX)?; + Some(match rest.rsplit_once(" · ") { + Some((to, lines)) + if lines + .strip_suffix('L') + .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) => + { + to + } + _ => rest, + }) +} + +/// The sender a turn-start row's summary names. +#[must_use] +pub fn turn_sender(summary: &str) -> Option<&str> { + summary.strip_prefix(TURN_SUMMARY_PREFIX) +} + +#[cfg(test)] +mod tests { + use super::{SEND_SUMMARY_PREFIX, TURN_SUMMARY_PREFIX, send_recipient, turn_sender}; + + #[test] + fn a_send_summary_names_its_recipient_with_and_without_the_line_count() { + assert_eq!( + send_recipient(&format!("{SEND_SUMMARY_PREFIX}iris")), + Some("iris") + ); + assert_eq!( + send_recipient(&format!("{SEND_SUMMARY_PREFIX}iris · 3L")), + Some("iris") + ); + assert_eq!(send_recipient("send* →"), None); + assert_eq!(send_recipient("Read(x)"), None); + // A suffix lookalike in the name is not stripped. + assert_eq!(send_recipient("send* → a · xL"), Some("a · xL")); + } + + #[test] + fn a_turn_summary_names_its_sender() { + assert_eq!( + turn_sender(&format!("{TURN_SUMMARY_PREFIX}atlas")), + Some("atlas") + ); + assert_eq!(turn_sender("TURN ←"), None); + assert_eq!(turn_sender("send* → iris"), None); + } +} diff --git a/swarm-controller/Cargo.toml b/swarm-controller/Cargo.toml index 5b46f230..cb153ca8 100644 --- a/swarm-controller/Cargo.toml +++ b/swarm-controller/Cargo.toml @@ -27,6 +27,8 @@ forgejo-api.workspace = true # decodes blobs it reads: forgejo's content and blob APIs carry base64, # never raw bytes. base64.workspace = true +# `message_stream.rs` renders the history cutoff in the spelling of a row's `ts`. +chrono.workspace = true # `agent_identity.rs` mints the per-agent queue secret, the one value in this # tree this daemon invents rather than receives. Straight from the kernel's # CSPRNG — see the workspace entry for why this and not `rand`. @@ -69,6 +71,8 @@ sha2.workspace = true similar.workspace = true # The `Permission` names `/api/agents/{name}/permissions` accepts, the same # enum the hive enforces. +# The send and turn row markers `message_stream.rs` reads a message back out +# of. # Validates `POST /api/agents`' `name` before it becomes `agent`/`repo` # everywhere downstream — see `create_agent`'s doc comment for why this is # defense-in-depth, not the only gate (per an argus review finding). diff --git a/swarm-controller/src/message_stream.rs b/swarm-controller/src/message_stream.rs index c130686b..9f07e6fa 100644 --- a/swarm-controller/src/message_stream.rs +++ b/swarm-controller/src/message_stream.rs @@ -32,7 +32,7 @@ use axum::extract::{Query, State}; use axum::http::StatusCode; use axum::response::sse::{Event, KeepAlive, Sse}; use futures_util::{Stream, StreamExt as _, stream}; -use hive_sh4re::term_msg::{SEND_ICON, TURN_ICON, iso8601_utc, send_recipient, turn_sender}; +use hive_types::term_msg::{SEND_ICON, TURN_ICON, send_recipient, turn_sender}; use serde::Deserialize; use swarm_controller_api::messages::SwarmMessage; use swarm_queue_client::agent_term; @@ -125,6 +125,17 @@ fn message_event(message: &SwarmMessage) -> Event { Event::default().data(serde_json::to_string(message).unwrap_or_default()) } +/// A unix-seconds stamp as ISO 8601 / RFC 3339 UTC, the spelling a row's `ts` +/// carries, so the two compare as strings. It must equal +/// `hive_sh4re::term_msg::iso8601_utc`, which stamps those rows. +/// +/// A stamp outside the representable range renders as the epoch. +fn iso8601_utc(unix_seconds: i64) -> String { + chrono::DateTime::from_timestamp(unix_seconds, 0) + .unwrap_or_default() + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true) +} + /// The messages in the newest rows of each of `roster`'s streams from the last /// `minutes`, oldest first. An agent whose stream cannot be read adds none. async fn history( @@ -244,6 +255,19 @@ mod tests { use crate::test_nats::TestNats; + /// The spelling is the one the agents stamp rows with (RFC 3339, whole + /// seconds, `Z`): the history cutoff is compared with `ts` as a string. + #[test] + fn iso8601_utc_spells_a_stamp_the_way_a_row_ts_does() { + assert_eq!(iso8601_utc(0), "1970-01-01T00:00:00Z"); + assert_eq!(iso8601_utc(1_789_302_903), "2026-09-13T12:35:03Z"); + } + + #[test] + fn iso8601_utc_renders_an_out_of_range_stamp_as_the_epoch() { + assert_eq!(iso8601_utc(i64::MAX), "1970-01-01T00:00:00Z"); + } + fn row(icon: &str, summary: &str, body: &str) -> bytes::Bytes { json!({ "ts": "2026-10-18T12:00:00Z", "icon": icon, "level": "info",