1use axum::{
24 extract::{Path, Query, State},
25 http::StatusCode,
26 Json,
27};
28use mlua_swarm::application::{TaskApplicationError, TaskApplicationInput, TaskApplicationOutput};
29use mlua_swarm::store::run::{RunContext, RunRecord, RunStatus, RunStoreError};
30use mlua_swarm::store::task::{TaskRecord, TaskRecordStatus, TaskStoreError};
31use mlua_swarm::{Role, RunId, TaskId};
32use serde::{Deserialize, Serialize};
33use std::time::Duration;
34
35use crate::{ApiError, AppState};
36
37pub(crate) fn now_secs() -> u64 {
41 std::time::SystemTime::now()
42 .duration_since(std::time::UNIX_EPOCH)
43 .map(|d| d.as_secs())
44 .unwrap_or(0)
45}
46
47pub(crate) async fn finalize_run(
57 state: &AppState,
58 task_id: &TaskId,
59 run_id: &RunId,
60 outcome: Result<TaskApplicationOutput, TaskApplicationError>,
61) -> Result<TaskApplicationOutput, TaskApplicationError> {
62 match &outcome {
63 Ok(out) => {
64 if let Err(e) = state
65 .run_store
66 .set_result(run_id, out.final_ctx.clone())
67 .await
68 {
69 tracing::warn!(%run_id, error = %e, "finalize_run: set_result failed");
70 }
71 if let Err(e) = state.run_store.update_status(run_id, RunStatus::Done).await {
72 tracing::warn!(%run_id, error = %e, "finalize_run: run update_status(Done) failed");
73 }
74 if let Err(e) = state
75 .task_store
76 .update_status(task_id, TaskRecordStatus::Done)
77 .await
78 {
79 tracing::warn!(%task_id, error = %e, "finalize_run: task update_status(Done) failed");
80 }
81 }
82 Err(e) => {
83 if let Err(store_err) = state
84 .run_store
85 .update_status(run_id, RunStatus::Failed)
86 .await
87 {
88 tracing::warn!(%run_id, error = %store_err, "finalize_run: run update_status(Failed) failed");
89 }
90 if let Err(store_err) = state
91 .task_store
92 .update_status(task_id, TaskRecordStatus::Failed)
93 .await
94 {
95 tracing::warn!(%task_id, error = %store_err, "finalize_run: task update_status(Failed) failed");
96 }
97 tracing::warn!(%task_id, %run_id, error = %e, "finalize_run: dispatch failed");
98 }
99 }
100 outcome
101}
102
103#[derive(Debug, Deserialize, Default)]
105pub struct TasksListQuery {
106 #[serde(default)]
109 pub limit: Option<usize>,
110}
111
112pub async fn tasks_list(
114 State(state): State<AppState>,
115 Query(q): Query<TasksListQuery>,
116) -> Result<Json<Vec<TaskRecord>>, ApiError> {
117 let mut records = state.task_store.list().await.map_err(ApiError::engine)?;
118 if let Some(limit) = q.limit {
119 records.truncate(limit);
120 }
121 Ok(Json(records))
122}
123
124#[derive(Debug, Clone, Serialize)]
126pub struct TaskDetailResponse {
127 pub task: TaskRecord,
129 pub runs: Vec<RunRecord>,
131}
132
133pub async fn task_get(
136 State(state): State<AppState>,
137 Path(id): Path<String>,
138) -> Result<Json<TaskDetailResponse>, ApiError> {
139 let task_id =
140 TaskId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid task id: {e}")))?;
141 let task = state
142 .task_store
143 .get(&task_id)
144 .await
145 .map_err(map_task_store_err)?;
146 let runs = state
147 .run_store
148 .list_by_task(&task_id)
149 .await
150 .map_err(ApiError::engine)?;
151 Ok(Json(TaskDetailResponse { task, runs }))
152}
153
154#[derive(Debug, Clone, Serialize)]
156pub struct RunKickResponse {
157 pub task_id: TaskId,
159 pub run_id: RunId,
161}
162
163pub async fn task_rekick(
172 State(state): State<AppState>,
173 Path(id): Path<String>,
174) -> Result<(StatusCode, Json<RunKickResponse>), ApiError> {
175 let task_id =
176 TaskId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid task id: {e}")))?;
177 let task = state
178 .task_store
179 .get(&task_id)
180 .await
181 .map_err(map_task_store_err)?;
182
183 let blueprint: mlua_swarm::application::BlueprintRef =
184 serde_json::from_value(task.blueprint_ref.clone()).map_err(|e| {
185 ApiError::bad_request(format!(
186 "task {task_id}: stored blueprint_ref failed to decode: {e}"
187 ))
188 })?;
189
190 let run_id = RunId::new();
191 let now = now_secs();
192 state
193 .task_store
194 .update_status(&task_id, TaskRecordStatus::Running)
195 .await
196 .map_err(ApiError::engine)?;
197 state
198 .run_store
199 .create(RunRecord {
200 id: run_id.clone(),
201 task_id: task_id.clone(),
202 status: RunStatus::Running,
203 step_entries: Vec::new(),
204 operator_sid: None,
205 result_ref: None,
206 created_at: now,
207 updated_at: now,
208 })
209 .await
210 .map_err(ApiError::engine)?;
211
212 let input = TaskApplicationInput::automate(
213 blueprint,
214 "http-run",
215 Role::Operator,
216 Duration::from_secs(crate::default_run_ttl()),
217 task.input_ctx.clone(),
218 );
219 let run_ctx = RunContext {
220 run_id: run_id.clone(),
221 run_store: state.run_store.clone(),
222 };
223 let outcome = state.task_app.handle_with_run(input, Some(run_ctx)).await;
224 finalize_run(&state, &task_id, &run_id, outcome)
225 .await
226 .map_err(|e| ApiError::bad_request(format!("run: {e}")))?;
227
228 Ok((
229 StatusCode::CREATED,
230 Json(RunKickResponse { task_id, run_id }),
231 ))
232}
233
234pub async fn run_get(
237 State(state): State<AppState>,
238 Path(id): Path<String>,
239) -> Result<Json<RunRecord>, ApiError> {
240 let run_id =
241 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
242 let run = state
243 .run_store
244 .get(&run_id)
245 .await
246 .map_err(map_run_store_err)?;
247 Ok(Json(run))
248}
249
250fn map_task_store_err(e: TaskStoreError) -> ApiError {
251 match e {
252 TaskStoreError::NotFound(id) => ApiError::not_found(format!("task not found: {id}")),
253 other => ApiError::engine(other),
254 }
255}
256
257fn map_run_store_err(e: RunStoreError) -> ApiError {
258 match e {
259 RunStoreError::NotFound(id) => ApiError::not_found(format!("run not found: {id}")),
260 other => ApiError::engine(other),
261 }
262}
263
264#[cfg(test)]
269mod tests {
270 use super::*;
271 use mlua_swarm::application::BlueprintRef;
272 use mlua_swarm::blueprint::{
273 current_schema_version, AgentDef, AgentKind, Blueprint, BlueprintMetadata, CompilerHints,
274 CompilerStrategy,
275 };
276 use mlua_swarm::core::config::EngineCfg;
277 use mlua_swarm::core::engine::Engine;
278 use mlua_swarm::store::output::InMemoryOutputStore;
279 use mlua_swarm::store::run::InMemoryRunStore;
280 use mlua_swarm::store::task::InMemoryTaskStore;
281 use std::collections::HashMap;
282 use std::sync::Arc;
283 use tokio::sync::Mutex;
284
285 fn identity_blueprint() -> Blueprint {
291 Blueprint {
292 schema_version: current_schema_version(),
293 id: "tasks-test-bp".into(),
294 flow: serde_json::from_value(serde_json::json!({
295 "kind": "step",
296 "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
297 "in": {"op": "lit", "value": "hello"},
298 "out": {"op": "path", "at": "$.out"},
299 }))
300 .expect("flow parse"),
301 agents: vec![AgentDef {
302 name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
303 kind: AgentKind::RustFn,
304 spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
305 profile: None,
306 meta: None,
307 }],
308 operators: vec![],
309 hints: CompilerHints::default(),
310 strategy: CompilerStrategy::default(),
311 metadata: BlueprintMetadata::default(),
312 spawner_hints: Default::default(),
313 default_agent_kind: AgentKind::Operator,
314 default_operator_kind: None,
315 }
316 }
317
318 fn test_state() -> AppState {
323 let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
324 let compiler = mlua_swarm::Compiler::new(crate::default_registry());
325 let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
326 AppState {
327 engine,
328 sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
329 task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
330 ws_operator_factory: None,
331 data_store: Arc::new(InMemoryOutputStore::new()),
332 operator_sessions: Arc::new(Mutex::new(HashMap::new())),
333 roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
334 task_store: Arc::new(InMemoryTaskStore::new()),
335 run_store: Arc::new(InMemoryRunStore::new()),
336 base_url: None,
337 }
338 }
339
340 fn post_tasks_req(goal: &str) -> crate::FlowTasksReq {
341 crate::FlowTasksReq {
342 blueprint: BlueprintRef::Inline {
343 value: Box::new(identity_blueprint()),
344 },
345 init_ctx: serde_json::json!({"in": "hello"}),
346 ttl_secs: None,
347 operator: None,
348 operator_sid: None,
349 goal: Some(goal.to_string()),
350 }
351 }
352
353 #[test]
354 fn task_id_serializes_as_bare_string() {
355 let v = serde_json::to_value(TaskId::parse("T-abc").unwrap()).expect("serialize");
359 assert_eq!(v, serde_json::json!("T-abc"));
360 }
361
362 #[tokio::test]
363 async fn post_then_get_drill_down() {
364 let state = test_state();
365
366 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("smoke goal")))
367 .await
368 .expect("tasks_start")
369 .0;
370 let task_id = posted.task_id.clone();
371 let run_id = posted.run_id.clone();
372
373 let list = tasks_list(State(state.clone()), Query(TasksListQuery { limit: None }))
375 .await
376 .expect("tasks_list")
377 .0;
378 assert!(
379 list.iter().any(|t| t.id == task_id),
380 "task {task_id} missing from list of {} tasks",
381 list.len()
382 );
383
384 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
386 .await
387 .expect("task_get")
388 .0;
389 assert_eq!(detail.task.id, task_id);
390 assert_eq!(detail.task.goal, "smoke goal");
391 assert_eq!(detail.task.status, TaskRecordStatus::Done);
392 assert_eq!(detail.runs.len(), 1);
393 assert_eq!(detail.runs[0].id, run_id);
394 assert_eq!(detail.runs[0].status, RunStatus::Done);
395
396 let run = run_get(State(state.clone()), Path(run_id.to_string()))
398 .await
399 .expect("run_get")
400 .0;
401 assert_eq!(run.id, run_id);
402 assert_eq!(run.task_id, task_id);
403 assert_eq!(run.result_ref, Some(posted.final_ctx));
404
405 assert_eq!(
409 run.step_entries.len(),
410 1,
411 "expected one step_entry for the 1-step identity Blueprint, got {:?}",
412 run.step_entries
413 );
414 assert_eq!(
415 run.step_entries[0].step_ref,
416 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
417 );
418 assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
419 }
420
421 #[tokio::test]
422 async fn rekick_adds_a_second_run_to_the_same_task() {
423 let state = test_state();
424 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("rekick goal")))
425 .await
426 .expect("tasks_start")
427 .0;
428 let task_id = posted.task_id.clone();
429 let first_run_id = posted.run_id.clone();
430
431 let (status, rekicked) = task_rekick(State(state.clone()), Path(task_id.to_string()))
432 .await
433 .expect("task_rekick");
434 assert_eq!(status, StatusCode::CREATED);
435 let second_run_id = rekicked.0.run_id.clone();
436 assert_ne!(first_run_id, second_run_id);
437
438 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
439 .await
440 .expect("task_get")
441 .0;
442 assert_eq!(
443 detail.runs.len(),
444 2,
445 "expected 2 runs, got {:?}",
446 detail.runs
447 );
448 let ids: Vec<&RunId> = detail.runs.iter().map(|r| &r.id).collect();
449 assert!(ids.contains(&&first_run_id));
450 assert!(ids.contains(&&second_run_id));
451
452 let first_run = detail
457 .runs
458 .iter()
459 .find(|r| r.id == first_run_id)
460 .expect("first run present in detail.runs");
461 let second_run = detail
462 .runs
463 .iter()
464 .find(|r| r.id == second_run_id)
465 .expect("second run present in detail.runs");
466 assert_eq!(
467 first_run.step_entries.len(),
468 1,
469 "first run step_entries: {:?}",
470 first_run.step_entries
471 );
472 assert_eq!(
473 second_run.step_entries.len(),
474 1,
475 "second run step_entries: {:?}",
476 second_run.step_entries
477 );
478 assert_eq!(
479 first_run.step_entries[0].step_ref,
480 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
481 );
482 assert_eq!(
483 second_run.step_entries[0].step_ref,
484 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
485 );
486 assert_eq!(first_run.step_entries[0].status, Some("passed".to_string()));
487 assert_eq!(
488 second_run.step_entries[0].status,
489 Some("passed".to_string())
490 );
491 assert_ne!(
492 first_run.step_entries[0].step_id, second_run.step_entries[0].step_id,
493 "each kick dispatches its own StepId — runs must not share step_entries"
494 );
495 }
496
497 #[tokio::test]
498 async fn rekick_unknown_task_returns_404() {
499 let state = test_state();
500 match task_rekick(State(state), Path("T-does-not-exist".to_string())).await {
504 Ok(_) => panic!("expected 404 for an unknown task"),
505 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
506 }
507 }
508
509 #[tokio::test]
510 async fn run_get_unknown_id_returns_404() {
511 let state = test_state();
512 match run_get(State(state), Path("R-does-not-exist".to_string())).await {
513 Ok(_) => panic!("expected 404 for an unknown run"),
514 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
515 }
516 }
517
518 #[tokio::test]
519 async fn task_get_unknown_id_returns_404() {
520 let state = test_state();
521 match task_get(State(state), Path("T-does-not-exist".to_string())).await {
522 Ok(_) => panic!("expected 404 for an unknown task"),
523 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
524 }
525 }
526}