move the send and turn row markers into hive-types
This commit is contained in:
parent
e4a9005468
commit
6084fb6329
9 changed files with 119 additions and 43 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -4869,6 +4869,7 @@ dependencies = [
|
||||||
"backon",
|
"backon",
|
||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"chrono",
|
||||||
"forgejo-api",
|
"forgejo-api",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
//! both a text block and a `tool_use` block produces two rows.
|
//! both a text block and a `tool_use` block produces two rows.
|
||||||
|
|
||||||
pub use hive_sh4re::term_msg::{ClassifyCtx, Level, TermMsg, iso8601_utc};
|
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;
|
use crate::events::LiveEvent;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@
|
||||||
//! `mcp__hyperhive__send`'s row say, which tools get an expandable body)
|
//! `mcp__hyperhive__send`'s row say, which tools get an expandable body)
|
||||||
//! didn't change; only the shape it gets packed into did.
|
//! 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};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
/// Classify one raw claude stream-json line into zero or more terminal rows.
|
/// 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.icon.as_deref(), Some(SEND_ICON));
|
||||||
assert_eq!(row.summary.contains(" · 3L"), lines, "{}", row.summary);
|
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!(hive_types::term_msg::send_recipient("send* →"), None);
|
||||||
assert_eq!(crate::term_msg::send_recipient("Read(x)"), None);
|
assert_eq!(hive_types::term_msg::send_recipient("Read(x)"), None);
|
||||||
// A suffix lookalike in the name is not stripped.
|
// A suffix lookalike in the name is not stripped.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
crate::term_msg::send_recipient("send* → a · xL"),
|
hive_types::term_msg::send_recipient("send* → a · xL"),
|
||||||
Some("a · xL")
|
Some("a · xL")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -128,41 +128,6 @@ pub fn iso8601_utc(unix_seconds: i64) -> String {
|
||||||
.to_rfc3339_opts(SecondsFormat::Secs, true)
|
.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 ` · <N>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
|
/// Per-connection/per-request classification state. A live SSE stream keeps
|
||||||
/// one of these alive for the connection's lifetime — `tool_use` id → name
|
/// 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
|
/// correlation, so a `tool_result` can tell it's answering a `recv` call and
|
||||||
|
|
|
||||||
|
|
@ -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
|
`Ident` (rather than bare `String`) gets serde-validated parsing **at the
|
||||||
socket boundary for free**: a malformed name is rejected at deserialize time
|
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.
|
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
|
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`
|
so anything in the workspace can depend on it. See `docs/process/conventions.md`
|
||||||
|
|
|
||||||
|
|
@ -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
|
//! A zero-dependency (bar `serde`) leaf crate so every wire-type crate
|
||||||
//! (`hive-sh4re`, `hive-host-sock`, `hive-core-agent-sock`) and both binaries
|
//! (`hive-sh4re`, `hive-host-sock`, `hive-core-agent-sock`) and both binaries
|
||||||
|
|
@ -10,6 +11,8 @@ mod permission;
|
||||||
|
|
||||||
pub use permission::Permission;
|
pub use permission::Permission;
|
||||||
|
|
||||||
|
pub mod term_msg;
|
||||||
|
|
||||||
/// The environment variable nix uses to hand this process the blacklist of
|
/// The environment variable nix uses to hand this process the blacklist of
|
||||||
/// names that already mean something to the message layer.
|
/// names that already mean something to the message layer.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
72
hive-types/src/term_msg.rs
Normal file
72
hive-types/src/term_msg.rs
Normal file
|
|
@ -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 ` · <N>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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,8 @@ forgejo-api.workspace = true
|
||||||
# decodes blobs it reads: forgejo's content and blob APIs carry base64,
|
# decodes blobs it reads: forgejo's content and blob APIs carry base64,
|
||||||
# never raw bytes.
|
# never raw bytes.
|
||||||
base64.workspace = true
|
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
|
# `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
|
# tree this daemon invents rather than receives. Straight from the kernel's
|
||||||
# CSPRNG — see the workspace entry for why this and not `rand`.
|
# CSPRNG — see the workspace entry for why this and not `rand`.
|
||||||
|
|
@ -69,6 +71,8 @@ sha2.workspace = true
|
||||||
similar.workspace = true
|
similar.workspace = true
|
||||||
# The `Permission` names `/api/agents/{name}/permissions` accepts, the same
|
# The `Permission` names `/api/agents/{name}/permissions` accepts, the same
|
||||||
# enum the hive enforces.
|
# 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`
|
# Validates `POST /api/agents`' `name` before it becomes `agent`/`repo`
|
||||||
# everywhere downstream — see `create_agent`'s doc comment for why this is
|
# everywhere downstream — see `create_agent`'s doc comment for why this is
|
||||||
# defense-in-depth, not the only gate (per an argus review finding).
|
# defense-in-depth, not the only gate (per an argus review finding).
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ use axum::extract::{Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||||
use futures_util::{Stream, StreamExt as _, stream};
|
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 serde::Deserialize;
|
||||||
use swarm_controller_api::messages::SwarmMessage;
|
use swarm_controller_api::messages::SwarmMessage;
|
||||||
use swarm_queue_client::agent_term;
|
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())
|
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
|
/// 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.
|
/// `minutes`, oldest first. An agent whose stream cannot be read adds none.
|
||||||
async fn history(
|
async fn history(
|
||||||
|
|
@ -244,6 +255,19 @@ mod tests {
|
||||||
|
|
||||||
use crate::test_nats::TestNats;
|
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 {
|
fn row(icon: &str, summary: &str, body: &str) -> bytes::Bytes {
|
||||||
json!({
|
json!({
|
||||||
"ts": "2026-10-18T12:00:00Z", "icon": icon, "level": "info",
|
"ts": "2026-10-18T12:00:00Z", "icon": icon, "level": "info",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue