Skip to main content

ninox_core/
events.rs

1use crate::{github::GitHubClient, store::Store, types::*};
2use std::{collections::HashMap, sync::Arc};
3use tokio::sync::{broadcast, Mutex};
4
5#[derive(Debug, Clone)]
6pub enum Event {
7    OrchestratorSpawned(Orchestrator),
8    OrchestratorRemoved(OrchestratorId),
9    SessionUpdated(Session),
10    SessionSpawned(Session),
11    SessionDone(SessionId),
12    TerminalOutput { session_id: SessionId, bytes: Vec<u8> },
13    /// Rendering stream from an attached tmux client (AttachedClient).
14    /// `generation` identifies which `AttachedClient::spawn` call produced
15    /// this event — consumers must ignore events whose generation doesn't
16    /// match the currently-live client for `session_id` so a stale client
17    /// (superseded by a fresh attach) cannot clobber the new one.
18    ClientOutput   { session_id: SessionId, generation: u64, bytes: Vec<u8> },
19    /// The attached tmux client process exited (detach, kill, server gone).
20    /// See `ClientOutput` for why `generation` matters.
21    ClientClosed   { session_id: SessionId, generation: u64 },
22    CiUpdated      { pr_id: PrId, status: CIStatus },
23    PrOpened       { session_id: SessionId, pr: PR },
24    ReviewComment  { pr_id: PrId, comment: Comment },
25    Notification(Notification),
26}
27
28pub struct Engine {
29    pub store: Arc<Store>,
30    tx: broadcast::Sender<Event>,
31    pty_writers:  Mutex<HashMap<SessionId, tokio::sync::mpsc::UnboundedSender<Vec<u8>>>>,
32    /// Per-session cancellation senders for active FIFO reader tasks.
33    /// Sending () to the stored sender stops the running reader immediately.
34    stream_cancel: Mutex<HashMap<SessionId, tokio::sync::oneshot::Sender<()>>>,
35    /// Optional GitHub API client. None when no token is configured.
36    pub github: Option<GitHubClient>,
37}
38
39impl Engine {
40    pub fn new(store: Arc<Store>) -> Arc<Self> {
41        let (tx, _) = broadcast::channel(256);
42        Arc::new(Self {
43            store,
44            tx,
45            pty_writers:   Mutex::new(HashMap::new()),
46            stream_cancel: Mutex::new(HashMap::new()),
47            github:        None,
48        })
49    }
50
51    pub fn new_with_github(store: Arc<Store>, token: String) -> Arc<Self> {
52        let (tx, _) = broadcast::channel(256);
53        let github = GitHubClient::new(token).ok();
54        Arc::new(Self {
55            store,
56            tx,
57            pty_writers:   Mutex::new(HashMap::new()),
58            stream_cancel: Mutex::new(HashMap::new()),
59            github,
60        })
61    }
62
63    /// Cancel any running FIFO reader for `session_id` and return a fresh
64    /// cancellation receiver for the new reader.  Call this at the top of
65    /// every `start_streaming` invocation.
66    pub async fn register_stream(
67        &self,
68        session_id: SessionId,
69    ) -> tokio::sync::oneshot::Receiver<()> {
70        let mut map = self.stream_cancel.lock().await;
71        if let Some(old_tx) = map.remove(&session_id) {
72            let _ = old_tx.send(());
73        }
74        let (tx, rx) = tokio::sync::oneshot::channel();
75        map.insert(session_id, tx);
76        rx
77    }
78
79    pub fn emit(&self, event: Event) {
80        let _ = self.tx.send(event);
81    }
82
83    pub fn subscribe(&self) -> broadcast::Receiver<Event> {
84        self.tx.subscribe()
85    }
86
87    pub async fn register_pty_writer(
88        &self,
89        session_id: SessionId,
90        writer: tokio::sync::mpsc::UnboundedSender<Vec<u8>>,
91    ) {
92        self.pty_writers.lock().await.insert(session_id, writer);
93    }
94
95    pub async fn get_pty_writer(
96        &self,
97        session_id: &str,
98    ) -> Option<tokio::sync::mpsc::UnboundedSender<Vec<u8>>> {
99        self.pty_writers.lock().await.get(session_id).cloned()
100    }
101
102    /// Kill all worker sessions belonging to an orchestrator, delete from DB, emit events.
103    pub async fn remove_orchestrator(&self, orchestrator_id: &str) -> anyhow::Result<()> {
104        let workers = self.store.sessions_by_orchestrator(orchestrator_id)?;
105        let sessions_dir = crate::config::AppConfig::sessions_dir();
106        for session in &workers {
107            let _ = crate::tmux::kill_session(&session.id).await;
108            if let Some(ref wp) = session.workspace_path {
109                remove_worker_worktree(wp, &session.id).await;
110            }
111            crate::hooks::remove_session_artifacts(&sessions_dir, &session.id);
112        }
113        // Also kill the orchestrator's own tmux session (same id as orchestrator).
114        let _ = crate::tmux::kill_session(orchestrator_id).await;
115        self.store.delete_orchestrator(orchestrator_id)?;
116        for session in workers {
117            self.emit(Event::SessionDone(session.id));
118        }
119        self.emit(Event::OrchestratorRemoved(orchestrator_id.to_string()));
120        Ok(())
121    }
122
123    /// Kill the tmux session and delete it from the DB entirely.
124    pub async fn remove_session(&self, session_id: &str) -> anyhow::Result<()> {
125        let _ = crate::tmux::kill_session(session_id).await;
126        if let Ok(Some(session)) = self.store.get_session(session_id) {
127            if let Some(ref wp) = session.workspace_path {
128                remove_worker_worktree(wp, session_id).await;
129            }
130        }
131        crate::hooks::remove_session_artifacts(
132            &crate::config::AppConfig::sessions_dir(), session_id,
133        );
134        self.store.delete_session(session_id)?;
135        self.emit(Event::SessionDone(session_id.to_string()));
136        Ok(())
137    }
138
139    /// Send a text message to the agent running in a session's tmux window.
140    /// The message is injected as keyboard input — the agent sees it as typed text.
141    /// Returns Ok(()) if the tmux send succeeded; returns an error if the session
142    /// has no active tmux window or tmux is unavailable.
143    pub async fn send_to_session(&self, session_id: &str, message: &str) -> anyhow::Result<()> {
144        crate::tmux::send_keys(session_id, message).await
145    }
146
147    /// Kill the tmux session, mark it Terminated in the DB, and emit SessionUpdated.
148    pub async fn terminate_session(&self, session_id: &str) -> anyhow::Result<()> {
149        // Best-effort tmux kill (session may already be dead).
150        let _ = crate::tmux::kill_session(session_id).await;
151
152        if let Some(mut session) = self.store.get_session(session_id)? {
153            session.status = crate::types::SessionStatus::Terminated;
154            self.store.upsert_session(&session)?;
155            self.emit(Event::SessionUpdated(session));
156        }
157        Ok(())
158    }
159
160    /// Kill the tmux session (best-effort) and mark it Done in the DB.
161    /// Called automatically when a PR is merged. Emits SessionUpdated.
162    pub async fn cleanup_session(&self, session_id: &str) -> anyhow::Result<()> {
163        // Best-effort tmux kill — session may already be dead.
164        let _ = crate::tmux::kill_session(session_id).await;
165
166        if let Some(mut session) = self.store.get_session(session_id)? {
167            session.status = crate::types::SessionStatus::Done;
168            self.store.upsert_session(&session)?;
169            self.emit(Event::SessionUpdated(session));
170        }
171        Ok(())
172    }
173}
174
175/// Remove a Ninox-managed git worktree (best-effort, never propagates errors).
176///
177/// Only acts on paths that match the `.claude/worktrees/{session_id}` pattern
178/// so it never touches unrelated directories.
179async fn remove_worker_worktree(workspace_path: &str, session_id: &str) {
180    let suffix = format!("/.claude/worktrees/{session_id}");
181    if !workspace_path.ends_with(&suffix) {
182        return; // Not a Ninox worktree — leave it alone.
183    }
184    // Derive the repo root by stripping the worktree suffix.
185    let repo_root = &workspace_path[..workspace_path.len() - suffix.len()];
186    if repo_root.is_empty() {
187        return;
188    }
189    let _ = tokio::process::Command::new("git")
190        .args(["-C", repo_root, "worktree", "remove", "--force", workspace_path])
191        .output()
192        .await;
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use crate::store::Store;
199    use tempfile::tempdir;
200
201    #[tokio::test]
202    async fn emit_received_by_subscriber() {
203        let store = Arc::new(Store::open(tempdir().unwrap().keep().join("t.db")).unwrap());
204        let engine = Engine::new(store);
205        let mut rx = engine.subscribe();
206        engine.emit(Event::SessionDone("s1".into()));
207        let event = rx.recv().await.unwrap();
208        assert!(matches!(event, Event::SessionDone(id) if id == "s1"));
209    }
210
211    #[tokio::test]
212    async fn terminate_emits_session_updated() {
213        let store = Arc::new(Store::open(tempdir().unwrap().keep().join("t.db")).unwrap());
214        let session = crate::types::Session {
215            id: "s1".into(), orchestrator_id: None, name: "w".into(),
216            repo: "r".into(), status: crate::types::SessionStatus::Working,
217            agent_type: "c".into(), cost_usd: 0.0, started_at: 0,
218            pr_number: None, pr_id: None, workspace_path: None, pid: None,
219            model: None, context_tokens: None, catalogue_path: None, claude_session_id: None,
220        };
221        store.upsert_session(&session).unwrap();
222        let engine = Engine::new(store);
223        let mut rx = engine.subscribe();
224
225        engine.terminate_session("s1").await.unwrap();
226
227        let evt = rx.recv().await.unwrap();
228        if let Event::SessionUpdated(s) = evt {
229            assert!(matches!(s.status, crate::types::SessionStatus::Terminated));
230        } else {
231            panic!("expected SessionUpdated");
232        }
233    }
234
235    #[tokio::test]
236    async fn cleanup_session_sets_done_status() {
237        let store = Arc::new(
238            Store::open(tempdir().unwrap().keep().join("t.db")).unwrap()
239        );
240        let session = crate::types::Session {
241            id: "s1".into(), orchestrator_id: None, name: "w".into(),
242            repo: "r".into(), status: crate::types::SessionStatus::PrOpen,
243            agent_type: "c".into(), cost_usd: 0.0, started_at: 0,
244            pr_number: Some(1), pr_id: Some(1),
245            workspace_path: None, pid: None,
246            model: None, context_tokens: None, catalogue_path: None, claude_session_id: None,
247        };
248        store.upsert_session(&session).unwrap();
249        let engine = Engine::new(Arc::clone(&store));
250        let mut rx = engine.subscribe();
251
252        engine.cleanup_session("s1").await.unwrap();
253
254        let evt = rx.recv().await.unwrap();
255        if let Event::SessionUpdated(s) = evt {
256            assert!(matches!(s.status, crate::types::SessionStatus::Done));
257        } else {
258            panic!("expected SessionUpdated");
259        }
260    }
261
262    #[tokio::test]
263    async fn remove_worker_worktree_ignores_non_ninox_paths() {
264        // Should be a no-op for paths that don't match .claude/worktrees/{id}.
265        // We just verify it doesn't panic or error.
266        remove_worker_worktree("/some/random/path", "s1").await;
267        remove_worker_worktree("/repo/.claude/worktrees/other-id", "s1").await;
268        remove_worker_worktree("", "s1").await;
269    }
270}