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();
|
notif_room_ids.iter().map(|r| r.to_string()).collect();
|
||||||
super::notifications::spawn_notification_listener(
|
super::notifications::spawn_notification_listener(
|
||||||
Arc::clone(&transport),
|
Arc::clone(&transport),
|
||||||
notif_room_id_strings,
|
move || notif_room_id_strings.clone(),
|
||||||
watcher_rx,
|
watcher_rx,
|
||||||
notif_project_root,
|
notif_project_root,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -142,9 +142,14 @@ pub fn format_rate_limit_notification(
|
|||||||
/// Spawn a background task that listens for watcher events and posts
|
/// Spawn a background task that listens for watcher events and posts
|
||||||
/// stage-transition notifications to all configured rooms via the
|
/// stage-transition notifications to all configured rooms via the
|
||||||
/// [`ChatTransport`] abstraction.
|
/// [`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(
|
pub fn spawn_notification_listener(
|
||||||
transport: Arc<dyn ChatTransport>,
|
transport: Arc<dyn ChatTransport>,
|
||||||
room_ids: Vec<String>,
|
get_room_ids: impl Fn() -> Vec<String> + Send + 'static,
|
||||||
watcher_rx: broadcast::Receiver<WatcherEvent>,
|
watcher_rx: broadcast::Receiver<WatcherEvent>,
|
||||||
project_root: PathBuf,
|
project_root: PathBuf,
|
||||||
) {
|
) {
|
||||||
@@ -175,12 +180,12 @@ pub fn spawn_notification_listener(
|
|||||||
to_display,
|
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 {
|
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
|
||||||
slog!(
|
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,
|
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 {
|
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
|
||||||
slog!(
|
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
|
&& now.duration_since(last) < RATE_LIMIT_DEBOUNCE
|
||||||
{
|
{
|
||||||
slog!(
|
slog!(
|
||||||
"[matrix-bot] Rate-limit notification debounced for \
|
"[bot] Rate-limit notification debounced for \
|
||||||
{story_id}:{agent_name}"
|
{story_id}:{agent_name}"
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
@@ -233,12 +238,12 @@ pub fn spawn_notification_listener(
|
|||||||
agent_name,
|
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 {
|
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
|
||||||
slog!(
|
slog!(
|
||||||
"[matrix-bot] Failed to send rate-limit notification \
|
"[bot] Failed to send rate-limit notification \
|
||||||
to {room_id}: {e}"
|
to {room_id}: {e}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -247,12 +252,12 @@ pub fn spawn_notification_listener(
|
|||||||
Ok(_) => {} // Ignore non-work-item events
|
Ok(_) => {} // Ignore non-work-item events
|
||||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||||
slog!(
|
slog!(
|
||||||
"[matrix-bot] Notification listener lagged, skipped {n} events"
|
"[bot] Notification listener lagged, skipped {n} events"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Err(broadcast::error::RecvError::Closed) => {
|
Err(broadcast::error::RecvError::Closed) => {
|
||||||
slog!(
|
slog!(
|
||||||
"[matrix-bot] Watcher channel closed, stopping notification listener"
|
"[bot] Watcher channel closed, stopping notification listener"
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -319,7 +324,7 @@ mod tests {
|
|||||||
|
|
||||||
spawn_notification_listener(
|
spawn_notification_listener(
|
||||||
transport,
|
transport,
|
||||||
vec!["!room123:example.org".to_string()],
|
|| vec!["!room123:example.org".to_string()],
|
||||||
watcher_rx,
|
watcher_rx,
|
||||||
tmp.path().to_path_buf(),
|
tmp.path().to_path_buf(),
|
||||||
);
|
);
|
||||||
@@ -353,7 +358,7 @@ mod tests {
|
|||||||
|
|
||||||
spawn_notification_listener(
|
spawn_notification_listener(
|
||||||
transport,
|
transport,
|
||||||
vec!["!room1:example.org".to_string()],
|
|| vec!["!room1:example.org".to_string()],
|
||||||
watcher_rx,
|
watcher_rx,
|
||||||
tmp.path().to_path_buf(),
|
tmp.path().to_path_buf(),
|
||||||
);
|
);
|
||||||
@@ -383,7 +388,7 @@ mod tests {
|
|||||||
|
|
||||||
spawn_notification_listener(
|
spawn_notification_listener(
|
||||||
transport,
|
transport,
|
||||||
vec!["!room1:example.org".to_string()],
|
|| vec!["!room1:example.org".to_string()],
|
||||||
watcher_rx,
|
watcher_rx,
|
||||||
tmp.path().to_path_buf(),
|
tmp.path().to_path_buf(),
|
||||||
);
|
);
|
||||||
@@ -403,6 +408,85 @@ mod tests {
|
|||||||
assert_eq!(calls.len(), 2, "Different agents should each trigger a notification");
|
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 ──────────────────────────────────────────────────
|
// ── stage_display_name ──────────────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -250,6 +250,10 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
|
|
||||||
// Clone watcher_tx for the Matrix bot before it is moved into AppContext.
|
// Clone watcher_tx for the Matrix bot before it is moved into AppContext.
|
||||||
let watcher_tx_for_bot = watcher_tx.clone();
|
let watcher_tx_for_bot = watcher_tx.clone();
|
||||||
|
// Subscribe to watcher events for WhatsApp/Slack notification listeners
|
||||||
|
// before watcher_tx is moved into AppContext.
|
||||||
|
let watcher_rx_for_whatsapp = watcher_tx.subscribe();
|
||||||
|
let watcher_rx_for_slack = watcher_tx.subscribe();
|
||||||
// Wrap perm_rx in Arc<Mutex> so it can be shared with both the WebSocket
|
// Wrap perm_rx in Arc<Mutex> so it can be shared with both the WebSocket
|
||||||
// handler (via AppContext) and the Matrix bot.
|
// handler (via AppContext) and the Matrix bot.
|
||||||
let perm_rx = Arc::new(tokio::sync::Mutex::new(perm_rx));
|
let perm_rx = Arc::new(tokio::sync::Mutex::new(perm_rx));
|
||||||
@@ -413,6 +417,31 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
drop(matrix_shutdown_rx);
|
drop(matrix_shutdown_rx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Spawn stage-transition notification listeners for WhatsApp and Slack.
|
||||||
|
// These mirror the listener that the Matrix bot spawns internally.
|
||||||
|
if let (Some(ctx), Some(root)) = (&whatsapp_ctx, &startup_root) {
|
||||||
|
let ambient_rooms = Arc::clone(&ctx.ambient_rooms);
|
||||||
|
chat::transport::matrix::notifications::spawn_notification_listener(
|
||||||
|
Arc::clone(&ctx.transport),
|
||||||
|
move || ambient_rooms.lock().unwrap().iter().cloned().collect(),
|
||||||
|
watcher_rx_for_whatsapp,
|
||||||
|
root.clone(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
drop(watcher_rx_for_whatsapp);
|
||||||
|
}
|
||||||
|
if let (Some(ctx), Some(root)) = (&slack_ctx, &startup_root) {
|
||||||
|
let channel_ids: Vec<String> = ctx.channel_ids.iter().cloned().collect();
|
||||||
|
chat::transport::matrix::notifications::spawn_notification_listener(
|
||||||
|
Arc::clone(&ctx.transport) as Arc<dyn crate::chat::ChatTransport>,
|
||||||
|
move || channel_ids.clone(),
|
||||||
|
watcher_rx_for_slack,
|
||||||
|
root.clone(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
drop(watcher_rx_for_slack);
|
||||||
|
}
|
||||||
|
|
||||||
// On startup:
|
// On startup:
|
||||||
// 1. Reconcile any stories whose agent work was committed while the server was
|
// 1. Reconcile any stories whose agent work was committed while the server was
|
||||||
// offline (worktree has commits ahead of master but pipeline didn't advance).
|
// offline (worktree has commits ahead of master but pipeline didn't advance).
|
||||||
|
|||||||
Reference in New Issue
Block a user