storkit: merge 393_story_pipeline_stage_notifications_for_whatsapp_and_slack_transports
This commit is contained in:
@@ -437,7 +437,7 @@ pub async fn run_bot(
|
||||
notif_room_ids.iter().map(|r| r.to_string()).collect();
|
||||
super::notifications::spawn_notification_listener(
|
||||
Arc::clone(&transport),
|
||||
notif_room_id_strings,
|
||||
move || notif_room_id_strings.clone(),
|
||||
watcher_rx,
|
||||
notif_project_root,
|
||||
);
|
||||
|
||||
@@ -142,9 +142,14 @@ pub fn format_rate_limit_notification(
|
||||
/// Spawn a background task that listens for watcher events and posts
|
||||
/// stage-transition notifications to all configured rooms via the
|
||||
/// [`ChatTransport`] abstraction.
|
||||
///
|
||||
/// `get_room_ids` is called on each notification to obtain the current list of
|
||||
/// destination room IDs. Pass a closure that returns a static list for Matrix
|
||||
/// and Slack, or one that reads from a runtime `Arc<Mutex<HashSet<String>>>`
|
||||
/// for WhatsApp ambient senders.
|
||||
pub fn spawn_notification_listener(
|
||||
transport: Arc<dyn ChatTransport>,
|
||||
room_ids: Vec<String>,
|
||||
get_room_ids: impl Fn() -> Vec<String> + Send + 'static,
|
||||
watcher_rx: broadcast::Receiver<WatcherEvent>,
|
||||
project_root: PathBuf,
|
||||
) {
|
||||
@@ -175,12 +180,12 @@ pub fn spawn_notification_listener(
|
||||
to_display,
|
||||
);
|
||||
|
||||
slog!("[matrix-bot] Sending stage notification: {plain}");
|
||||
slog!("[bot] Sending stage notification: {plain}");
|
||||
|
||||
for room_id in &room_ids {
|
||||
for room_id in &get_room_ids() {
|
||||
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
|
||||
slog!(
|
||||
"[matrix-bot] Failed to send notification to {room_id}: {e}"
|
||||
"[bot] Failed to send notification to {room_id}: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -197,12 +202,12 @@ pub fn spawn_notification_listener(
|
||||
reason,
|
||||
);
|
||||
|
||||
slog!("[matrix-bot] Sending error notification: {plain}");
|
||||
slog!("[bot] Sending error notification: {plain}");
|
||||
|
||||
for room_id in &room_ids {
|
||||
for room_id in &get_room_ids() {
|
||||
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
|
||||
slog!(
|
||||
"[matrix-bot] Failed to send error notification to {room_id}: {e}"
|
||||
"[bot] Failed to send error notification to {room_id}: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -219,7 +224,7 @@ pub fn spawn_notification_listener(
|
||||
&& now.duration_since(last) < RATE_LIMIT_DEBOUNCE
|
||||
{
|
||||
slog!(
|
||||
"[matrix-bot] Rate-limit notification debounced for \
|
||||
"[bot] Rate-limit notification debounced for \
|
||||
{story_id}:{agent_name}"
|
||||
);
|
||||
continue;
|
||||
@@ -233,12 +238,12 @@ pub fn spawn_notification_listener(
|
||||
agent_name,
|
||||
);
|
||||
|
||||
slog!("[matrix-bot] Sending rate-limit notification: {plain}");
|
||||
slog!("[bot] Sending rate-limit notification: {plain}");
|
||||
|
||||
for room_id in &room_ids {
|
||||
for room_id in &get_room_ids() {
|
||||
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
|
||||
slog!(
|
||||
"[matrix-bot] Failed to send rate-limit notification \
|
||||
"[bot] Failed to send rate-limit notification \
|
||||
to {room_id}: {e}"
|
||||
);
|
||||
}
|
||||
@@ -247,12 +252,12 @@ pub fn spawn_notification_listener(
|
||||
Ok(_) => {} // Ignore non-work-item events
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
slog!(
|
||||
"[matrix-bot] Notification listener lagged, skipped {n} events"
|
||||
"[bot] Notification listener lagged, skipped {n} events"
|
||||
);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
slog!(
|
||||
"[matrix-bot] Watcher channel closed, stopping notification listener"
|
||||
"[bot] Watcher channel closed, stopping notification listener"
|
||||
);
|
||||
break;
|
||||
}
|
||||
@@ -319,7 +324,7 @@ mod tests {
|
||||
|
||||
spawn_notification_listener(
|
||||
transport,
|
||||
vec!["!room123:example.org".to_string()],
|
||||
|| vec!["!room123:example.org".to_string()],
|
||||
watcher_rx,
|
||||
tmp.path().to_path_buf(),
|
||||
);
|
||||
@@ -353,7 +358,7 @@ mod tests {
|
||||
|
||||
spawn_notification_listener(
|
||||
transport,
|
||||
vec!["!room1:example.org".to_string()],
|
||||
|| vec!["!room1:example.org".to_string()],
|
||||
watcher_rx,
|
||||
tmp.path().to_path_buf(),
|
||||
);
|
||||
@@ -383,7 +388,7 @@ mod tests {
|
||||
|
||||
spawn_notification_listener(
|
||||
transport,
|
||||
vec!["!room1:example.org".to_string()],
|
||||
|| vec!["!room1:example.org".to_string()],
|
||||
watcher_rx,
|
||||
tmp.path().to_path_buf(),
|
||||
);
|
||||
@@ -403,6 +408,85 @@ mod tests {
|
||||
assert_eq!(calls.len(), 2, "Different agents should each trigger a notification");
|
||||
}
|
||||
|
||||
// ── dynamic room IDs (WhatsApp ambient_rooms pattern) ───────────────────
|
||||
|
||||
/// Notifications are sent to the rooms returned by the closure at
|
||||
/// notification time, not at listener-spawn time. This verifies that a
|
||||
/// closure backed by a runtime set (e.g. WhatsApp ambient_rooms) delivers
|
||||
/// messages to the rooms present when the event fires.
|
||||
#[tokio::test]
|
||||
async fn stage_notification_uses_dynamic_room_ids() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let stage_dir = tmp.path().join(".storkit").join("work").join("3_qa");
|
||||
std::fs::create_dir_all(&stage_dir).unwrap();
|
||||
std::fs::write(
|
||||
stage_dir.join("10_story_foo.md"),
|
||||
"---\nname: Foo Story\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
|
||||
let (transport, calls) = MockTransport::new();
|
||||
|
||||
let rooms: Arc<std::sync::Mutex<std::collections::HashSet<String>>> =
|
||||
Arc::new(std::sync::Mutex::new(std::collections::HashSet::new()));
|
||||
let rooms_for_closure = Arc::clone(&rooms);
|
||||
|
||||
spawn_notification_listener(
|
||||
transport,
|
||||
move || rooms_for_closure.lock().unwrap().iter().cloned().collect(),
|
||||
watcher_rx,
|
||||
tmp.path().to_path_buf(),
|
||||
);
|
||||
|
||||
// Add a room after the listener is spawned (simulates a user messaging first).
|
||||
rooms.lock().unwrap().insert("phone:+15551234567".to_string());
|
||||
|
||||
watcher_tx.send(WatcherEvent::WorkItem {
|
||||
stage: "3_qa".to_string(),
|
||||
item_id: "10_story_foo".to_string(),
|
||||
action: "qa".to_string(),
|
||||
commit_msg: "storkit: qa 10_story_foo".to_string(),
|
||||
}).unwrap();
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
let calls = calls.lock().unwrap();
|
||||
assert_eq!(calls.len(), 1, "Should deliver to the dynamically added room");
|
||||
assert_eq!(calls[0].0, "phone:+15551234567");
|
||||
assert!(calls[0].1.contains("10"), "plain should contain story number");
|
||||
assert!(calls[0].1.contains("Foo Story"), "plain should contain story name");
|
||||
}
|
||||
|
||||
/// When no rooms are registered (e.g. no WhatsApp users have messaged yet),
|
||||
/// no notifications are sent and the listener does not panic.
|
||||
#[tokio::test]
|
||||
async fn stage_notification_with_no_rooms_is_silent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
|
||||
let (transport, calls) = MockTransport::new();
|
||||
|
||||
spawn_notification_listener(
|
||||
transport,
|
||||
|| vec![],
|
||||
watcher_rx,
|
||||
tmp.path().to_path_buf(),
|
||||
);
|
||||
|
||||
watcher_tx.send(WatcherEvent::WorkItem {
|
||||
stage: "3_qa".to_string(),
|
||||
item_id: "10_story_foo".to_string(),
|
||||
action: "qa".to_string(),
|
||||
commit_msg: "storkit: qa 10_story_foo".to_string(),
|
||||
}).unwrap();
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
let calls = calls.lock().unwrap();
|
||||
assert_eq!(calls.len(), 0, "No rooms means no notifications");
|
||||
}
|
||||
|
||||
// ── stage_display_name ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user