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 ClientOutput { session_id: SessionId, generation: u64, bytes: Vec<u8> },
19 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 stream_cancel: Mutex<HashMap<SessionId, tokio::sync::oneshot::Sender<()>>>,
35 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 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 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 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 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 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 pub async fn terminate_session(&self, session_id: &str) -> anyhow::Result<()> {
149 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 pub async fn cleanup_session(&self, session_id: &str) -> anyhow::Result<()> {
163 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
175async 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; }
184 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,
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,
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 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}