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 remove_worktree_and_artifacts(&session.id, session.workspace_path.as_deref(), &sessions_dir).await;
124 }
125 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 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 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 pub async fn terminate_session(&self, session_id: &str) -> anyhow::Result<()> {
158 let _ = crate::tmux::kill_session(session_id).await;
160
161 if let Some(mut session) = self.store.get_session(session_id)? {
162 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 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 async fn cleanup_session_in(
196 &self,
197 session_id: &str,
198 sessions_dir: &std::path::Path,
199 ) -> anyhow::Result<()> {
200 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
214async 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
229async 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; }
238 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 #[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 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 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}