huskies: merge 762

This commit is contained in:
dave
2026-04-28 01:27:00 +00:00
parent de5b585157
commit 0d14fffe1c
6 changed files with 253 additions and 2 deletions
+173 -1
View File
@@ -14,7 +14,8 @@ use std::sync::Arc;
pub use crate::service::gateway::{
GatewayConfig, GatewayState as GatewayStateType, GatewayStatusEvent, JoinedAgent, ProjectEntry,
broadcast_status_event, fetch_all_project_pipeline_statuses, format_aggregate_status_compact,
spawn_gateway_notification_poller, subscribe_status_events,
spawn_gateway_broadcaster_forwarder, spawn_gateway_notification_poller,
subscribe_status_events,
};
/// Build the complete gateway route tree.
@@ -130,6 +131,7 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
gateway_projects,
gateway_project_urls,
port,
Some(state_arc.event_tx.clone()),
);
*state_arc.bot_handle.lock().await = bot_abort;
@@ -976,6 +978,176 @@ mod tests {
}
}
// ── Gateway broadcaster forwarder tests ─────────────────────────────
#[tokio::test]
async fn broadcaster_forwarder_forwards_events_with_project_tag() {
use crate::chat::{ChatTransport, MessageId};
use crate::service::events::StoredEvent;
use async_trait::async_trait;
type CallLog = Arc<std::sync::Mutex<Vec<(String, String)>>>;
struct MockTransport {
calls: CallLog,
}
#[async_trait]
impl ChatTransport for MockTransport {
async fn send_message(
&self,
room_id: &str,
plain: &str,
_html: &str,
) -> Result<MessageId, String> {
self.calls
.lock()
.unwrap()
.push((room_id.to_string(), plain.to_string()));
Ok("id".to_string())
}
async fn edit_message(
&self,
_room_id: &str,
_id: &str,
_plain: &str,
_html: &str,
) -> Result<(), String> {
Ok(())
}
async fn send_typing(&self, _room_id: &str, _typing: bool) -> Result<(), String> {
Ok(())
}
}
let calls: CallLog = Arc::new(std::sync::Mutex::new(Vec::new()));
let transport = Arc::new(MockTransport {
calls: Arc::clone(&calls),
});
let (tx, rx) =
tokio::sync::broadcast::channel::<crate::service::gateway::GatewayStatusEvent>(16);
gateway::spawn_gateway_broadcaster_forwarder(
transport as Arc<dyn crate::chat::ChatTransport>,
vec!["!room:example.org".to_string()],
rx,
);
// Give the forwarder task a moment to start.
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
let event = crate::service::gateway::GatewayStatusEvent {
project: "my-project".to_string(),
event: StoredEvent::StageTransition {
story_id: "7_story_x".to_string(),
from_stage: "2_current".to_string(),
to_stage: "3_qa".to_string(),
timestamp_ms: 100,
},
};
tx.send(event).unwrap();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let messages = calls.lock().unwrap();
assert_eq!(messages.len(), 1, "Expected exactly one notification");
let (room, plain) = &messages[0];
assert_eq!(room, "!room:example.org");
assert!(
plain.starts_with("[my-project]"),
"Expected [my-project] prefix; got: {plain}"
);
assert!(
plain.contains("7_story_x"),
"Expected story ID; got: {plain}"
);
}
#[tokio::test]
async fn broadcaster_forwarder_resubscribes_on_lag() {
use crate::chat::{ChatTransport, MessageId};
use crate::service::events::StoredEvent;
use async_trait::async_trait;
type Counter = Arc<std::sync::Mutex<usize>>;
struct CountTransport {
count: Counter,
}
#[async_trait]
impl ChatTransport for CountTransport {
async fn send_message(
&self,
_room_id: &str,
_plain: &str,
_html: &str,
) -> Result<MessageId, String> {
*self.count.lock().unwrap() += 1;
Ok("id".to_string())
}
async fn edit_message(
&self,
_room_id: &str,
_id: &str,
_plain: &str,
_html: &str,
) -> Result<(), String> {
Ok(())
}
async fn send_typing(&self, _room_id: &str, _typing: bool) -> Result<(), String> {
Ok(())
}
}
let count: Counter = Arc::new(std::sync::Mutex::new(0));
let transport = Arc::new(CountTransport {
count: Arc::clone(&count),
});
// Use a tiny channel (capacity 1) so the second send causes a Lagged error.
let (tx, rx) =
tokio::sync::broadcast::channel::<crate::service::gateway::GatewayStatusEvent>(1);
// Flood the channel to trigger Lagged before the forwarder task starts.
let make_event = |n: u64| crate::service::gateway::GatewayStatusEvent {
project: "p".to_string(),
event: StoredEvent::StageTransition {
story_id: format!("{n}_story"),
from_stage: "2_current".to_string(),
to_stage: "3_qa".to_string(),
timestamp_ms: n,
},
};
// Send 3 events to overflow the capacity-1 channel before the task runs.
let _ = tx.send(make_event(1));
let _ = tx.send(make_event(2));
let _ = tx.send(make_event(3));
gateway::spawn_gateway_broadcaster_forwarder(
transport as Arc<dyn crate::chat::ChatTransport>,
vec!["!r:x.org".to_string()],
rx,
);
// Send one more event after the forwarder subscribes; it should arrive.
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
tx.send(make_event(4)).unwrap();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
// After Lagged + resubscribe, the forwarder must still process event 4.
let received = *count.lock().unwrap();
assert!(
received >= 1,
"Expected at least one event after Lagged resubscribe; got {received}"
);
}
// ── BotConfig tests ─────────────────────────────────────────────────
#[test]