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