1use axum::{
29 extract::{Path, Query, State},
30 http::StatusCode,
31 Json,
32};
33use mlua_swarm::application::{TaskApplicationError, TaskApplicationInput, TaskApplicationOutput};
34use mlua_swarm::service::merge_init_ctx_3layer;
35use mlua_swarm::store::run::{RunContext, RunRecord, RunStatus, RunStoreError};
36use mlua_swarm::store::task::{TaskRecord, TaskRecordStatus, TaskStoreError};
37use mlua_swarm::{Role, RunId, TaskId, TaskInputSpec};
38use serde::{Deserialize, Serialize};
39use serde_json::Value;
40use std::collections::HashMap;
41use std::time::Duration;
42
43use crate::{ApiError, AppState};
44
45pub(crate) fn now_secs() -> u64 {
49 std::time::SystemTime::now()
50 .duration_since(std::time::UNIX_EPOCH)
51 .map(|d| d.as_secs())
52 .unwrap_or(0)
53}
54
55pub(crate) async fn finalize_run(
65 state: &AppState,
66 task_id: &TaskId,
67 run_id: &RunId,
68 outcome: Result<TaskApplicationOutput, TaskApplicationError>,
69) -> Result<TaskApplicationOutput, TaskApplicationError> {
70 match &outcome {
71 Ok(out) => {
72 if let Err(e) = state
73 .run_store
74 .set_result(run_id, out.final_ctx.clone())
75 .await
76 {
77 tracing::warn!(%run_id, error = %e, "finalize_run: set_result failed");
78 }
79 if let Err(e) = state.run_store.update_status(run_id, RunStatus::Done).await {
80 tracing::warn!(%run_id, error = %e, "finalize_run: run update_status(Done) failed");
81 }
82 if let Err(e) = state
83 .task_store
84 .update_status(task_id, TaskRecordStatus::Done)
85 .await
86 {
87 tracing::warn!(%task_id, error = %e, "finalize_run: task update_status(Done) failed");
88 }
89 }
90 Err(e) => {
91 if let Err(store_err) = state
92 .run_store
93 .update_status(run_id, RunStatus::Failed)
94 .await
95 {
96 tracing::warn!(%run_id, error = %store_err, "finalize_run: run update_status(Failed) failed");
97 }
98 if let Err(store_err) = state
99 .task_store
100 .update_status(task_id, TaskRecordStatus::Failed)
101 .await
102 {
103 tracing::warn!(%task_id, error = %store_err, "finalize_run: task update_status(Failed) failed");
104 }
105 tracing::warn!(%task_id, %run_id, error = %e, "finalize_run: dispatch failed");
106 }
107 }
108 outcome
109}
110
111#[derive(Debug, Deserialize, Default)]
113pub struct TasksListQuery {
114 #[serde(default)]
117 pub limit: Option<usize>,
118}
119
120pub async fn tasks_list(
122 State(state): State<AppState>,
123 Query(q): Query<TasksListQuery>,
124) -> Result<Json<Vec<TaskRecord>>, ApiError> {
125 let mut records = state.task_store.list().await.map_err(ApiError::engine)?;
126 if let Some(limit) = q.limit {
127 records.truncate(limit);
128 }
129 Ok(Json(records))
130}
131
132#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
134pub struct TaskDetailResponse {
135 pub task: TaskRecord,
137 pub runs: Vec<RunRecord>,
139}
140
141pub async fn task_get(
144 State(state): State<AppState>,
145 Path(id): Path<String>,
146) -> Result<Json<TaskDetailResponse>, ApiError> {
147 let task_id =
148 TaskId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid task id: {e}")))?;
149 let task = state
150 .task_store
151 .get(&task_id)
152 .await
153 .map_err(map_task_store_err)?;
154 let runs = state
155 .run_store
156 .list_by_task(&task_id)
157 .await
158 .map_err(ApiError::engine)?;
159 Ok(Json(TaskDetailResponse { task, runs }))
160}
161
162#[derive(Debug, Deserialize, Default, schemars::JsonSchema)]
168pub struct RunKickRequest {
169 #[serde(default)]
178 #[schemars(with = "Option<Value>")]
179 pub init_ctx_override: Option<Value>,
180 #[serde(default)]
187 pub task_input_override: Option<TaskInputSpec>,
188}
189
190#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
192pub struct RunKickResponse {
193 #[schemars(with = "String")]
195 pub task_id: TaskId,
196 #[schemars(with = "String")]
198 pub run_id: RunId,
199}
200
201pub async fn task_rekick(
220 State(state): State<AppState>,
221 Path(id): Path<String>,
222 body: Option<Json<RunKickRequest>>,
223) -> Result<(StatusCode, Json<RunKickResponse>), ApiError> {
224 let task_id =
225 TaskId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid task id: {e}")))?;
226 let task = state
227 .task_store
228 .get(&task_id)
229 .await
230 .map_err(map_task_store_err)?;
231
232 let blueprint_ref: mlua_swarm::application::BlueprintRef =
233 serde_json::from_value(task.blueprint_ref.clone()).map_err(|e| {
234 ApiError::bad_request(format!(
235 "task {task_id}: stored blueprint_ref failed to decode: {e}"
236 ))
237 })?;
238
239 let (resolved_bp, _bound_version) = state
245 .task_app
246 .resolve(&blueprint_ref)
247 .await
248 .map_err(|e| ApiError::bad_request(format!("task {task_id}: bp resolve: {e}")))?;
249
250 let req = body.map(|Json(r)| r).unwrap_or_default();
251
252 let merged_init_ctx = merge_init_ctx_3layer(
253 resolved_bp.default_init_ctx.as_ref(),
254 &task.input_ctx,
255 req.init_ctx_override.as_ref(),
256 );
257
258 let task_input_spec: Option<TaskInputSpec> = match req.task_input_override {
262 Some(over) => Some(over),
263 None => task
264 .task_input_spec
265 .as_ref()
266 .map(|v| serde_json::from_value(v.clone()))
267 .transpose()
268 .map_err(|e| {
269 ApiError::bad_request(format!(
270 "task {task_id}: stored task_input_spec failed to decode: {e}"
271 ))
272 })?,
273 };
274
275 let run_id = RunId::new();
276 let now = now_secs();
277 state
278 .task_store
279 .update_status(&task_id, TaskRecordStatus::Running)
280 .await
281 .map_err(ApiError::engine)?;
282 state
283 .run_store
284 .create(RunRecord {
285 id: run_id.clone(),
286 task_id: task_id.clone(),
287 status: RunStatus::Running,
288 step_entries: Vec::new(),
289 operator_sid: None,
290 result_ref: None,
291 created_at: now,
292 updated_at: now,
293 })
294 .await
295 .map_err(ApiError::engine)?;
296
297 let input = TaskApplicationInput {
298 blueprint: blueprint_ref,
299 operator_id: "http-run".to_string(),
300 role: Role::Operator,
301 ttl: Duration::from_secs(crate::default_run_ttl()),
302 init_ctx: merged_init_ctx,
303 operator_kind: None,
304 bridge_id: None,
305 hook_id: None,
306 operator_backend_id: None,
307 operator_kind_overrides: HashMap::new(),
308 task_input: task_input_spec,
309 };
310 let run_ctx = RunContext {
311 run_id: run_id.clone(),
312 run_store: state.run_store.clone(),
313 };
314 let outcome = state.task_app.handle_with_run(input, Some(run_ctx)).await;
315 finalize_run(&state, &task_id, &run_id, outcome)
316 .await
317 .map_err(|e| ApiError::bad_request(format!("run: {e}")))?;
318
319 Ok((
320 StatusCode::CREATED,
321 Json(RunKickResponse { task_id, run_id }),
322 ))
323}
324
325pub async fn run_get(
328 State(state): State<AppState>,
329 Path(id): Path<String>,
330) -> Result<Json<RunRecord>, ApiError> {
331 let run_id =
332 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
333 let run = state
334 .run_store
335 .get(&run_id)
336 .await
337 .map_err(map_run_store_err)?;
338 Ok(Json(run))
339}
340
341pub(crate) fn map_task_store_err(e: TaskStoreError) -> ApiError {
345 match e {
346 TaskStoreError::NotFound(id) => ApiError::not_found(format!("task not found: {id}")),
347 other => ApiError::engine(other),
348 }
349}
350
351fn map_run_store_err(e: RunStoreError) -> ApiError {
352 match e {
353 RunStoreError::NotFound(id) => ApiError::not_found(format!("run not found: {id}")),
354 other => ApiError::engine(other),
355 }
356}
357
358#[cfg(test)]
363mod tests {
364 use super::*;
365 use mlua_swarm::application::BlueprintRef;
366 use mlua_swarm::blueprint::{
367 current_schema_version, AgentDef, AgentKind, Blueprint, BlueprintMetadata, CompilerHints,
368 CompilerStrategy,
369 };
370 use mlua_swarm::core::config::EngineCfg;
371 use mlua_swarm::core::engine::Engine;
372 use mlua_swarm::store::output::InMemoryOutputStore;
373 use mlua_swarm::store::run::InMemoryRunStore;
374 use mlua_swarm::store::task::InMemoryTaskStore;
375 use std::collections::HashMap;
376 use std::sync::Arc;
377 use tokio::sync::Mutex;
378
379 fn identity_blueprint() -> Blueprint {
385 Blueprint {
386 schema_version: current_schema_version(),
387 id: "tasks-test-bp".into(),
388 flow: serde_json::from_value(serde_json::json!({
389 "kind": "step",
390 "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
391 "in": {"op": "lit", "value": "hello"},
392 "out": {"op": "path", "at": "$.out"},
393 }))
394 .expect("flow parse"),
395 agents: vec![AgentDef {
396 name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
397 kind: AgentKind::RustFn,
398 spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
399 profile: None,
400 meta: None,
401 }],
402 operators: vec![],
403 metas: vec![],
404 hints: CompilerHints::default(),
405 strategy: CompilerStrategy::default(),
406 metadata: BlueprintMetadata::default(),
407 spawner_hints: Default::default(),
408 default_agent_kind: AgentKind::Operator,
409 default_operator_kind: None,
410 default_init_ctx: None,
411 default_agent_ctx: None,
412 default_context_policy: None,
413 }
414 }
415
416 fn test_state() -> AppState {
421 let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
422 let compiler = mlua_swarm::Compiler::new(crate::default_registry());
423 let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
424 AppState {
425 engine,
426 sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
427 task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
428 ws_operator_factory: None,
429 data_store: Arc::new(InMemoryOutputStore::new()),
430 operator_sessions: Arc::new(Mutex::new(HashMap::new())),
431 roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
432 task_store: Arc::new(InMemoryTaskStore::new()),
433 run_store: Arc::new(InMemoryRunStore::new()),
434 base_url: None,
435 }
436 }
437
438 fn post_tasks_req(goal: &str) -> crate::TaskLaunchRequest {
439 crate::TaskLaunchRequest {
440 blueprint: BlueprintRef::Inline {
441 value: Box::new(identity_blueprint()),
442 },
443 init_ctx: serde_json::json!({"in": "hello"}),
444 project_root: None,
445 work_dir: None,
446 task_metadata: None,
447 ttl_secs: None,
448 operator: None,
449 operator_sid: None,
450 goal: Some(goal.to_string()),
451 }
452 }
453
454 #[test]
455 fn task_id_serializes_as_bare_string() {
456 let v = serde_json::to_value(TaskId::parse("T-abc").unwrap()).expect("serialize");
460 assert_eq!(v, serde_json::json!("T-abc"));
461 }
462
463 #[tokio::test]
464 async fn post_then_get_drill_down() {
465 let state = test_state();
466
467 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("smoke goal")))
468 .await
469 .expect("tasks_start")
470 .0;
471 let task_id = posted.task_id.clone();
472 let run_id = posted.run_id.clone();
473
474 let list = tasks_list(State(state.clone()), Query(TasksListQuery { limit: None }))
476 .await
477 .expect("tasks_list")
478 .0;
479 assert!(
480 list.iter().any(|t| t.id == task_id),
481 "task {task_id} missing from list of {} tasks",
482 list.len()
483 );
484
485 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
487 .await
488 .expect("task_get")
489 .0;
490 assert_eq!(detail.task.id, task_id);
491 assert_eq!(detail.task.goal, "smoke goal");
492 assert_eq!(detail.task.status, TaskRecordStatus::Done);
493 assert_eq!(detail.runs.len(), 1);
494 assert_eq!(detail.runs[0].id, run_id);
495 assert_eq!(detail.runs[0].status, RunStatus::Done);
496
497 let run = run_get(State(state.clone()), Path(run_id.to_string()))
499 .await
500 .expect("run_get")
501 .0;
502 assert_eq!(run.id, run_id);
503 assert_eq!(run.task_id, task_id);
504 assert_eq!(run.result_ref, Some(posted.final_ctx));
505
506 assert_eq!(
510 run.step_entries.len(),
511 1,
512 "expected one step_entry for the 1-step identity Blueprint, got {:?}",
513 run.step_entries
514 );
515 assert_eq!(
516 run.step_entries[0].step_ref,
517 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
518 );
519 assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
520 }
521
522 #[tokio::test]
523 async fn rekick_adds_a_second_run_to_the_same_task() {
524 let state = test_state();
525 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("rekick goal")))
526 .await
527 .expect("tasks_start")
528 .0;
529 let task_id = posted.task_id.clone();
530 let first_run_id = posted.run_id.clone();
531
532 let (status, rekicked) = task_rekick(State(state.clone()), Path(task_id.to_string()), None)
533 .await
534 .expect("task_rekick");
535 assert_eq!(status, StatusCode::CREATED);
536 let second_run_id = rekicked.0.run_id.clone();
537 assert_ne!(first_run_id, second_run_id);
538
539 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
540 .await
541 .expect("task_get")
542 .0;
543 assert_eq!(
544 detail.runs.len(),
545 2,
546 "expected 2 runs, got {:?}",
547 detail.runs
548 );
549 let ids: Vec<&RunId> = detail.runs.iter().map(|r| &r.id).collect();
550 assert!(ids.contains(&&first_run_id));
551 assert!(ids.contains(&&second_run_id));
552
553 let first_run = detail
558 .runs
559 .iter()
560 .find(|r| r.id == first_run_id)
561 .expect("first run present in detail.runs");
562 let second_run = detail
563 .runs
564 .iter()
565 .find(|r| r.id == second_run_id)
566 .expect("second run present in detail.runs");
567 assert_eq!(
568 first_run.step_entries.len(),
569 1,
570 "first run step_entries: {:?}",
571 first_run.step_entries
572 );
573 assert_eq!(
574 second_run.step_entries.len(),
575 1,
576 "second run step_entries: {:?}",
577 second_run.step_entries
578 );
579 assert_eq!(
580 first_run.step_entries[0].step_ref,
581 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
582 );
583 assert_eq!(
584 second_run.step_entries[0].step_ref,
585 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
586 );
587 assert_eq!(first_run.step_entries[0].status, Some("passed".to_string()));
588 assert_eq!(
589 second_run.step_entries[0].status,
590 Some("passed".to_string())
591 );
592 assert_ne!(
593 first_run.step_entries[0].step_id, second_run.step_entries[0].step_id,
594 "each kick dispatches its own StepId — runs must not share step_entries"
595 );
596 }
597
598 #[tokio::test]
599 async fn rekick_unknown_task_returns_404() {
600 let state = test_state();
601 match task_rekick(State(state), Path("T-does-not-exist".to_string()), None).await {
605 Ok(_) => panic!("expected 404 for an unknown task"),
606 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
607 }
608 }
609
610 fn greeting_blueprint() -> Blueprint {
619 Blueprint {
620 schema_version: current_schema_version(),
621 id: "tasks-test-greeting-bp".into(),
622 flow: serde_json::from_value(serde_json::json!({
623 "kind": "step",
624 "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
625 "in": {"op": "path", "at": "$.greeting"},
626 "out": {"op": "path", "at": "$.out"},
627 }))
628 .expect("flow parse"),
629 agents: vec![AgentDef {
630 name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
631 kind: AgentKind::RustFn,
632 spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
633 profile: None,
634 meta: None,
635 }],
636 operators: vec![],
637 metas: vec![],
638 hints: CompilerHints::default(),
639 strategy: CompilerStrategy::default(),
640 metadata: BlueprintMetadata::default(),
641 spawner_hints: Default::default(),
642 default_agent_kind: AgentKind::Operator,
643 default_operator_kind: None,
644 default_init_ctx: None,
645 default_agent_ctx: None,
646 default_context_policy: None,
647 }
648 }
649
650 fn post_greeting_task_req(
651 greeting: &str,
652 project_root: Option<&str>,
653 ) -> crate::TaskLaunchRequest {
654 crate::TaskLaunchRequest {
655 blueprint: BlueprintRef::Inline {
656 value: Box::new(greeting_blueprint()),
657 },
658 init_ctx: serde_json::json!({ "greeting": greeting }),
659 project_root: project_root.map(str::to_string),
660 work_dir: None,
661 task_metadata: None,
662 ttl_secs: None,
663 operator: None,
664 operator_sid: None,
665 goal: Some("st4 rekick goal".to_string()),
666 }
667 }
668
669 #[tokio::test]
670 async fn rekick_no_body_preserves_stored_task_input_ctx_byte_for_byte() {
671 let state = test_state();
674 let posted = crate::tasks_start(
675 State(state.clone()),
676 Json(post_greeting_task_req("from-task", None)),
677 )
678 .await
679 .expect("tasks_start")
680 .0;
681 assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
682
683 let (status, rekicked) =
684 task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
685 .await
686 .expect("task_rekick");
687 assert_eq!(status, StatusCode::CREATED);
688
689 let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
690 .await
691 .expect("run_get")
692 .0;
693 assert_eq!(
694 run.result_ref.expect("result_ref present")["out"]["echoed"],
695 "from-task"
696 );
697 }
698
699 #[tokio::test]
700 async fn rekick_with_init_ctx_override_wins_over_stored_task_input_ctx() {
701 let state = test_state();
702 let posted = crate::tasks_start(
703 State(state.clone()),
704 Json(post_greeting_task_req("from-task", None)),
705 )
706 .await
707 .expect("tasks_start")
708 .0;
709 assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
710
711 let (status, rekicked) = task_rekick(
712 State(state.clone()),
713 Path(posted.task_id.to_string()),
714 Some(Json(RunKickRequest {
715 init_ctx_override: Some(serde_json::json!({ "greeting": "from-run" })),
716 task_input_override: None,
717 })),
718 )
719 .await
720 .expect("task_rekick");
721 assert_eq!(status, StatusCode::CREATED);
722
723 let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
724 .await
725 .expect("run_get")
726 .0;
727 assert_eq!(
728 run.result_ref.expect("result_ref present")["out"]["echoed"],
729 "from-run",
730 "Run's init_ctx_override must win over the stored Task input_ctx"
731 );
732 }
733
734 #[tokio::test]
735 async fn rekick_with_stored_task_input_spec_dispatches_and_leaves_it_unmutated() {
736 let state = test_state();
744 let posted = crate::tasks_start(
745 State(state.clone()),
746 Json(post_greeting_task_req("from-task", Some("/repo"))),
747 )
748 .await
749 .expect("tasks_start")
750 .0;
751
752 let before = state
753 .task_store
754 .get(&posted.task_id)
755 .await
756 .expect("task fetch");
757 let before_spec: Option<TaskInputSpec> = before
758 .task_input_spec
759 .as_ref()
760 .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
761 assert_eq!(
762 before_spec,
763 Some(TaskInputSpec {
764 project_root: Some("/repo".to_string()),
765 work_dir: None,
766 task_metadata: None,
767 })
768 );
769
770 let (status, _rekicked) =
771 task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
772 .await
773 .expect("task_rekick");
774 assert_eq!(status, StatusCode::CREATED);
775
776 let after = state
777 .task_store
778 .get(&posted.task_id)
779 .await
780 .expect("task fetch");
781 assert_eq!(
782 after.task_input_spec, before.task_input_spec,
783 "rekick must not mutate the stored Task-level task_input_spec snapshot"
784 );
785 }
786
787 #[tokio::test]
788 async fn rekick_with_task_input_override_does_not_mutate_stored_task_record() {
789 let state = test_state();
792 let posted = crate::tasks_start(
793 State(state.clone()),
794 Json(post_greeting_task_req("from-task", Some("/repo"))),
795 )
796 .await
797 .expect("tasks_start")
798 .0;
799
800 let (status, _rekicked) = task_rekick(
801 State(state.clone()),
802 Path(posted.task_id.to_string()),
803 Some(Json(RunKickRequest {
804 init_ctx_override: None,
805 task_input_override: Some(TaskInputSpec {
806 project_root: Some("/override".to_string()),
807 work_dir: None,
808 task_metadata: None,
809 }),
810 })),
811 )
812 .await
813 .expect("task_rekick");
814 assert_eq!(status, StatusCode::CREATED);
815
816 let after = state
817 .task_store
818 .get(&posted.task_id)
819 .await
820 .expect("task fetch");
821 let after_spec: Option<TaskInputSpec> = after
822 .task_input_spec
823 .as_ref()
824 .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
825 assert_eq!(
826 after_spec,
827 Some(TaskInputSpec {
828 project_root: Some("/repo".to_string()),
829 work_dir: None,
830 task_metadata: None,
831 }),
832 "a per-Run task_input_override must not leak into the stored TaskRecord"
833 );
834 }
835
836 #[tokio::test]
837 async fn run_get_unknown_id_returns_404() {
838 let state = test_state();
839 match run_get(State(state), Path("R-does-not-exist".to_string())).await {
840 Ok(_) => panic!("expected 404 for an unknown run"),
841 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
842 }
843 }
844
845 #[tokio::test]
846 async fn task_get_unknown_id_returns_404() {
847 let state = test_state();
848 match task_get(State(state), Path("T-does-not-exist".to_string())).await {
849 Ok(_) => panic!("expected 404 for an unknown task"),
850 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
851 }
852 }
853}