1use std::collections::HashMap;
17use std::sync::Arc;
18use std::time::Duration;
19
20use forge_connectors::gotify::GotifyHost;
21use forge_primitives::GotifySubscription;
22
23use crate::protocol::{Command, SessionUpdate};
24use crate::target::ProjectKey;
25use crate::workspace::Workspace;
26
27impl Workspace {
28 pub fn gotify_config(&self) -> Option<forge_primitives::GotifyConfig> {
32 self.config_arc().gotify.clone()
33 }
34
35 pub(crate) fn add_gotify_subscription(
39 &self,
40 sub: forge_primitives::GotifySubscription,
41 durable: bool,
42 ) {
43 let project = sub.project.clone();
44 if durable
45 && let Some(db) = self.db.lock().as_ref()
46 && let Err(error) = crate::store::gotify::insert(db, &sub)
47 {
48 tracing::warn!(
49 target: "forge_workspace::gotify",
50 %error,
51 "persisting a Gotify subscription failed",
52 );
53 }
54 self.gotify_subs.lock().push(sub);
55 self.announce_connector_subscriptions_changed(&project);
56 }
57
58 pub(crate) fn remove_gotify_subscription_owned_by(
68 &self,
69 project: &str,
70 id: uuid::Uuid,
71 owner: Option<&str>,
72 ) -> Option<GotifySubscription> {
73 let removed = {
74 let mut subs = self.gotify_subs.lock();
75 let pos = subs.iter().position(|s| {
76 s.id == id && s.project == project && s.team_role.as_deref() == owner
77 });
78 pos.map(|pos| subs.remove(pos))
79 };
80 if removed.is_some()
81 && let Some(db) = self.db.lock().as_ref()
82 && let Err(error) = crate::store::gotify::remove(db, id)
83 {
84 tracing::warn!(
85 target: "forge_workspace::gotify",
86 %error,
87 "removing a persisted Gotify subscription failed",
88 );
89 }
90 if removed.is_some() {
91 self.announce_connector_subscriptions_changed(project);
92 }
93 removed
94 }
95
96 pub(crate) fn remove_gotify_subscriptions_for_worker(
104 &self,
105 project_key: &ProjectKey,
106 label: &str,
107 ) {
108 let Some(project_name) =
109 self.list_projects().into_iter().find(|v| v.key == *project_key).map(|v| v.name)
110 else {
111 tracing::warn!(
112 target: "forge_workspace::gotify",
113 project = %project_key.as_str(),
114 label = %label,
115 "could not resolve a project name at worker teardown; its Gotify subs may be stranded",
116 );
117 return;
118 };
119 let removed_ids: Vec<uuid::Uuid> = {
120 let mut subs = self.gotify_subs.lock();
121 let mut removed = Vec::new();
122 subs.retain(|s| {
123 let owned = s.project == project_name && s.team_role.as_deref() == Some(label);
124 if owned {
125 removed.push(s.id);
126 }
127 !owned
128 });
129 removed
130 };
131 if removed_ids.is_empty() {
132 return;
133 }
134 if let Some(db) = self.db.lock().as_ref() {
135 for id in removed_ids {
136 if let Err(error) = crate::store::gotify::remove(db, id) {
137 tracing::error!(
140 target: "forge_workspace::gotify",
141 %error,
142 id = %id,
143 project = %project_name,
144 label = %label,
145 "deleting a persisted Gotify subscription failed; it reloads into the active set on restart and a future same-label worker inherits it",
146 );
147 }
148 }
149 }
150 self.announce_connector_subscriptions_changed(&project_name);
151 }
152
153 pub fn gotify_subscriptions_for_project(
160 &self,
161 project: &str,
162 ) -> Vec<forge_primitives::GotifySubscription> {
163 self.gotify_subs.lock().iter().filter(|s| s.project == project).cloned().collect()
164 }
165
166 pub(crate) fn announce_connector_subscriptions_changed(&self, project_name: &str) {
175 let Some(key) = self.lead_slot_for_project(project_name) else {
176 tracing::debug!(
177 target: "forge_workspace::gotify",
178 event_name = "connector_subscriptions_changed_unroutable",
179 project = %project_name,
180 "a subscription write named a project no project carries; nothing was announced",
181 );
182 return;
183 };
184 let gotify = self.gotify_subscriptions_for_project(project_name);
185 let slack = self.slack_subscriptions_for_project(project_name);
186 let _ = self.update_tx.send(SessionUpdate::ConnectorSubscriptionsChanged {
187 key,
188 gotify,
189 slack,
190 });
191 }
192
193 pub fn gotify_connected(&self) -> bool {
196 *self.gotify_connected.lock()
197 }
198
199 pub fn start_gotify_subsystem(self: &Arc<Self>) {
210 let Some(cfg) = self.gotify_config() else {
211 tracing::debug!(
212 target: "forge_workspace::gotify",
213 event_name = "gotify_subsystem_declined",
214 reason = "no_config",
215 "the Gotify subsystem did not start",
216 );
217 return;
218 };
219 let subscriptions = self.gotify_subs.lock().len();
220 if subscriptions == 0 {
221 tracing::debug!(
222 target: "forge_workspace::gotify",
223 event_name = "gotify_subsystem_declined",
224 reason = "no_subscriptions",
225 "the Gotify subsystem did not start",
226 );
227 return;
228 }
229 let mut guard = self.gotify_subsystem.lock();
230 if guard.is_some() {
231 drop(guard);
232 tracing::debug!(
233 target: "forge_workspace::gotify",
234 event_name = "gotify_subsystem_declined",
235 reason = "already_running",
236 "the Gotify subsystem did not start",
237 );
238 return;
239 }
240 let token = Arc::new(());
241 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
242 *guard = Some(GotifySubsystem { token: Arc::clone(&token), shutdown: shutdown_tx });
243 drop(guard);
244 tracing::info!(
245 target: "forge_workspace::gotify",
246 event_name = "gotify_subsystem_started",
247 host = url_host(&cfg.url),
248 subscriptions,
249 "the Gotify subsystem started",
250 );
251 let host: Arc<dyn GotifyHost> = Arc::new(SubsystemHost::new(self));
252 let _pump = self.spawn_gotify_subsystem_task(
255 token,
256 forge_connectors::gotify::run_subsystem(host, cfg, shutdown_rx),
257 );
258 }
259
260 fn spawn_gotify_subsystem_task<F>(
264 self: &Arc<Self>,
265 token: Arc<()>,
266 pump: F,
267 ) -> tokio::task::JoinHandle<()>
268 where
269 F: std::future::Future<Output = ()> + Send + 'static,
270 {
271 let workspace = Arc::downgrade(self);
272 tokio::spawn(async move {
273 let _exit = GotifyExitGuard { workspace, token };
274 pump.await;
275 })
276 }
277
278 pub fn stop_gotify_subsystem_if_idle(&self) {
282 if !self.gotify_subs.lock().is_empty() {
283 return;
284 }
285 self.stop_gotify_subsystem();
286 }
287
288 pub(crate) fn stop_gotify_subsystem(&self) {
293 let subsystem = {
297 let mut slot = self.gotify_subsystem.lock();
298 let subsystem = slot.take();
299 if subsystem.is_some() {
300 *self.gotify_connected.lock() = false;
301 }
302 subsystem
303 };
304 if let Some(subsystem) = subsystem {
305 let _ = subsystem.shutdown.send(());
306 }
307 }
308}
309
310pub(crate) struct GotifySubsystem {
313 token: Arc<()>,
314 shutdown: tokio::sync::oneshot::Sender<()>,
315}
316
317struct GotifyExitGuard {
323 workspace: std::sync::Weak<Workspace>,
324 token: Arc<()>,
325}
326
327impl Drop for GotifyExitGuard {
328 fn drop(&mut self) {
329 let Some(workspace) = self.workspace.upgrade() else { return };
330 let cleared = {
331 let mut slot = workspace.gotify_subsystem.lock();
335 let ours = slot.as_ref().is_some_and(|run| Arc::ptr_eq(&run.token, &self.token));
336 if ours {
337 slot.take();
338 *workspace.gotify_connected.lock() = false;
339 }
340 ours
341 };
342 if cleared {
343 let reason = if std::thread::panicking() { "panic" } else { "stream_ended" };
346 tracing::warn!(
347 target: "forge_workspace::gotify",
348 event_name = "gotify_subsystem_exited",
349 reason,
350 "the Gotify subsystem exited without a shutdown",
351 );
352 } else {
353 tracing::info!(
354 target: "forge_workspace::gotify",
355 event_name = "gotify_subsystem_exited",
356 reason = "shutdown",
357 "the Gotify subsystem stopped",
358 );
359 }
360 }
361}
362
363fn url_host(url: &str) -> &str {
366 let authority = url.split_once("://").map_or(url, |(_, rest)| rest);
367 authority.split('/').next().unwrap_or_default()
368}
369
370pub(crate) struct SubsystemHost(std::sync::Weak<Workspace>);
376
377impl SubsystemHost {
378 pub(crate) fn new(workspace: &Arc<Workspace>) -> Self {
379 Self(Arc::downgrade(workspace))
380 }
381}
382
383impl GotifyHost for SubsystemHost {
384 fn http_client(&self, timeout: Duration) -> Result<reqwest::Client, String> {
385 forge_agent::http_trust::with_extra_roots(reqwest::Client::builder().timeout(timeout))
386 .build()
387 .map_err(|error| error.to_string())
388 }
389
390 fn subscriptions(&self) -> Vec<forge_primitives::GotifySubscription> {
391 self.0.upgrade().map(|ws| ws.gotify_subs.lock().clone()).unwrap_or_default()
392 }
393
394 fn app_name(&self, appid: u64) -> Option<String> {
398 let ws = self.0.upgrade()?;
399 ws.gotify_app_index
400 .lock()
401 .iter()
402 .find(|&(_, &id)| id == appid)
403 .map(|(name, _)| name.clone())
404 }
405
406 fn store_app_index(&self, index: HashMap<String, u64>) {
407 if let Some(ws) = self.0.upgrade() {
408 *ws.gotify_app_index.lock() = index;
409 }
410 }
411
412 fn set_connected(&self, connected: bool) {
413 if let Some(ws) = self.0.upgrade() {
414 *ws.gotify_connected.lock() = connected;
415 }
416 }
417
418 fn deliver(
424 &self,
425 subscription: &forge_primitives::GotifySubscription,
426 app: &str,
427 message: &forge_primitives::GotifyMessage,
428 ) {
429 let Some(ws) = self.0.upgrade() else { return };
430 let notification = crate::mcp::gotify::types::GotifyNotification {
431 app: app.to_owned(),
432 title: message.title.clone(),
433 message: message.message.clone(),
434 priority: message.priority,
435 };
436 if let Err(err) = ws.dispatch(Command::DeliverGotifyMessage {
437 project: subscription.project.clone(),
438 team_role: subscription.team_role.clone(),
439 notification,
440 }) {
441 tracing::warn!(
442 target: "forge_workspace::gotify",
443 project = %subscription.project,
444 error = ?err,
445 "gotify DeliverGotifyMessage dispatch failed",
446 );
447 }
448 }
449}
450
451#[cfg(any(test, feature = "testing"))]
452impl Workspace {
453 pub fn seed_test_gotify_subscription(&self, sub: forge_primitives::GotifySubscription) {
461 self.gotify_subs.lock().push(sub);
462 }
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468
469 use std::collections::HashMap;
470
471 use tempfile::tempdir;
472
473 use crate::target::SessionSlot;
474
475 fn parked_gotify(
478 ws: &crate::Workspace,
479 project: &str,
480 label: Option<&str>,
481 ) -> Vec<crate::mcp::gotify::types::GotifyNotification> {
482 let org = ws.list_projects().into_iter().find(|v| v.name == project).expect("project").org;
483 ws.parked_by_slot
484 .lock()
485 .get(&crate::SessionSlot::for_label(&org, project, label))
486 .map(|parked| parked.gotify.clone())
487 .unwrap_or_default()
488 }
489
490 fn seed_worker_row(ws: &crate::Workspace, project_key: &ProjectKey, label: &str) {
495 ws.seed_test_worker_row(project_key, label);
496 assert!(
497 ws.stored_worker_row(project_key, label).expect("read the seeded row").is_some(),
498 "the worker row for {label} did not land; does {project_key:?} resolve to a project?",
499 );
500 }
501
502 fn live_worker_entry(project: &str, label: &str) -> crate::mcp::workers::types::WorkerEntry {
503 crate::mcp::workers::types::WorkerEntry {
504 label: label.to_owned(),
505 charter: "c".to_owned(),
506 slot: SessionSlot::worker("TestOrg", project, label),
507 session_id: None,
508 status: forge_primitives::WorkerLiveness::Running,
509 spawned_at: std::time::SystemTime::UNIX_EPOCH,
510 spawned_by: SessionSlot::lead("TestOrg", project),
511 needs_tag: false,
512 is_git_repo_at_spawn: false,
513 cwd: None,
514 diagnostic: None,
515 kick: None,
516 }
517 }
518
519 fn gotify_sub(
520 project: &str,
521 applications: &[&str],
522 min_priority: Option<u8>,
523 ) -> forge_primitives::GotifySubscription {
524 forge_primitives::GotifySubscription {
525 id: uuid::Uuid::new_v4(),
526 project: project.to_owned(),
527 team_role: None,
528 applications: applications.iter().map(|s| (*s).to_owned()).collect(),
529 min_priority,
530 created_at: std::time::SystemTime::UNIX_EPOCH,
531 }
532 }
533
534 fn gotify_msg(appid: u64, priority: u8) -> forge_primitives::GotifyMessage {
535 forge_primitives::GotifyMessage {
536 id: 1,
537 appid,
538 title: "Alert".to_owned(),
539 message: "body".to_owned(),
540 priority,
541 date: "2026-07-03T00:00:00Z".to_owned(),
542 }
543 }
544
545 fn gotify_notif(
546 app: &str,
547 title: &str,
548 message: &str,
549 priority: u8,
550 ) -> crate::GotifyNotification {
551 crate::GotifyNotification {
552 app: app.to_owned(),
553 title: title.to_owned(),
554 message: message.to_owned(),
555 priority,
556 }
557 }
558
559 fn drain_updates(
561 rx: &mut tokio::sync::mpsc::UnboundedReceiver<crate::SeatUpdate>,
562 ) -> Vec<crate::protocol::SessionUpdate> {
563 let mut out = Vec::new();
564 while let Ok(frame) = rx.try_recv() {
565 out.push(std::sync::Arc::unwrap_or_clone(frame.update));
566 }
567 out
568 }
569
570 #[test]
571 fn durable_gotify_subscription_persists_ephemeral_stays_in_memory() {
572 use forge_primitives::GotifySubscription;
573
574 let dir = tempdir().expect("tempdir");
575 let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
576 let db = crate::store::Db::open(&dir.path().join("db.redb")).expect("open db");
577 ws.install_db_for_test(db);
578
579 let sub = |team_role: Option<&str>| GotifySubscription {
580 id: uuid::Uuid::new_v4(),
581 project: "p".to_owned(),
582 team_role: team_role.map(str::to_owned),
583 applications: vec![],
584 min_priority: None,
585 created_at: std::time::SystemTime::UNIX_EPOCH,
586 };
587 let durable = sub(None);
588 let ephemeral = sub(Some("scratch"));
589 ws.add_gotify_subscription(durable.clone(), true);
590 ws.add_gotify_subscription(ephemeral, false);
591
592 assert_eq!(
593 ws.gotify_subscriptions_for_project("p").len(),
594 2,
595 "both live in the active in-memory set",
596 );
597 let persisted = || {
598 crate::store::gotify::list(ws.db.lock().as_ref().expect("db installed")).expect("list")
599 };
600 assert_eq!(persisted().len(), 1, "only the durable subscription hit redb");
601 assert_eq!(persisted()[0].id, durable.id);
602
603 assert_eq!(
604 ws.remove_gotify_subscription_owned_by("p", durable.id, None).map(|s| s.id),
605 Some(durable.id),
606 "the lead's own durable id removes, and the row comes back",
607 );
608 assert!(persisted().is_empty(), "removal cleared the persisted record");
609 }
610
611 #[test]
616 fn remove_gotify_subscriptions_for_worker_is_scoped_to_project_and_label() {
617 let (ws, _rx) = Workspace::testing_stub();
618 let dir = tempdir().expect("tempdir");
619 ws.install_db_for_test(
620 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
621 );
622 ws.seed_test_project("forge", "/tmp/gotify-durability");
623 let view_key = ws
624 .list_projects()
625 .into_iter()
626 .find(|v| v.name == "forge")
627 .map(|v| v.key)
628 .expect("seeded project view");
629
630 let mut scratch_sub = gotify_sub("forge", &[], None);
631 scratch_sub.team_role = Some("scratch".to_owned());
632 let lead_sub = gotify_sub("forge", &[], None);
633 let mut sibling_sub = gotify_sub("forge", &[], None);
634 sibling_sub.team_role = Some("other".to_owned());
635 ws.add_gotify_subscription(scratch_sub.clone(), true);
636 ws.add_gotify_subscription(lead_sub.clone(), true);
637 ws.add_gotify_subscription(sibling_sub.clone(), true);
638
639 ws.remove_gotify_subscriptions_for_worker(&view_key, "scratch");
640
641 let in_mem = ws.gotify_subscriptions_for_project("forge");
642 assert!(
643 in_mem.iter().all(|s| s.id != scratch_sub.id),
644 "the scratch worker's sub is gone from memory",
645 );
646 assert!(
647 in_mem.iter().any(|s| s.id == lead_sub.id)
648 && in_mem.iter().any(|s| s.id == sibling_sub.id),
649 "the lead sub and the sibling worker's sub survive in memory",
650 );
651
652 let persisted = {
653 let guard = ws.db.lock();
654 crate::store::gotify::list(guard.as_ref().expect("db installed")).expect("list")
655 };
656 assert!(
657 persisted.iter().all(|s| s.id != scratch_sub.id),
658 "the scratch worker's sub is gone from redb",
659 );
660 assert!(
661 persisted.iter().any(|s| s.id == lead_sub.id)
662 && persisted.iter().any(|s| s.id == sibling_sub.id),
663 "the survivors are still persisted in redb",
664 );
665 }
666
667 #[tokio::test]
670 async fn closing_a_worker_drops_the_workers_durable_gotify_subs() {
671 let (ws, _rx) = Workspace::testing_stub();
672 let dir = tempdir().expect("tempdir");
673 ws.install_db_for_test(
674 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
675 );
676 ws.seed_test_project("forge", "/tmp/gotify-durability");
677 let view_key = ws
678 .list_projects()
679 .into_iter()
680 .find(|v| v.name == "forge")
681 .map(|v| v.key)
682 .expect("seeded project view");
683 ws.insert_live_worker(&view_key, live_worker_entry("forge", "scratch"));
684
685 let mut scratch_sub = gotify_sub("forge", &[], None);
686 scratch_sub.team_role = Some("scratch".to_owned());
687 let lead_sub = gotify_sub("forge", &[], None);
688 ws.add_gotify_subscription(scratch_sub.clone(), true);
689 ws.add_gotify_subscription(lead_sub.clone(), true);
690
691 crate::spawn::handle_close_worker(&ws, &view_key, "scratch");
692
693 let in_mem = ws.gotify_subscriptions_for_project("forge");
694 assert!(
695 in_mem.iter().all(|s| s.id != scratch_sub.id),
696 "teardown removed the worker's sub from memory",
697 );
698 assert!(in_mem.iter().any(|s| s.id == lead_sub.id), "the lead sub survives teardown");
699
700 let persisted = {
701 let guard = ws.db.lock();
702 crate::store::gotify::list(guard.as_ref().expect("db installed")).expect("list")
703 };
704 assert!(
705 persisted.iter().all(|s| s.id != scratch_sub.id),
706 "teardown removed the worker's sub from redb",
707 );
708 assert!(persisted.iter().any(|s| s.id == lead_sub.id), "the lead sub is still persisted");
709 }
710
711 #[test]
716 fn resolve_identity_persists_a_table_backed_dynamic_workers_sub() {
717 let (ws, _rx) = Workspace::testing_stub();
718 let dir = tempdir().expect("tempdir");
719 ws.install_db_for_test(
720 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
721 );
722 ws.seed_test_project("forge", "/tmp/gotify-durability-seam");
723 let view_key = ws
724 .list_projects()
725 .into_iter()
726 .find(|v| v.name == "forge")
727 .map(|v| v.key)
728 .expect("seeded project view");
729
730 seed_worker_row(&ws, &view_key, "scratch");
733 let caller = SessionSlot::worker("TestOrg", "forge", "scratch");
734 ws.insert_live_worker(&view_key, live_worker_entry("forge", "scratch"));
735
736 let (name, team_role, durable) = crate::mcp::gotify::facade::resolve_identity(&ws, &caller)
737 .expect("the worker caller resolves to its project");
738 assert_eq!(
739 (name.as_str(), team_role.as_deref(), durable),
740 ("forge", Some("scratch"), true),
741 "a table-backed dynamic worker resolves as a durable subscriber",
742 );
743
744 let sub = forge_primitives::GotifySubscription {
746 id: uuid::Uuid::new_v4(),
747 project: name,
748 team_role,
749 applications: vec![],
750 min_priority: None,
751 created_at: std::time::SystemTime::UNIX_EPOCH,
752 };
753 let sub_id = sub.id;
754 ws.add_gotify_subscription(sub, durable);
755 let persisted = {
756 let guard = ws.db.lock();
757 crate::store::gotify::list(guard.as_ref().expect("db installed")).expect("list")
758 };
759 assert!(
760 persisted.iter().any(|s| s.id == sub_id),
761 "the table-backed worker's sub is persisted to redb",
762 );
763 }
764
765 #[test]
768 fn remove_gotify_subscriptions_for_worker_unknown_project_is_a_no_op() {
769 let (ws, _rx) = Workspace::testing_stub();
770 let dir = tempdir().expect("tempdir");
771 ws.install_db_for_test(
772 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
773 );
774 ws.seed_test_project("forge", "/tmp/gotify-durability-noop");
775
776 let mut scratch_sub = gotify_sub("forge", &[], None);
777 scratch_sub.team_role = Some("scratch".to_owned());
778 ws.add_gotify_subscription(scratch_sub.clone(), true);
779
780 ws.remove_gotify_subscriptions_for_worker(&ProjectKey::new("ghost"), "scratch");
781
782 assert!(
783 ws.gotify_subscriptions_for_project("forge").iter().any(|s| s.id == scratch_sub.id),
784 "an unknown project key leaves the in-memory subs intact",
785 );
786 let persisted = {
787 let guard = ws.db.lock();
788 crate::store::gotify::list(guard.as_ref().expect("db installed")).expect("list")
789 };
790 assert!(
791 persisted.iter().any(|s| s.id == scratch_sub.id),
792 "and leaves the persisted subs intact",
793 );
794 }
795
796 #[test]
800 fn remove_gotify_subscriptions_for_worker_scrubs_memory_without_a_db() {
801 let (ws, _rx) = Workspace::testing_stub();
802 ws.seed_test_project("forge", "/tmp/gotify-durability-nodb");
804 let view_key = ws
805 .list_projects()
806 .into_iter()
807 .find(|v| v.name == "forge")
808 .map(|v| v.key)
809 .expect("seeded project view");
810
811 let mut scratch_sub = gotify_sub("forge", &[], None);
812 scratch_sub.team_role = Some("scratch".to_owned());
813 ws.add_gotify_subscription(scratch_sub.clone(), true);
814
815 ws.remove_gotify_subscriptions_for_worker(&view_key, "scratch");
816
817 assert!(
818 ws.gotify_subscriptions_for_project("forge").iter().all(|s| s.id != scratch_sub.id),
819 "the in-memory sub is scrubbed even with no store installed",
820 );
821 }
822
823 #[test]
828 fn gotify_host_impl_reads_state_and_delivers_to_the_subscriber() {
829 use forge_connectors::gotify::GotifyHost as _;
830
831 let dir = tempdir().expect("tempdir");
832 let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
833 let mut worker_sub = gotify_sub("p1", &[], None);
834 worker_sub.team_role = Some("reviewer".to_owned());
835 ws.add_gotify_subscription(worker_sub.clone(), false);
836 ws.add_gotify_subscription(gotify_sub("p2", &[], None), false);
837
838 let host = SubsystemHost::new(&ws);
839 host.store_app_index(HashMap::from([("alerts".to_owned(), 3u64)]));
840 assert_eq!(ws.gotify_app_index.lock().len(), 1, "the index lands on the workspace");
841 assert_eq!(host.app_name(3).as_deref(), Some("alerts"), "appid resolves via the index");
842 assert_eq!(host.app_name(9), None, "an unknown appid resolves to nothing");
843
844 host.set_connected(true);
845 assert!(ws.gotify_connected(), "liveness reaches the Inspector's flag");
846
847 assert_eq!(host.subscriptions().len(), 2, "the pump matches against the active set");
848
849 ws.enable_test_dispatch_intercept();
850 host.deliver(&worker_sub, "alerts", &gotify_msg(3, 5));
851 let dispatched = ws.drain_test_dispatch_buffer();
852 let Some(crate::protocol::Command::DeliverGotifyMessage {
853 project,
854 team_role,
855 notification,
856 }) = dispatched.first()
857 else {
858 panic!("deliver dispatches exactly one DeliverGotifyMessage: {dispatched:?}");
859 };
860 assert_eq!(project, "p1");
861 assert_eq!(team_role.as_deref(), Some("reviewer"), "the team role rides along");
862 assert_eq!(
863 notification.app, "alerts",
864 "the connector-resolved display name becomes the notification's app",
865 );
866 assert_eq!(notification.title, "Alert", "the message title rides into the wrap");
867 assert_eq!(notification.message, "body", "the message body rides into the wrap");
868 assert_eq!(notification.priority, 5, "the priority rides into the wrap");
869 }
870
871 #[test]
872 fn deliver_gotify_message_spawns_asleep_project_and_buffers() {
873 let dir = tempdir().expect("tempdir");
874 let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
875 ws.seed_test_project("forge", "/tmp/gotify-forge");
876
877 let notif = gotify_notif("forge", "Heads up", "hello envelope", 5);
878 ws.enable_test_dispatch_intercept();
879 crate::spawn::deliver_gotify_message(&ws, "forge", None, notif.clone());
880 let dispatched = ws.drain_test_dispatch_buffer();
881
882 let spawns = dispatched
883 .iter()
884 .filter(|c| {
885 matches!(c, crate::protocol::Command::SpawnProject { project_name, .. }
886 if project_name == "forge")
887 })
888 .count();
889 assert_eq!(spawns, 1, "the asleep project got a spawn");
890
891 assert_eq!(
892 parked_gotify(&ws, "forge", None),
893 vec![notif],
894 "the notification is parked for the project's lead",
895 );
896 }
897
898 #[test]
899 fn deliver_gotify_message_skips_gone_project() {
900 let dir = tempdir().expect("tempdir");
901 let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
902 ws.enable_test_dispatch_intercept();
904 crate::spawn::deliver_gotify_message(
905 &ws,
906 "ghost",
907 None,
908 gotify_notif("ghost", "t", "x", 1),
909 );
910 let dispatched = ws.drain_test_dispatch_buffer();
911 assert!(
912 dispatched.iter().all(|c| !matches!(c, crate::protocol::Command::SpawnProject { .. })),
913 "a gone target is skipped without a spawn or panic",
914 );
915 }
916
917 #[test]
918 fn deliver_gotify_message_delivers_to_running_team_worker() {
919 let dir = tempdir().expect("tempdir");
920 let (ws, mut rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
921 ws.seed_test_project("forge", "/tmp/gotify-team");
922 let view_key = ws
923 .list_projects()
924 .into_iter()
925 .find(|v| v.name == "forge")
926 .map(|v| v.key)
927 .expect("seeded project view");
928 let worker_key = SessionSlot::worker("TestOrg", "forge", "reviewer");
929 ws.insert_live_worker(
930 &view_key,
931 crate::mcp::workers::types::WorkerEntry {
932 label: "reviewer".to_owned(),
933 charter: "review".to_owned(),
934 slot: worker_key.clone(),
935 session_id: None,
936 status: forge_primitives::WorkerLiveness::Running,
937 spawned_at: std::time::SystemTime::UNIX_EPOCH,
938 spawned_by: SessionSlot::lead("TestOrg", "forge"),
939 needs_tag: false,
940 is_git_repo_at_spawn: false,
941 cwd: None,
942 diagnostic: None,
943 kick: None,
944 },
945 );
946
947 ws.mark_session_connected_for_test(&worker_key, "worker-reviewer");
948 let notif = gotify_notif("Backups", "Nightly backup", "done", 5);
949 ws.enable_test_dispatch_intercept();
950 crate::spawn::deliver_gotify_message(&ws, "forge", Some("reviewer"), notif.clone());
951 let dispatched = ws.drain_test_dispatch_buffer();
952
953 assert!(
954 dispatched.iter().any(|c| matches!(
955 c,
956 crate::protocol::Command::Prompt { key, .. }
957 | crate::protocol::Command::PromptUnder { key, .. }
958 if key == &worker_key
959 )),
960 "a running team worker receives the notification directly",
961 );
962 assert!(
963 dispatched.iter().all(|c| !matches!(c, crate::protocol::Command::SpawnProject { .. })),
964 "no project spawn when the target worker is already running",
965 );
966
967 let echoed = drain_updates(&mut rx).into_iter().any(|update| {
970 matches!(
971 update,
972 crate::protocol::SessionUpdate::GotifyNotificationAppended {
973 key,
974 notification,
975 ..
976 } if key == worker_key && notification == notif
977 )
978 });
979 assert!(echoed, "a running-target delivery emits a GotifyNotificationAppended echo");
980 }
981
982 #[test]
983 fn deliver_gotify_message_team_worker_asleep_falls_through_to_lead() {
984 let dir = tempdir().expect("tempdir");
985 let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
986 ws.seed_test_project("forge", "/tmp/gotify-team");
987
988 let notif = gotify_notif("Backups", "backup", "env", 5);
992 ws.enable_test_dispatch_intercept();
993 crate::spawn::deliver_gotify_message(&ws, "forge", Some("reviewer"), notif.clone());
994 let dispatched = ws.drain_test_dispatch_buffer();
995
996 let spawns = dispatched
997 .iter()
998 .filter(|c| {
999 matches!(c, crate::protocol::Command::SpawnProject { project_name, .. }
1000 if project_name == "forge")
1001 })
1002 .count();
1003 assert_eq!(spawns, 1, "an asleep team-worker subscription spawns the project lead");
1004
1005 assert_eq!(
1006 parked_gotify(&ws, "forge", None),
1007 vec![notif],
1008 "the notification is parked for the project's lead",
1009 );
1010 }
1011
1012 #[test]
1017 fn deliver_gotify_message_failed_team_worker_falls_through_to_lead() {
1018 let dir = tempdir().expect("tempdir");
1019 let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1020 ws.seed_test_project("forge", "/tmp/gotify-team");
1021 let view_key = ws.project_key_for_name("forge").expect("seeded project");
1022 let mut failed = live_worker_entry("forge", "reviewer");
1023 failed.status = forge_primitives::WorkerLiveness::Failed;
1024 failed.diagnostic = Some("connection closed".to_owned());
1025 ws.insert_live_worker(&view_key, failed);
1026
1027 let notif = gotify_notif("Backups", "backup", "env", 5);
1028 ws.enable_test_dispatch_intercept();
1029 crate::spawn::deliver_gotify_message(&ws, "forge", Some("reviewer"), notif.clone());
1030 let dispatched = ws.drain_test_dispatch_buffer();
1031
1032 assert_eq!(
1033 parked_gotify(&ws, "forge", Some("reviewer")),
1034 Vec::new(),
1035 "nothing parks for a failed worker's slot",
1036 );
1037 let spawns = dispatched
1038 .iter()
1039 .filter(|c| {
1040 matches!(c, crate::protocol::Command::SpawnProject { project_name, .. }
1041 if project_name == "forge")
1042 })
1043 .count();
1044 assert_eq!(spawns, 1, "the fall-through spawns the asleep project");
1045 assert_eq!(
1046 parked_gotify(&ws, "forge", None),
1047 vec![notif],
1048 "and the notification waits for the project's lead",
1049 );
1050 }
1051
1052 #[test]
1057 fn deliver_gotify_message_to_spawning_team_worker_buffers_on_its_domain() {
1058 let dir = tempdir().expect("tempdir");
1059 let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1060 ws.seed_test_project("forge", "/tmp/gotify-spawning");
1061 let view_key = ws
1062 .list_projects()
1063 .into_iter()
1064 .find(|v| v.name == "forge")
1065 .map(|v| v.key)
1066 .expect("seeded project view");
1067 let worker_key = SessionSlot::worker("TestOrg", "forge", "reviewer");
1068 ws.insert_live_worker(&view_key, live_worker_entry("forge", "reviewer"));
1069 ws.register_domain_session(worker_key.clone(), None);
1071
1072 let notif = gotify_notif("Backups", "backup", "env", 5);
1073 ws.enable_test_dispatch_intercept();
1074 crate::spawn::deliver_gotify_message(&ws, "forge", Some("reviewer"), notif.clone());
1075 let dispatched = ws.drain_test_dispatch_buffer();
1076
1077 assert!(
1078 dispatched.is_empty(),
1079 "no bare Prompt (dropped) and no lead fallback for a spawning worker",
1080 );
1081 assert_eq!(
1082 parked_gotify(&ws, "forge", Some("reviewer")),
1083 vec![notif],
1084 "parked for the worker's own label, for its Connected drain",
1085 );
1086 }
1087
1088 fn next_connectors_changed(
1091 rx: &mut tokio::sync::mpsc::UnboundedReceiver<crate::SeatUpdate>,
1092 ) -> (
1093 SessionSlot,
1094 Vec<forge_primitives::GotifySubscription>,
1095 Vec<forge_primitives::slack::SlackSubscription>,
1096 ) {
1097 match rx.try_recv().map(|frame| std::sync::Arc::unwrap_or_clone(frame.update)) {
1098 Ok(crate::protocol::SessionUpdate::ConnectorSubscriptionsChanged {
1099 key,
1100 gotify,
1101 slack,
1102 }) => (key, gotify, slack),
1103 other => {
1104 panic!("expected a ConnectorSubscriptionsChanged on the stream, got {other:?}")
1105 }
1106 }
1107 }
1108
1109 #[test]
1118 fn every_gotify_write_announces_the_projects_subscriptions() {
1119 let dir = tempdir().expect("tempdir");
1120 let (ws, mut rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1121 ws.seed_test_project("forge", "/tmp/tp-gotify");
1122 let view_key = ws.project_key_for_name("forge").expect("seeded project");
1123 let lead = SessionSlot::lead("TestOrg", "forge");
1124
1125 ws.add_slack_subscription(
1130 forge_primitives::slack::SlackSubscription {
1131 id: uuid::Uuid::new_v4(),
1132 workspace: "acme".to_owned(),
1133 project: "forge".to_owned(),
1134 team_role: None,
1135 target: forge_primitives::slack::SlackSubscriptionTarget::DirectMessages,
1136 created_at: std::time::SystemTime::UNIX_EPOCH,
1137 },
1138 true,
1139 );
1140 let (_, _, slack) = next_connectors_changed(&mut rx);
1141 assert_eq!(slack.len(), 1, "precondition: the sibling set is seeded and announced");
1142
1143 let subscription = gotify_sub("forge", &["alerts"], Some(5));
1144 ws.add_gotify_subscription(subscription.clone(), true);
1145 let (key, gotify, slack) = next_connectors_changed(&mut rx);
1146 assert_eq!(key, lead, "a subscribe routes on the project's lead seat");
1147 assert_eq!(
1148 gotify.iter().map(|sub| sub.id).collect::<Vec<_>>(),
1149 [subscription.id],
1150 "and carries the set the core now holds",
1151 );
1152 assert_eq!(slack.len(), 1, "beside the sibling connector's own set: {slack:?}");
1153
1154 assert!(
1155 ws.remove_gotify_subscription_owned_by("forge", subscription.id, None).is_some(),
1156 "precondition: the lead removes its own subscription",
1157 );
1158 let (_, gotify, slack) = next_connectors_changed(&mut rx);
1159 assert!(gotify.is_empty(), "an unsubscribe announces the set it left behind");
1160 assert_eq!(slack.len(), 1, "and still carries the sibling's set: {slack:?}");
1161
1162 let mut worker_subscription = gotify_sub("forge", &[], None);
1164 worker_subscription.team_role = Some("reviewer".to_owned());
1165 ws.add_gotify_subscription(worker_subscription, true);
1166 let (_, gotify, _) = next_connectors_changed(&mut rx);
1167 assert_eq!(gotify.len(), 1, "precondition: the worker's subscribe announced it");
1168 ws.remove_gotify_subscriptions_for_worker(&view_key, "reviewer");
1169 let (announced_key, gotify, _) = next_connectors_changed(&mut rx);
1170 assert_eq!(announced_key, lead, "a teardown routes the same way");
1171 assert!(gotify.is_empty(), "and announces what the teardown left");
1172 }
1173
1174 fn subsystem_fixture() -> (Arc<Workspace>, tempfile::TempDir) {
1179 let dir = tempfile::tempdir().expect("tempdir");
1180 let mut config = crate::config::LoadedConfig::empty_for_test();
1181 config.gotify = Some(forge_primitives::GotifyConfig {
1182 url: "http://127.0.0.1:1".to_owned(),
1183 client_token: "Ctest".to_owned(),
1184 });
1185 let (ws, _rx) =
1186 Workspace::testing_stub_with_config(dir.path().to_owned(), config).expect("stub");
1187 ws.seed_test_gotify_subscription(gotify_sub("forge", &[], None));
1188 (ws, dir)
1189 }
1190
1191 #[tokio::test]
1198 async fn a_dead_pump_clears_its_handle_and_the_next_start_runs() {
1199 let (ws, _dir) = subsystem_fixture();
1200 let token = Arc::new(());
1201 let (shutdown, _shutdown_rx) = tokio::sync::oneshot::channel();
1202 *ws.gotify_subsystem.lock() = Some(GotifySubsystem { token: Arc::clone(&token), shutdown });
1203 ws.seed_test_gotify_connected(true);
1204
1205 ws.spawn_gotify_subsystem_task(token, async {}).await.expect("the pump task returns");
1207
1208 assert!(ws.gotify_subsystem.lock().is_none(), "a dead pump's handle is cleared");
1209 assert!(!ws.gotify_connected(), "and liveness goes false with it");
1210
1211 ws.start_gotify_subsystem();
1212 assert!(ws.gotify_subsystem.lock().is_some(), "a second start runs");
1213 }
1214
1215 #[tokio::test]
1223 async fn a_panicking_pump_clears_its_handle() {
1224 let (ws, _dir) = subsystem_fixture();
1225 let token = Arc::new(());
1226 let (shutdown, _shutdown_rx) = tokio::sync::oneshot::channel();
1227 *ws.gotify_subsystem.lock() = Some(GotifySubsystem { token: Arc::clone(&token), shutdown });
1228 ws.seed_test_gotify_connected(true);
1229
1230 let task = ws.spawn_gotify_subsystem_task(token, async {
1231 panic!("the pump unwound before it could clear its handle");
1232 });
1233 let error = task.await.expect_err("the pump task unwinds");
1234 assert!(error.is_panic(), "the task ended by unwinding, not cancellation");
1235
1236 assert!(ws.gotify_subsystem.lock().is_none(), "an unwind clears the handle too");
1237 assert!(!ws.gotify_connected(), "and liveness goes false with it");
1238 }
1239
1240 #[tokio::test]
1247 async fn a_stale_guard_leaves_a_newer_runs_handle_armed() {
1248 let (ws, _dir) = subsystem_fixture();
1249 let stale = Arc::new(());
1250 let (stale_tx, _stale_rx) = tokio::sync::oneshot::channel();
1251 *ws.gotify_subsystem.lock() =
1252 Some(GotifySubsystem { token: Arc::clone(&stale), shutdown: stale_tx });
1253 let task = ws.spawn_gotify_subsystem_task(stale, async {});
1254
1255 let newer = Arc::new(());
1258 let (newer_tx, _newer_rx) = tokio::sync::oneshot::channel();
1259 *ws.gotify_subsystem.lock() =
1260 Some(GotifySubsystem { token: Arc::clone(&newer), shutdown: newer_tx });
1261 ws.seed_test_gotify_connected(true);
1262
1263 task.await.expect("the stale pump task returns");
1264
1265 let slot = ws.gotify_subsystem.lock();
1266 let held = slot.as_ref().expect("the newer run's handle stays armed");
1267 assert!(Arc::ptr_eq(&held.token, &newer), "the stale guard took nothing");
1268 drop(slot);
1269 assert!(ws.gotify_connected(), "and left the newer run's liveness alone");
1270 }
1271
1272 #[tokio::test]
1278 async fn a_workspace_shutdown_stops_the_gotify_pump() {
1279 use tracing_subscriber::layer::SubscriberExt;
1280
1281 let (ws, _dir) = subsystem_fixture();
1282 let token = Arc::new(());
1283 let (shutdown, shutdown_rx) = tokio::sync::oneshot::channel();
1284 *ws.gotify_subsystem.lock() = Some(GotifySubsystem { token: Arc::clone(&token), shutdown });
1285 ws.seed_test_gotify_connected(true);
1286 let pump = ws.spawn_gotify_subsystem_task(token, async {
1288 let _ = shutdown_rx.await;
1289 });
1290
1291 let caught = Caught::default();
1292 let subscriber = tracing_subscriber::Registry::default().with(caught.clone());
1293 {
1294 let _default = tracing::subscriber::set_default(subscriber);
1295 ws.shutdown_within(std::time::Duration::from_secs(5)).await;
1296 pump.await.expect("the pump task returns");
1297 }
1298
1299 assert!(ws.gotify_subsystem.lock().is_none(), "shutdown takes the handle");
1300 assert!(!ws.gotify_connected(), "and marks the connector disconnected");
1301 let records = caught.0.lock().expect("capture").clone();
1302 assert!(
1303 records.iter().any(|(level, _, fields)| {
1304 *level == tracing::Level::INFO
1305 && fields.contains("gotify_subsystem_exited")
1306 && fields.contains("reason=\"shutdown\"")
1307 }),
1308 "the guard takes the shutdown branch, not the stream-ended one: {records:?}",
1309 );
1310 }
1311
1312 #[derive(Clone, Default)]
1314 struct Caught(std::sync::Arc<std::sync::Mutex<Vec<(tracing::Level, String, String)>>>);
1315
1316 #[derive(Default)]
1317 struct CaughtFields(String);
1318
1319 impl tracing::field::Visit for CaughtFields {
1320 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
1321 if !self.0.is_empty() {
1322 self.0.push(' ');
1323 }
1324 let _ = std::fmt::write(&mut self.0, format_args!("{}={value:?}", field.name()));
1325 }
1326 }
1327
1328 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for Caught {
1329 fn on_event(
1330 &self,
1331 event: &tracing::Event<'_>,
1332 _ctx: tracing_subscriber::layer::Context<'_, S>,
1333 ) {
1334 let mut fields = CaughtFields::default();
1335 event.record(&mut fields);
1336 self.0.lock().expect("capture").push((
1337 *event.metadata().level(),
1338 event.metadata().target().to_owned(),
1339 fields.0,
1340 ));
1341 }
1342 }
1343
1344 #[test]
1351 fn a_declined_start_is_recorded_at_debug() {
1352 use tracing_subscriber::layer::SubscriberExt;
1353
1354 let (ws, _rx) = Workspace::testing_stub();
1355 let caught = Caught::default();
1356 let subscriber = tracing_subscriber::Registry::default().with(caught.clone());
1357 tracing::subscriber::with_default(subscriber, || ws.start_gotify_subsystem());
1358
1359 let records = caught.0.lock().expect("capture").clone();
1360 assert!(
1361 records.iter().any(|(level, target, fields)| {
1362 *level == tracing::Level::DEBUG
1363 && target == "forge_workspace::gotify"
1364 && fields.contains("gotify_subsystem_declined")
1365 && fields.contains("reason=\"no_config\"")
1366 }),
1367 "a start with no Gotify config declines at debug: {records:?}",
1368 );
1369 assert!(
1370 records.iter().all(|(level, _, _)| *level != tracing::Level::WARN),
1371 "no decline is a warning: {records:?}",
1372 );
1373 }
1374
1375 #[test]
1382 fn a_gotify_write_that_changed_nothing_announces_nothing() {
1383 let dir = tempdir().expect("tempdir");
1384 let (ws, mut rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1385 ws.seed_test_project("forge", "/tmp/tp-gotify-noop");
1386 let view_key = ws.project_key_for_name("forge").expect("seeded project");
1387
1388 assert!(
1389 ws.remove_gotify_subscription_owned_by("forge", uuid::Uuid::new_v4(), None).is_none(),
1390 "precondition: no subscription carries the id",
1391 );
1392 ws.remove_gotify_subscriptions_for_worker(&view_key, "nobody");
1393
1394 let announced = rx.try_recv();
1395 assert!(
1396 announced.is_err(),
1397 "no write here moved a subscription, so nothing is announced, and this was: \
1398 {announced:?}",
1399 );
1400 }
1401}