Skip to main content

ninox_core/
events.rs

1use crate::{github::{GitHubClient, GithubApi}, 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<Arc<dyn GithubApi>>,
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            .map(|c| Arc::new(c) as Arc<dyn GithubApi>);
55        Arc::new(Self {
56            store,
57            tx,
58            pty_writers:   Mutex::new(HashMap::new()),
59            stream_cancel: Mutex::new(HashMap::new()),
60            github,
61        })
62    }
63
64    /// Construct an `Engine` with a caller-supplied `GithubApi` — the
65    /// dependency-injection seam tests use to drive the GitHub-enrichment
66    /// poller against a fake instead of the real network.
67    pub fn new_with_github_api(store: Arc<Store>, github: Arc<dyn GithubApi>) -> Arc<Self> {
68        let (tx, _) = broadcast::channel(256);
69        Arc::new(Self {
70            store,
71            tx,
72            pty_writers:   Mutex::new(HashMap::new()),
73            stream_cancel: Mutex::new(HashMap::new()),
74            github:        Some(github),
75        })
76    }
77
78    /// Cancel any running FIFO reader for `session_id` and return a fresh
79    /// cancellation receiver for the new reader.  Call this at the top of
80    /// every `start_streaming` invocation.
81    pub async fn register_stream(
82        &self,
83        session_id: SessionId,
84    ) -> tokio::sync::oneshot::Receiver<()> {
85        let mut map = self.stream_cancel.lock().await;
86        if let Some(old_tx) = map.remove(&session_id) {
87            let _ = old_tx.send(());
88        }
89        let (tx, rx) = tokio::sync::oneshot::channel();
90        map.insert(session_id, tx);
91        rx
92    }
93
94    pub fn emit(&self, event: Event) {
95        let _ = self.tx.send(event);
96    }
97
98    pub fn subscribe(&self) -> broadcast::Receiver<Event> {
99        self.tx.subscribe()
100    }
101
102    pub async fn register_pty_writer(
103        &self,
104        session_id: SessionId,
105        writer: tokio::sync::mpsc::UnboundedSender<Vec<u8>>,
106    ) {
107        self.pty_writers.lock().await.insert(session_id, writer);
108    }
109
110    pub async fn get_pty_writer(
111        &self,
112        session_id: &str,
113    ) -> Option<tokio::sync::mpsc::UnboundedSender<Vec<u8>>> {
114        self.pty_writers.lock().await.get(session_id).cloned()
115    }
116
117    /// Kill all worker sessions belonging to an orchestrator, delete from DB, emit events.
118    pub async fn remove_orchestrator(&self, orchestrator_id: &str) -> anyhow::Result<()> {
119        let workers = self.store.sessions_by_orchestrator(orchestrator_id)?;
120        let sessions_dir = crate::config::AppConfig::sessions_dir();
121        for session in &workers {
122            let _ = crate::tmux::kill_session(&session.id).await;
123            remove_worktree_and_artifacts(&session.id, session.workspace_path.as_deref(), &sessions_dir).await;
124        }
125        // Also kill the orchestrator's own tmux session (same id as orchestrator).
126        let _ = crate::tmux::kill_session(orchestrator_id).await;
127        self.store.delete_orchestrator(orchestrator_id)?;
128        for session in workers {
129            self.emit(Event::SessionDone(session.id));
130        }
131        self.emit(Event::OrchestratorRemoved(orchestrator_id.to_string()));
132        Ok(())
133    }
134
135    /// Kill the tmux session and delete it from the DB entirely.
136    pub async fn remove_session(&self, session_id: &str) -> anyhow::Result<()> {
137        let _ = crate::tmux::kill_session(session_id).await;
138        let workspace_path = self.store.get_session(session_id).ok().flatten()
139            .and_then(|s| s.workspace_path);
140        remove_worktree_and_artifacts(
141            session_id, workspace_path.as_deref(), &crate::config::AppConfig::sessions_dir(),
142        ).await;
143        self.store.delete_session(session_id)?;
144        self.emit(Event::SessionDone(session_id.to_string()));
145        Ok(())
146    }
147
148    /// Send a text message to the agent running in a session's tmux window.
149    /// The message is injected as keyboard input — the agent sees it as typed text.
150    /// Returns Ok(()) if the tmux send succeeded; returns an error if the session
151    /// has no active tmux window or tmux is unavailable.
152    pub async fn send_to_session(&self, session_id: &str, message: &str) -> anyhow::Result<()> {
153        crate::tmux::send_keys(session_id, message).await
154    }
155
156    /// Kill the tmux session, mark it Terminated in the DB, and emit SessionUpdated.
157    pub async fn terminate_session(&self, session_id: &str) -> anyhow::Result<()> {
158        // Best-effort tmux kill (session may already be dead).
159        let _ = crate::tmux::kill_session(session_id).await;
160
161        if let Some(mut session) = self.store.get_session(session_id)? {
162            // Never clobber a terminal status — most importantly, never flip
163            // a `Done` session back to `Terminated`. `Done` is only ever
164            // reached via `handle_merge_detection`, which already notified
165            // the orchestrator; `sweep_retired_sessions`'s notification
166            // dedup relies on `Done` meaning "already told" (see
167            // `lifecycle::poller::sweep_retired_sessions`), so re-marking it
168            // `Terminated` here would make the sweep send a second,
169            // contradictory notice for a PR that was in fact merged.
170            if matches!(
171                session.status,
172                SessionStatus::Done | SessionStatus::Terminated | SessionStatus::Interrupted
173            ) {
174                return Ok(());
175            }
176            session.status = SessionStatus::Terminated;
177            self.store.upsert_session(&session)?;
178            self.emit(Event::SessionUpdated(session));
179        }
180        Ok(())
181    }
182
183    /// Kill the tmux session (best-effort), remove its worktree/artifacts,
184    /// and mark it Done in the DB. Called automatically when a PR is
185    /// merged. Emits SessionUpdated. Mirrors `remove_session`'s worktree
186    /// and artifact cleanup so the record isn't deleted here but still
187    /// stops leaking a checked-out worktree and per-session hook files.
188    pub async fn cleanup_session(&self, session_id: &str) -> anyhow::Result<()> {
189        self.cleanup_session_in(session_id, &crate::config::AppConfig::sessions_dir()).await
190    }
191
192    /// `cleanup_session`'s implementation, with the sessions dir as a
193    /// parameter so tests can point artifact removal at a tempdir instead
194    /// of the real `AppConfig::sessions_dir()`.
195    async fn cleanup_session_in(
196        &self,
197        session_id:   &str,
198        sessions_dir: &std::path::Path,
199    ) -> anyhow::Result<()> {
200        // Best-effort tmux kill — session may already be dead.
201        let _ = crate::tmux::kill_session(session_id).await;
202
203        if let Some(mut session) = self.store.get_session(session_id)? {
204            remove_worktree_and_artifacts(session_id, session.workspace_path.as_deref(), sessions_dir).await;
205            session.status = crate::types::SessionStatus::Done;
206            session.terminal_at = Some(crate::lifecycle::poller::now_millis());
207            self.store.upsert_session(&session)?;
208            self.emit(Event::SessionUpdated(session));
209        }
210        Ok(())
211    }
212}
213
214/// Remove a session's worktree (if any) and hook artifacts — the shared
215/// teardown tail of `remove_orchestrator`, `remove_session`, and
216/// `cleanup_session`, so none of them leak a checked-out worktree or
217/// per-session hook files.
218async fn remove_worktree_and_artifacts(
219    session_id:     &str,
220    workspace_path: Option<&str>,
221    sessions_dir:   &std::path::Path,
222) {
223    if let Some(wp) = workspace_path {
224        remove_worker_worktree(wp, session_id).await;
225    }
226    crate::hooks::remove_session_artifacts(sessions_dir, session_id);
227}
228
229/// Remove a Ninox-managed git worktree (best-effort, never propagates errors).
230///
231/// Only acts on paths that match the `.claude/worktrees/{session_id}` pattern
232/// so it never touches unrelated directories.
233async fn remove_worker_worktree(workspace_path: &str, session_id: &str) {
234    let suffix = format!("/.claude/worktrees/{session_id}");
235    if !workspace_path.ends_with(&suffix) {
236        return; // Not a Ninox worktree — leave it alone.
237    }
238    // Derive the repo root by stripping the worktree suffix.
239    let repo_root = &workspace_path[..workspace_path.len() - suffix.len()];
240    if repo_root.is_empty() {
241        return;
242    }
243    let _ = tokio::process::Command::new("git")
244        .args(["-C", repo_root, "worktree", "remove", "--force", workspace_path])
245        .output()
246        .await;
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use crate::store::Store;
253    use tempfile::tempdir;
254
255    #[tokio::test]
256    async fn emit_received_by_subscriber() {
257        let store = Arc::new(Store::open(tempdir().unwrap().keep().join("t.db")).unwrap());
258        let engine = Engine::new(store);
259        let mut rx = engine.subscribe();
260        engine.emit(Event::SessionDone("s1".into()));
261        let event = rx.recv().await.unwrap();
262        assert!(matches!(event, Event::SessionDone(id) if id == "s1"));
263    }
264
265    #[tokio::test]
266    async fn terminate_emits_session_updated() {
267        let store = Arc::new(Store::open(tempdir().unwrap().keep().join("t.db")).unwrap());
268        let session = crate::types::Session {
269            id: "s1".into(), orchestrator_id: None, name: "w".into(),
270            repo: "r".into(), status: crate::types::SessionStatus::Working,
271            agent_type: "c".into(), cost_usd: 0.0, started_at: 0,
272            pr_number: None, pr_id: None, workspace_path: None, pid: None,
273            model: None, context_tokens: None, catalogue_path: None,
274            context_used_pct: None, context_total_tokens: None, context_window_size: None,
275            claude_session_id: None,
276            summary: None,
277            terminal_at: None,
278        };
279        store.upsert_session(&session).unwrap();
280        let engine = Engine::new(store);
281        let mut rx = engine.subscribe();
282
283        engine.terminate_session("s1").await.unwrap();
284
285        let evt = rx.recv().await.unwrap();
286        if let Event::SessionUpdated(s) = evt {
287            assert!(matches!(s.status, crate::types::SessionStatus::Terminated));
288            assert_eq!(
289                s.terminal_at, None,
290                "user-initiated terminate_session must not stamp terminal_at — \
291                 that's reserved for the automatic lifecycle path, so this \
292                 session is purged on sight rather than held for the \
293                 retention grace period",
294            );
295        } else {
296            panic!("expected SessionUpdated");
297        }
298    }
299
300    /// A `Done` session (reached via `cleanup_session`, which already
301    /// notified the orchestrator about the merge) must never be flipped back
302    /// to `Terminated` by a later `terminate_session` call (e.g. the `DELETE
303    /// /sessions/:id` route firing on a stale client, or a race with the
304    /// poller). `sweep_retired_sessions`'s notification dedup relies on
305    /// `Done` meaning "already told" — reopening it as `Terminated` would
306    /// make the sweep send a second, contradictory notification claiming the
307    /// PR was never detected merged.
308    #[tokio::test]
309    async fn terminate_session_does_not_reopen_a_done_session() {
310        let store = Arc::new(Store::open(tempdir().unwrap().keep().join("t.db")).unwrap());
311        let session = crate::types::Session {
312            id: "s1".into(), orchestrator_id: None, name: "w".into(),
313            repo: "r".into(), status: crate::types::SessionStatus::Done,
314            agent_type: "c".into(), cost_usd: 0.0, started_at: 0,
315            pr_number: Some(1), pr_id: Some(1), workspace_path: None, pid: None,
316            model: None, context_tokens: None, catalogue_path: None,
317            context_used_pct: None, context_total_tokens: None, context_window_size: None,
318            claude_session_id: None,
319            summary: None,
320            terminal_at: Some(1_000),
321        };
322        store.upsert_session(&session).unwrap();
323        let engine = Engine::new(Arc::clone(&store));
324        let mut rx = engine.subscribe();
325
326        engine.terminate_session("s1").await.unwrap();
327
328        assert!(
329            rx.try_recv().is_err(),
330            "a no-op terminate_session on a Done session must not emit SessionUpdated"
331        );
332        let after = store.get_session("s1").unwrap().unwrap();
333        assert!(matches!(after.status, crate::types::SessionStatus::Done), "status must stay Done");
334        assert_eq!(after.terminal_at, Some(1_000), "terminal_at must be untouched");
335    }
336
337    #[tokio::test]
338    async fn cleanup_session_sets_done_status() {
339        let store = Arc::new(
340            Store::open(tempdir().unwrap().keep().join("t.db")).unwrap()
341        );
342        let session = crate::types::Session {
343            id: "s1".into(), orchestrator_id: None, name: "w".into(),
344            repo: "r".into(), status: crate::types::SessionStatus::PrOpen,
345            agent_type: "c".into(), cost_usd: 0.0, started_at: 0,
346            pr_number: Some(1), pr_id: Some(1),
347            workspace_path: None, pid: None,
348            model: None, context_tokens: None, catalogue_path: None,
349            context_used_pct: None, context_total_tokens: None, context_window_size: None,
350            claude_session_id: None,
351            summary: None,
352            terminal_at: None,
353        };
354        store.upsert_session(&session).unwrap();
355        let engine = Engine::new(Arc::clone(&store));
356        let mut rx = engine.subscribe();
357
358        engine.cleanup_session("s1").await.unwrap();
359
360        let evt = rx.recv().await.unwrap();
361        if let Event::SessionUpdated(s) = evt {
362            assert!(matches!(s.status, crate::types::SessionStatus::Done));
363            assert!(
364                s.terminal_at.is_some(),
365                "cleanup_session must stamp terminal_at so the retention sweep can hold this \
366                 record for the fleet board's grace window instead of purging it on sight",
367            );
368        } else {
369            panic!("expected SessionUpdated");
370        }
371    }
372
373    #[tokio::test]
374    async fn cleanup_session_removes_worktree_and_artifacts() {
375        // Real git repo + real worktree so we exercise the same
376        // `remove_worker_worktree` path `remove_session` uses, not a mock.
377        let repo_dir = tempdir().unwrap();
378        let run_git = |args: &[&str]| {
379            let status = std::process::Command::new("git")
380                .args(args)
381                .current_dir(repo_dir.path())
382                .status()
383                .unwrap();
384            assert!(status.success(), "git {args:?} failed");
385        };
386        run_git(&["init", "-q"]);
387        run_git(&["-c", "user.email=t@t.com", "-c", "user.name=t", "commit", "--allow-empty", "-q", "-m", "init"]);
388
389        let session_id = "s1";
390        let worktree_path = repo_dir.path().join(".claude/worktrees").join(session_id);
391        run_git(&["worktree", "add", "-q", worktree_path.to_str().unwrap()]);
392        assert!(worktree_path.exists(), "test setup: worktree must exist before cleanup");
393
394        let sessions_dir = tempdir().unwrap();
395        let artifact_path = sessions_dir.path().join(format!("{session_id}.json"));
396        std::fs::write(&artifact_path, "{}").unwrap();
397
398        let store = Arc::new(Store::open(tempdir().unwrap().keep().join("t.db")).unwrap());
399        let session = crate::types::Session {
400            id: session_id.into(), orchestrator_id: None, name: "w".into(),
401            repo: "r".into(), status: crate::types::SessionStatus::PrOpen,
402            agent_type: "c".into(), cost_usd: 0.0, started_at: 0,
403            pr_number: Some(1), pr_id: Some(1),
404            workspace_path: Some(worktree_path.to_string_lossy().to_string()),
405            pid: None,
406            model: None, context_tokens: None, catalogue_path: None,
407            context_used_pct: None, context_total_tokens: None, context_window_size: None,
408            claude_session_id: None,
409            summary: None,
410            terminal_at: None,
411        };
412        store.upsert_session(&session).unwrap();
413        let engine = Engine::new(Arc::clone(&store));
414
415        engine.cleanup_session_in(session_id, sessions_dir.path()).await.unwrap();
416
417        assert!(!worktree_path.exists(), "cleanup_session must remove the worktree, like remove_session does");
418        assert!(!artifact_path.exists(), "cleanup_session must remove session artifacts, like remove_session does");
419        let after = store.get_session(session_id).unwrap().unwrap();
420        assert!(matches!(after.status, crate::types::SessionStatus::Done));
421    }
422
423    #[tokio::test]
424    async fn remove_worker_worktree_ignores_non_ninox_paths() {
425        // Should be a no-op for paths that don't match .claude/worktrees/{id}.
426        // We just verify it doesn't panic or error.
427        remove_worker_worktree("/some/random/path", "s1").await;
428        remove_worker_worktree("/repo/.claude/worktrees/other-id", "s1").await;
429        remove_worker_worktree("", "s1").await;
430    }
431}