Skip to main content

forge_workspace/
gotify.rs

1//! The Gotify seam on [`Workspace`]: the forge.toml `[gotify]` config
2//! accessor, subscription CRUD (durable rows in the redb store,
3//! ephemeral ones in memory), the subsystem start/stop lifecycle, and
4//! the [`GotifyHost`] port impl the forge-connectors pump drives.
5//!
6//! Everything here stays on `Workspace` as a second `impl` block, so
7//! every caller (the boot path in [`crate::workspace`], the
8//! `mcp::gotify` facade, `spawn::deliver_gotify_message`, the
9//! Inspector GOTIFY snapshot) keeps its path. The `gotify_*` fields
10//! these methods own are `pub(crate)` for the same reason `db` is: so
11//! this sibling module can reach them without a wrapper. Stream,
12//! matching and the pump live in `forge_connectors::gotify`; store IO
13//! in [`crate::store::gotify`]; the MCP tool surface in
14//! [`crate::mcp::gotify`]; delivery in [`crate::spawn`].
15
16use 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    /// The `[gotify]` server connection from forge.toml, or `None`
29    /// when the section is absent. `None` keeps the Gotify subsystem
30    /// dormant and makes `gotify__subscribe` error.
31    pub fn gotify_config(&self) -> Option<forge_primitives::GotifyConfig> {
32        self.config_arc().gotify.clone()
33    }
34
35    /// Register a Gotify subscription in the active set. Durable ones
36    /// (lead / team-worker) also persist to the redb store; ephemeral
37    /// ad-hoc-worker ones stay in memory only and drop on restart.
38    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    /// Remove the subscription `id` in `project` only when its owner
59    /// matches `owner` (`None` = a lead subscription, `Some(label)` =
60    /// that worker's), from both the active set and (when present) the
61    /// redb store. Returns the row as it stood (`None` when nothing
62    /// matched). Backs the owner-scoped `gotify__unsubscribe` so a caller
63    /// removes only what it subscribed, mirroring
64    /// [`Self::remove_cron_owned_by`]. Worker teardown uses
65    /// [`Self::remove_gotify_subscriptions_for_worker`] instead and is
66    /// deliberately not owner-gated.
67    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    /// Remove the worker `label`'s Gotify subscriptions in `project_key`
97    /// from both the active set and the redb store, scoped to
98    /// `(project name, team_role == Some(label))`. Backs
99    /// `spawn::teardown_worker` so a despawned durable dynamic worker can't
100    /// orphan a persisted sub. Subscriptions are project-scoped by NAME, so
101    /// the key is resolved to a name via the same view lookup
102    /// `resolve_identity` uses when subscribing.
103    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                    // Same zombie-durable-state class as a stranded
138                    // dynamic-worker row: match its error! severity.
139                    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    /// The active subscriptions for `project`, whatever their owner. The
154    /// scoping consumers narrow it - `gotify__list` to the caller's own
155    /// subscriptions, the Inspector GOTIFY snapshot to the active tab's
156    /// stamped project name - while the refused-unsubscribe determination
157    /// reads it whole, because telling "not in this project" from "not
158    /// yours" needs the other owners' rows.
159    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    /// Tell every view the project's connector subscriptions moved, as the
167    /// two sets the core holds, on the project's lead seat. One update
168    /// carries both connectors: they are one section of the board's project
169    /// row, and a reader that patches it needs the pair. A name no project
170    /// carries has no seat to route on and announces nothing.
171    ///
172    /// Lives here rather than on either connector: the section is the pair,
173    /// so both seams announce through the one site.
174    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    /// Whether the Gotify stream is currently connected. Backs the
194    /// Inspector GOTIFY status line.
195    pub fn gotify_connected(&self) -> bool {
196        *self.gotify_connected.lock()
197    }
198
199    /// Start the Gotify subsystem when it's configured, has at least one
200    /// active subscription, and isn't already running. Idempotent. Spawns
201    /// the forge-connectors pump, which runs the reconnecting stream task
202    /// and translates its events into the `gotify_*` state below and
203    /// `GotifyHost::deliver` calls. Called at boot and after a subscribe
204    /// grows the active set.
205    ///
206    /// The pump's exit guard clears the handle when the task ends, so a
207    /// pump that dies no longer wedges the subsystem: the next call here
208    /// starts a new one.
209    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        // Detached: dropping the handle lets the task run on its own, and
253        // the exit guard is what observes its end.
254        let _pump = self.spawn_gotify_subsystem_task(
255            token,
256            forge_connectors::gotify::run_subsystem(host, cfg, shutdown_rx),
257        );
258    }
259
260    /// Spawn the Gotify pump behind the guard that clears this run's
261    /// handle and liveness however the task ends: a return, a cancellation,
262    /// or an unwind. Returns the task handle so a test can await the end.
263    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    /// Stop the Gotify subsystem once no subscriptions remain: signal the
279    /// stream task to exit and mark disconnected. No-op while any
280    /// subscription is active or the subsystem isn't running.
281    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    /// Signal the running pump to stop and mark the connector
289    /// disconnected, whatever subscriptions remain. The workspace's
290    /// shutdown calls this so a clean stop reaches the pump's own exit
291    /// rather than leaving the handle armed for the task's drop.
292    pub(crate) fn stop_gotify_subsystem(&self) {
293        // The handle lock is held across the flag write, as in the exit
294        // guard: a subscribe can arm a new run the moment the handle is
295        // taken, and a late write would blank that run's liveness.
296        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
310/// One running Gotify pump as the workspace stores it: the shutdown signal
311/// plus the token that tells this run from a later one.
312pub(crate) struct GotifySubsystem {
313    token: Arc<()>,
314    shutdown: tokio::sync::oneshot::Sender<()>,
315}
316
317/// Clears a finished pump's handle and liveness when its task ends.
318///
319/// A guard rather than a trailing statement: an unwind reaches it too,
320/// which is the defect this exists for - a panicking stream task left the
321/// handle armed and no start could revive the subsystem.
322struct 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            // The handle lock is held across the flag write: a stop-then-
332            // resubscribe can arm a new run the moment this one's handle is
333            // taken, and a late write would blank that run's liveness.
334            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            // A handle still armed here means nobody asked this run to
344            // stop: the stream task died under it, or the pump unwound.
345            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
363/// The host a configured Gotify url names, for a log line: the scheme's
364/// authority and nothing after it, so the client token never rides along.
365fn 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
370/// The [`GotifyHost`] the forge-connectors pump drives: a
371/// `Weak<Workspace>` behind the port. The pump holds the host strongly,
372/// so the wrapper stays the weak boundary - the workspace can drop while
373/// the subsystem runs, and every port call degrades to a no-op then,
374/// exactly like the per-event upgrade the pump did when it lived here.
375pub(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    /// The application NAME for `appid` via the reverse of the app index,
395    /// or `None` when the id isn't known (index not yet fetched, or a new
396    /// app the server added after the last refresh).
397    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    /// Wrap the matched message into the notification wire shape and
419    /// dispatch it to the subscriber. This is the only place a
420    /// forge-connectors match becomes a `GotifyNotification`, so the
421    /// prose shape stays workspace-owned
422    /// (`forge_server::envelope::detect_inbound` keys on it).
423    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    /// Register a Gotify subscription directly, bypassing the MCP
454    /// subscribe path. Cross-crate test access so a view can exercise
455    /// the Inspector's `refresh_gotify` resolution, mirroring
456    /// [`Self::seed_test_cron`].
457    ///
458    /// The active-set write without the announcement: a fixture seeds state,
459    /// it does not perform the write a view is owed a frame for.
460    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    /// The Gotify notifications parked for `(project, label)`, under the
476    /// org the project is declared in.
477    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    /// Persist a worker row for `label` in `project_key`, the registry
491    /// entry the durability checks read, failing loudly when it did not
492    /// land. The key must resolve to a configured project: the row lands
493    /// in the `(org, project name, label)` keyed `sessions` table.
494    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    /// Drain every currently-queued `SessionUpdate` from the test rx.
560    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    /// `remove_gotify_subscriptions_for_worker` drops only the target
612    /// worker's subs (matched by project name + label) from both the
613    /// in-memory set and redb; the lead's sub and a sibling worker's sub
614    /// survive, proving the removal is scoped, not a blanket project wipe.
615    #[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    /// Closing a worker drops its durable Gotify subs alongside its
668    /// dynamic-worker row; the lead's sub survives.
669    #[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    /// The seam the bug lived in: `resolve_identity` gathers the caller's
712    /// dynamic-worker labels from the table and marks a table-backed
713    /// worker durable, so its sub persists through the subscribe path even
714    /// though the worker is not the lead.
715    #[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        // "scratch" is not the lead: durability
731        // must come solely from its persisted worker row.
732        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        // The durable identity persists through the subscribe path's write.
745        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    /// A `ProjectKey` that matches no project resolves to no name, so the
766    /// removal is a safe no-op that leaves every sub intact.
767    #[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    /// With no store installed the removal still scrubs the in-memory set;
797    /// the redb delete is simply skipped (mirrors
798    /// `record_worker_row_errors_when_store_unavailable`).
799    #[test]
800    fn remove_gotify_subscriptions_for_worker_scrubs_memory_without_a_db() {
801        let (ws, _rx) = Workspace::testing_stub();
802        // No install_db_for_test: the store is closed for this session.
803        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    /// The GotifyHost impl the forge-connectors pump drives: the port
824    /// reads the `gotify_*` state, and `deliver` wraps the matched
825    /// message into the notification wire shape addressed to the
826    /// subscriber's project + team role.
827    #[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        // No project seeded: "ghost" is not in forge.toml.
903        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        // The delivery ALSO echoes the notification block into the worker's
968        // chat so the user sees what arrived (mirrors the peer echo).
969        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        // No live worker of that role: the subscription falls through to
989        // lead delivery, which spawns the asleep project (the lead brings up
990        // its team) and buffers the envelope for the Connected drain.
991        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    /// A `Failed` entry is kept by design but is NOT live, so it is no more
1013    /// of a delivery target than a label with no entry at all: routing to it
1014    /// would park the notification in a bucket nothing drains. It falls
1015    /// through to the lead exactly like the gone-worker case beside this one.
1016    #[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    /// A team-worker subscription whose worker is a live entry but still
1053    /// Spawning (session_id None) must buffer on the worker's own
1054    /// DomainSession for its Connected drain - not dispatch a bare
1055    /// Command::Prompt (dropped) and not fall through to the lead.
1056    #[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        // Register the domain WITHOUT stamping session_id: still Spawning.
1070        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    /// The one `ConnectorSubscriptionsChanged` on a test's update stream, as
1089    /// its key and the two sets it announces.
1090    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    /// A gotify subscribe, unsubscribe and worker teardown each announce the
1110    /// project's connector sets on its lead seat - BOTH connectors, because
1111    /// the section a client patches is one - so the inspector's GOTIFY
1112    /// section pops on the frame that moved it.
1113    ///
1114    /// Mutants: drop any one emission (the drain is empty); announce only the
1115    /// connector that moved (a patch would leave the sibling stale); announce
1116    /// a set taken before the write.
1117    #[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        // The sibling connector carries a set of its own, so an announcer
1126        // that sent a hardcoded empty beside the moved set cannot pass: the
1127        // frame carries the pair, and a later "send only what moved" would
1128        // clear the client's slack rows on every gotify write.
1129        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        // A worker's teardown announces the survivors.
1163        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    /// A workspace with a real `[gotify]` config and one subscription, so
1175    /// `start_gotify_subsystem` takes its start path rather than declining.
1176    /// The url is a refused local port, so a pump the real start spawns
1177    /// fails its dial without reaching anything.
1178    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    /// The defect: a pump whose stream task dies leaves its handle armed,
1192    /// and every later start no-ops against it. A dead pump clears the
1193    /// handle and liveness, so the next start runs.
1194    ///
1195    /// Mutant: clear only on the shutdown path - a trailing statement,
1196    /// which is what left the subsystem wedged.
1197    #[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        // The pump body returning is the stream task ending under it.
1206        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    /// An unwind inside the pump - a panic out of `route` or a host call -
1216    /// clears the handle too. A panic in the inner stream task is the other
1217    /// shape: it ends that task, the pump returns normally, and the exit
1218    /// guard reports `stream_ended`.
1219    ///
1220    /// Mutant: a trailing statement instead of a guard, where the unwind
1221    /// skips it.
1222    #[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    /// A guard that fires after its run was stopped must leave a run armed
1241    /// since alone: stop-then-resubscribe can start the next pump before
1242    /// the old task unwinds.
1243    ///
1244    /// Mutant: take the slot unconditionally, where the stale guard
1245    /// disarms a live pump and clears its liveness.
1246    #[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        // The stale run's task has not run yet: its stop took the handle
1256        // and a new subscription armed a new run.
1257        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    /// A clean stop signals the pump, so its exit guard takes the shutdown
1273    /// branch instead of reporting a stream end at every process stop.
1274    ///
1275    /// Mutant: leave the handle to the task's drop, where every clean stop
1276    /// logs a stream end.
1277    #[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        // The pump body ends on the shutdown signal, as the real pump does.
1287        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    /// One captured tracing record: its level, its target and its fields.
1313    #[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    /// A decline is a normal state, so it is recorded at debug and never
1345    /// warns. `forge-server`'s logging test pins the directive that keeps
1346    /// the record readable.
1347    ///
1348    /// Mutant: put any decline back at warn, where every boot without
1349    /// Gotify reads as a problem.
1350    #[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    /// A write that changed nothing announces nothing: an unsubscribe for an
1376    /// id nothing holds and a teardown matching no worker subscription are
1377    /// both no-ops.
1378    ///
1379    /// Mutant: emit unconditionally, where each of these is news the section
1380    /// never moved for.
1381    #[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}