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 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<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 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 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 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 if let Some(ref wp) = session.workspace_path {
124 remove_worker_worktree(wp, &session.id).await;
125 }
126 crate::hooks::remove_session_artifacts(&sessions_dir, &session.id);
127 }
128 let _ = crate::tmux::kill_session(orchestrator_id).await;
130 self.store.delete_orchestrator(orchestrator_id)?;
131 for session in workers {
132 self.emit(Event::SessionDone(session.id));
133 }
134 self.emit(Event::OrchestratorRemoved(orchestrator_id.to_string()));
135 Ok(())
136 }
137
138 pub async fn remove_session(&self, session_id: &str) -> anyhow::Result<()> {
140 let _ = crate::tmux::kill_session(session_id).await;
141 if let Ok(Some(session)) = self.store.get_session(session_id) {
142 if let Some(ref wp) = session.workspace_path {
143 remove_worker_worktree(wp, session_id).await;
144 }
145 }
146 crate::hooks::remove_session_artifacts(
147 &crate::config::AppConfig::sessions_dir(), session_id,
148 );
149 self.store.delete_session(session_id)?;
150 self.emit(Event::SessionDone(session_id.to_string()));
151 Ok(())
152 }
153
154 pub async fn send_to_session(&self, session_id: &str, message: &str) -> anyhow::Result<()> {
159 crate::tmux::send_keys(session_id, message).await
160 }
161
162 pub async fn terminate_session(&self, session_id: &str) -> anyhow::Result<()> {
164 let _ = crate::tmux::kill_session(session_id).await;
166
167 if let Some(mut session) = self.store.get_session(session_id)? {
168 session.status = crate::types::SessionStatus::Terminated;
169 self.store.upsert_session(&session)?;
170 self.emit(Event::SessionUpdated(session));
171 }
172 Ok(())
173 }
174
175 pub async fn cleanup_session(&self, session_id: &str) -> anyhow::Result<()> {
178 let _ = crate::tmux::kill_session(session_id).await;
180
181 if let Some(mut session) = self.store.get_session(session_id)? {
182 session.status = crate::types::SessionStatus::Done;
183 self.store.upsert_session(&session)?;
184 self.emit(Event::SessionUpdated(session));
185 }
186 Ok(())
187 }
188}
189
190async fn remove_worker_worktree(workspace_path: &str, session_id: &str) {
195 let suffix = format!("/.claude/worktrees/{session_id}");
196 if !workspace_path.ends_with(&suffix) {
197 return; }
199 let repo_root = &workspace_path[..workspace_path.len() - suffix.len()];
201 if repo_root.is_empty() {
202 return;
203 }
204 let _ = tokio::process::Command::new("git")
205 .args(["-C", repo_root, "worktree", "remove", "--force", workspace_path])
206 .output()
207 .await;
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use crate::store::Store;
214 use tempfile::tempdir;
215
216 #[tokio::test]
217 async fn emit_received_by_subscriber() {
218 let store = Arc::new(Store::open(tempdir().unwrap().keep().join("t.db")).unwrap());
219 let engine = Engine::new(store);
220 let mut rx = engine.subscribe();
221 engine.emit(Event::SessionDone("s1".into()));
222 let event = rx.recv().await.unwrap();
223 assert!(matches!(event, Event::SessionDone(id) if id == "s1"));
224 }
225
226 #[tokio::test]
227 async fn terminate_emits_session_updated() {
228 let store = Arc::new(Store::open(tempdir().unwrap().keep().join("t.db")).unwrap());
229 let session = crate::types::Session {
230 id: "s1".into(), orchestrator_id: None, name: "w".into(),
231 repo: "r".into(), status: crate::types::SessionStatus::Working,
232 agent_type: "c".into(), cost_usd: 0.0, started_at: 0,
233 pr_number: None, pr_id: None, workspace_path: None, pid: None,
234 model: None, context_tokens: None, catalogue_path: None,
235 context_used_pct: None, context_total_tokens: None, context_window_size: None,
236 claude_session_id: None,
237 };
238 store.upsert_session(&session).unwrap();
239 let engine = Engine::new(store);
240 let mut rx = engine.subscribe();
241
242 engine.terminate_session("s1").await.unwrap();
243
244 let evt = rx.recv().await.unwrap();
245 if let Event::SessionUpdated(s) = evt {
246 assert!(matches!(s.status, crate::types::SessionStatus::Terminated));
247 } else {
248 panic!("expected SessionUpdated");
249 }
250 }
251
252 #[tokio::test]
253 async fn cleanup_session_sets_done_status() {
254 let store = Arc::new(
255 Store::open(tempdir().unwrap().keep().join("t.db")).unwrap()
256 );
257 let session = crate::types::Session {
258 id: "s1".into(), orchestrator_id: None, name: "w".into(),
259 repo: "r".into(), status: crate::types::SessionStatus::PrOpen,
260 agent_type: "c".into(), cost_usd: 0.0, started_at: 0,
261 pr_number: Some(1), pr_id: Some(1),
262 workspace_path: None, pid: None,
263 model: None, context_tokens: None, catalogue_path: None,
264 context_used_pct: None, context_total_tokens: None, context_window_size: None,
265 claude_session_id: None,
266 };
267 store.upsert_session(&session).unwrap();
268 let engine = Engine::new(Arc::clone(&store));
269 let mut rx = engine.subscribe();
270
271 engine.cleanup_session("s1").await.unwrap();
272
273 let evt = rx.recv().await.unwrap();
274 if let Event::SessionUpdated(s) = evt {
275 assert!(matches!(s.status, crate::types::SessionStatus::Done));
276 } else {
277 panic!("expected SessionUpdated");
278 }
279 }
280
281 #[tokio::test]
282 async fn remove_worker_worktree_ignores_non_ninox_paths() {
283 remove_worker_worktree("/some/random/path", "s1").await;
286 remove_worker_worktree("/repo/.claude/worktrees/other-id", "s1").await;
287 remove_worker_worktree("", "s1").await;
288 }
289}