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 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 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 stream_cancel: Mutex<HashMap<SessionId, tokio::sync::oneshot::Sender<()>>>,
42 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 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 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 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 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 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 pub async fn send_to_session(&self, session_id: &str, message: &str) -> anyhow::Result<()> {
168 let inbox_enabled = crate::config::AppConfig::load()
172 .map(|c| c.inbox_messaging.enabled)
173 .unwrap_or(false);
174 crate::messaging::deliver_message(
179 &self.store, &crate::config::AppConfig::sessions_dir(), session_id, message, inbox_enabled,
180 )
181 .await
182 }
183
184 pub async fn terminate_session(&self, session_id: &str) -> anyhow::Result<()> {
186 let _ = crate::tmux::kill_session(session_id).await;
188
189 if let Some(mut session) = self.store.get_session(session_id)? {
190 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 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 async fn cleanup_session_in(
224 &self,
225 session_id: &str,
226 sessions_dir: &std::path::Path,
227 ) -> anyhow::Result<()> {
228 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
242async 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
257async 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; }
266 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 #[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 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 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}