1use algocline_core::execution::{
19 AwaitError, CancelError, CancelReason, ExecutionService, ExecutionState, ObserveError,
20 ObserverHandle, ResumeError, ResumeOutcome, ResumePayload, SessionId, SessionSpec, SpawnError,
21 StateError, TerminalOutcome,
22};
23use async_trait::async_trait;
24
25use crate::service::AppService;
26
27#[async_trait]
28impl ExecutionService for AppService {
29 async fn spawn(&self, spec: SessionSpec) -> Result<SessionId, SpawnError> {
30 let app_dir = self.log_config.app_dir();
38 let card_run_enabled = crate::service::setting::resolve_setting(
39 &app_dir,
40 spec.project_root.as_deref(),
41 Some("card"),
42 )
43 .ok()
44 .and_then(|s| s.get_bool("card", "run"))
45 .unwrap_or(false);
46 self.execution_registry
47 .spawn_v2(spec, card_run_enabled)
48 .await
49 }
50
51 async fn state(&self, id: &SessionId) -> Result<ExecutionState, StateError> {
52 self.execution_registry.state(id).await
53 }
54
55 async fn resume(
56 &self,
57 id: &SessionId,
58 payload: ResumePayload,
59 ) -> Result<ResumeOutcome, ResumeError> {
60 self.execution_registry.resume(id, payload).await
61 }
62
63 async fn cancel(&self, id: &SessionId, reason: CancelReason) -> Result<(), CancelError> {
64 self.execution_registry.cancel(id, reason).await
65 }
66
67 fn observe(&self, id: &SessionId) -> Result<Box<dyn ObserverHandle>, ObserveError> {
68 self.execution_registry.observe(id)
69 }
70
71 async fn await_terminal(&self, id: &SessionId) -> Result<TerminalOutcome, AwaitError> {
72 self.execution_registry.await_terminal(id).await
73 }
74}
75
76#[cfg(test)]
77mod tests {
78 use algocline_core::execution::{
79 CancelCode, CancelReason, ExecutionService, ExecutionState, ObserverRecvError,
80 ProgressEvent, ResumePayload, SessionSpec, SpecKind, TerminalOutcome,
81 };
82
83 use crate::service::test_support::make_app_service;
84
85 fn simple_spec(code: &str) -> SessionSpec {
86 SessionSpec {
87 kind: SpecKind::Run {
88 code: code.to_owned(),
89 },
90 project_root: None,
91 ctx: None,
92 }
93 }
94
95 fn user_cancel_reason() -> CancelReason {
96 CancelReason {
97 code: CancelCode::User,
98 detail: None,
99 requested_at: std::time::SystemTime::now()
100 .duration_since(std::time::UNIX_EPOCH)
101 .unwrap_or_default()
102 .as_millis() as i64,
103 }
104 }
105
106 #[tokio::test]
112 async fn spawn_returns_session_id_and_running_state() {
113 let svc = make_app_service().await;
114 let sid = svc
116 .spawn(simple_spec(r#"return alc.llm("q")"#))
117 .await
118 .expect("spawn must succeed");
119
120 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
122
123 let state = svc.state(&sid).await.expect("state must succeed");
124 assert!(
125 matches!(state, ExecutionState::Running | ExecutionState::Paused(_)),
126 "state immediately after spawn must be Running or Paused, got: {:?}",
127 state.tag()
128 );
129 }
130
131 #[tokio::test]
137 async fn spawn_run_lua_to_completion() {
138 let svc = make_app_service().await;
139 let sid = svc
140 .spawn(simple_spec("return 42"))
141 .await
142 .expect("spawn must succeed");
143
144 let outcome =
145 tokio::time::timeout(std::time::Duration::from_secs(5), svc.await_terminal(&sid))
146 .await
147 .expect("await_terminal must not timeout")
148 .expect("await_terminal must succeed");
149
150 match outcome {
151 TerminalOutcome::Done(result) => {
152 assert_eq!(
153 result.value,
154 serde_json::json!(42),
155 "Done result value must be 42"
156 );
157 }
158 other => panic!("expected Done, got: {other:?}"),
159 }
160 }
161
162 #[tokio::test]
168 async fn spawn_lua_pause_publishes_progress_event() {
169 let svc = make_app_service().await;
170
171 let sid = svc
172 .spawn(simple_spec(r#"return alc.llm("tell me something")"#))
173 .await
174 .expect("spawn must succeed");
175
176 let mut handle = svc.observe(&sid).expect("observe must succeed");
178
179 let got_pause = tokio::time::timeout(std::time::Duration::from_secs(5), async {
181 loop {
182 match handle.recv().await {
183 Ok(ProgressEvent::PauseRequested { .. }) => return true,
184 Ok(_) => {}
185 Err(ObserverRecvError::Closed) => return false,
186 Err(ObserverRecvError::Lagged(_)) => {}
187 }
188 }
189 })
190 .await
191 .expect("must not timeout waiting for PauseRequested");
192
193 assert!(got_pause, "must receive PauseRequested event");
194 }
195
196 #[tokio::test]
203 async fn resume_continues_paused_session() {
204 use algocline_core::execution::ResumeOutcome;
205
206 let svc = make_app_service().await;
207
208 let sid = svc
210 .spawn(simple_spec(r#"return alc.llm("what is 1+1?")"#))
211 .await
212 .expect("spawn must succeed");
213
214 let query_id = tokio::time::timeout(std::time::Duration::from_secs(5), async {
216 loop {
217 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
218 let state = svc.state(&sid).await.expect("state");
219 if let ExecutionState::Paused(info) = state {
220 if let Some(prompt) = info.prompts.first() {
222 return prompt.query_id.clone();
223 }
224 }
225 }
226 })
227 .await
228 .expect("must reach Paused state within timeout");
229
230 let outcome = svc
231 .resume(
232 &sid,
233 ResumePayload::Single {
234 query_id,
235 response: "2".into(),
236 usage: None,
237 },
238 )
239 .await
240 .expect("resume must succeed");
241
242 assert!(
243 matches!(outcome, ResumeOutcome::Continued),
244 "resume must return Continued, got: {outcome:?}"
245 );
246
247 let terminal =
249 tokio::time::timeout(std::time::Duration::from_secs(5), svc.await_terminal(&sid))
250 .await
251 .expect("await_terminal must not timeout")
252 .expect("await_terminal must succeed");
253
254 assert!(
255 matches!(terminal, TerminalOutcome::Done(_)),
256 "session must complete as Done after resume, got: {terminal:?}"
257 );
258 }
259
260 #[tokio::test]
272 async fn cancel_running_session_transitions_to_cancelled() {
273 let svc = make_app_service().await;
274
275 let sid = svc
277 .spawn(simple_spec(r#"return alc.llm("cancel me")"#))
278 .await
279 .expect("spawn must succeed");
280
281 tokio::time::timeout(std::time::Duration::from_secs(5), async {
283 loop {
284 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
285 let state = svc.state(&sid).await.expect("state");
286 if matches!(state, ExecutionState::Paused(_)) {
287 return;
288 }
289 }
290 })
291 .await
292 .expect("session must reach Paused state within timeout");
293
294 svc.cancel(&sid, user_cancel_reason())
295 .await
296 .expect("cancel must succeed");
297
298 let terminal =
299 tokio::time::timeout(std::time::Duration::from_secs(5), svc.await_terminal(&sid))
300 .await
301 .expect("await_terminal must not timeout")
302 .expect("await_terminal must succeed");
303
304 match terminal {
305 TerminalOutcome::Cancelled(info) => {
306 assert_eq!(
307 info.reason.code,
308 CancelCode::User,
309 "cancel code must be User"
310 );
311 }
312 other => panic!("expected Cancelled, got: {other:?}"),
313 }
314 }
315
316 #[tokio::test]
323 async fn cancel_idempotent_returns_ok() {
324 let svc = make_app_service().await;
325
326 let sid = svc
328 .spawn(simple_spec(r#"return alc.llm("cancel me")"#))
329 .await
330 .expect("spawn must succeed");
331
332 tokio::time::timeout(std::time::Duration::from_secs(5), async {
334 loop {
335 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
336 let state = svc.state(&sid).await.expect("state");
337 if matches!(state, ExecutionState::Paused(_)) {
338 return;
339 }
340 }
341 })
342 .await
343 .expect("session must reach Paused state within timeout");
344
345 svc.cancel(&sid, user_cancel_reason())
346 .await
347 .expect("first cancel must return Ok");
348
349 svc.cancel(&sid, user_cancel_reason())
351 .await
352 .expect("second cancel must return Ok (idempotent)");
353
354 let terminal =
356 tokio::time::timeout(std::time::Duration::from_secs(5), svc.await_terminal(&sid))
357 .await
358 .expect("await_terminal must not timeout")
359 .expect("await_terminal must succeed");
360
361 assert!(
362 matches!(terminal, TerminalOutcome::Cancelled(_)),
363 "session must be Cancelled, got: {terminal:?}"
364 );
365 }
366
367 #[tokio::test]
374 async fn observe_multi_subscriber_fan_out_via_appservice() {
375 let svc = make_app_service().await;
376
377 let sid = svc
378 .spawn(simple_spec("return 99"))
379 .await
380 .expect("spawn must succeed");
381
382 let mut h1 = svc.observe(&sid).expect("observe h1 must succeed");
384 let mut h2 = svc.observe(&sid).expect("observe h2 must succeed");
385
386 let _ = tokio::time::timeout(std::time::Duration::from_secs(5), svc.await_terminal(&sid))
388 .await
389 .expect("await_terminal must not timeout");
390
391 use std::time::Duration;
403 for (label, handle) in [("h1", &mut h1), ("h2", &mut h2)] {
404 let mut got_transition = false;
405 loop {
406 match tokio::time::timeout(Duration::from_millis(100), handle.recv()).await {
407 Ok(Ok(ProgressEvent::StateTransition { .. })) => got_transition = true,
408 Ok(Ok(_)) => {}
409 Ok(Err(ObserverRecvError::Closed)) => break,
410 Ok(Err(ObserverRecvError::Lagged(_))) => {}
411 Err(_) => break, }
413 }
414 assert!(
415 got_transition,
416 "{label}: must receive at least one StateTransition event"
417 );
418 }
419 }
420
421 #[tokio::test]
436 async fn observe_sink_free_when_no_subscribers() {
437 use algocline_core::execution::ObserveError;
438 use std::sync::Arc;
439 use std::time::Duration;
440
441 let executor = Arc::new(
443 algocline_engine::Executor::new(vec![])
444 .await
445 .expect("Executor::new"),
446 );
447 let tmp = tempfile::tempdir().expect("tempdir");
448 let state_store = Arc::new(algocline_engine::JsonFileStore::new(
449 tmp.path().join("state"),
450 ));
451 let card_store = Arc::new(algocline_engine::FileCardStore::new(
452 tmp.path().join("cards"),
453 ));
454 let scenarios_dir = tmp.path().join("scenarios");
455 let nn_dir = tmp.path().join("nn");
456 let dirs = algocline_engine::SessionDirs {
457 state_store,
458 card_store,
459 scenarios_dir,
460 nn_dir,
461 };
462 let registry = algocline_engine::execution::SessionRegistryV2::new(executor, dirs);
463
464 let ttl = Duration::from_millis(100);
465 let interval = Duration::from_millis(50);
466
467 let sid = registry
468 .spawn_v2(simple_spec("return 1"), false)
469 .await
470 .expect("spawn must succeed");
471
472 let terminal = tokio::time::timeout(Duration::from_secs(5), registry.await_terminal(&sid))
474 .await
475 .expect("await_terminal must not timeout even with 0 observers")
476 .expect("await_terminal must succeed");
477
478 assert!(
479 matches!(terminal, TerminalOutcome::Done(_)),
480 "session must complete as Done, got: {terminal:?}"
481 );
482
483 registry.spawn_gc_task(ttl, interval);
485
486 tokio::time::sleep(interval + ttl + Duration::from_millis(50)).await;
489
490 assert!(
492 matches!(registry.observe(&sid), Err(ObserveError::NotFound(_))),
493 "observe() must return NotFound after GC evicts the terminal session"
494 );
495 }
496}