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 projection_placement: None,
414 }
415 }
416
417 fn test_state() -> AppState {
422 let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
423 let compiler = mlua_swarm::Compiler::new(crate::default_registry());
424 let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
425 AppState {
426 engine,
427 sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
428 task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
429 ws_operator_factory: None,
430 data_store: Arc::new(InMemoryOutputStore::new()),
431 operator_sessions: Arc::new(Mutex::new(HashMap::new())),
432 roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
433 task_store: Arc::new(InMemoryTaskStore::new()),
434 run_store: Arc::new(InMemoryRunStore::new()),
435 base_url: None,
436 }
437 }
438
439 fn post_tasks_req(goal: &str) -> crate::TaskLaunchRequest {
440 crate::TaskLaunchRequest {
441 blueprint: BlueprintRef::Inline {
442 value: Box::new(identity_blueprint()),
443 },
444 init_ctx: serde_json::json!({"in": "hello"}),
445 project_root: None,
446 work_dir: None,
447 task_metadata: None,
448 ttl_secs: None,
449 operator: None,
450 operator_sid: None,
451 goal: Some(goal.to_string()),
452 }
453 }
454
455 #[test]
456 fn task_id_serializes_as_bare_string() {
457 let v = serde_json::to_value(TaskId::parse("T-abc").unwrap()).expect("serialize");
461 assert_eq!(v, serde_json::json!("T-abc"));
462 }
463
464 #[tokio::test]
465 async fn post_then_get_drill_down() {
466 let state = test_state();
467
468 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("smoke goal")))
469 .await
470 .expect("tasks_start")
471 .0;
472 let task_id = posted.task_id.clone();
473 let run_id = posted.run_id.clone();
474
475 let list = tasks_list(State(state.clone()), Query(TasksListQuery { limit: None }))
477 .await
478 .expect("tasks_list")
479 .0;
480 assert!(
481 list.iter().any(|t| t.id == task_id),
482 "task {task_id} missing from list of {} tasks",
483 list.len()
484 );
485
486 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
488 .await
489 .expect("task_get")
490 .0;
491 assert_eq!(detail.task.id, task_id);
492 assert_eq!(detail.task.goal, "smoke goal");
493 assert_eq!(detail.task.status, TaskRecordStatus::Done);
494 assert_eq!(detail.runs.len(), 1);
495 assert_eq!(detail.runs[0].id, run_id);
496 assert_eq!(detail.runs[0].status, RunStatus::Done);
497
498 let run = run_get(State(state.clone()), Path(run_id.to_string()))
500 .await
501 .expect("run_get")
502 .0;
503 assert_eq!(run.id, run_id);
504 assert_eq!(run.task_id, task_id);
505 assert_eq!(run.result_ref, Some(posted.final_ctx));
506
507 assert_eq!(
511 run.step_entries.len(),
512 1,
513 "expected one step_entry for the 1-step identity Blueprint, got {:?}",
514 run.step_entries
515 );
516 assert_eq!(
517 run.step_entries[0].step_ref,
518 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
519 );
520 assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
521 }
522
523 #[tokio::test]
524 async fn rekick_adds_a_second_run_to_the_same_task() {
525 let state = test_state();
526 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("rekick goal")))
527 .await
528 .expect("tasks_start")
529 .0;
530 let task_id = posted.task_id.clone();
531 let first_run_id = posted.run_id.clone();
532
533 let (status, rekicked) = task_rekick(State(state.clone()), Path(task_id.to_string()), None)
534 .await
535 .expect("task_rekick");
536 assert_eq!(status, StatusCode::CREATED);
537 let second_run_id = rekicked.0.run_id.clone();
538 assert_ne!(first_run_id, second_run_id);
539
540 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
541 .await
542 .expect("task_get")
543 .0;
544 assert_eq!(
545 detail.runs.len(),
546 2,
547 "expected 2 runs, got {:?}",
548 detail.runs
549 );
550 let ids: Vec<&RunId> = detail.runs.iter().map(|r| &r.id).collect();
551 assert!(ids.contains(&&first_run_id));
552 assert!(ids.contains(&&second_run_id));
553
554 let first_run = detail
559 .runs
560 .iter()
561 .find(|r| r.id == first_run_id)
562 .expect("first run present in detail.runs");
563 let second_run = detail
564 .runs
565 .iter()
566 .find(|r| r.id == second_run_id)
567 .expect("second run present in detail.runs");
568 assert_eq!(
569 first_run.step_entries.len(),
570 1,
571 "first run step_entries: {:?}",
572 first_run.step_entries
573 );
574 assert_eq!(
575 second_run.step_entries.len(),
576 1,
577 "second run step_entries: {:?}",
578 second_run.step_entries
579 );
580 assert_eq!(
581 first_run.step_entries[0].step_ref,
582 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
583 );
584 assert_eq!(
585 second_run.step_entries[0].step_ref,
586 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
587 );
588 assert_eq!(first_run.step_entries[0].status, Some("passed".to_string()));
589 assert_eq!(
590 second_run.step_entries[0].status,
591 Some("passed".to_string())
592 );
593 assert_ne!(
594 first_run.step_entries[0].step_id, second_run.step_entries[0].step_id,
595 "each kick dispatches its own StepId — runs must not share step_entries"
596 );
597 }
598
599 #[tokio::test]
600 async fn rekick_unknown_task_returns_404() {
601 let state = test_state();
602 match task_rekick(State(state), Path("T-does-not-exist".to_string()), None).await {
606 Ok(_) => panic!("expected 404 for an unknown task"),
607 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
608 }
609 }
610
611 fn greeting_blueprint() -> Blueprint {
620 Blueprint {
621 schema_version: current_schema_version(),
622 id: "tasks-test-greeting-bp".into(),
623 flow: serde_json::from_value(serde_json::json!({
624 "kind": "step",
625 "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
626 "in": {"op": "path", "at": "$.greeting"},
627 "out": {"op": "path", "at": "$.out"},
628 }))
629 .expect("flow parse"),
630 agents: vec![AgentDef {
631 name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
632 kind: AgentKind::RustFn,
633 spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
634 profile: None,
635 meta: None,
636 }],
637 operators: vec![],
638 metas: vec![],
639 hints: CompilerHints::default(),
640 strategy: CompilerStrategy::default(),
641 metadata: BlueprintMetadata::default(),
642 spawner_hints: Default::default(),
643 default_agent_kind: AgentKind::Operator,
644 default_operator_kind: None,
645 default_init_ctx: None,
646 default_agent_ctx: None,
647 default_context_policy: None,
648 projection_placement: None,
649 }
650 }
651
652 fn post_greeting_task_req(
653 greeting: &str,
654 project_root: Option<&str>,
655 ) -> crate::TaskLaunchRequest {
656 crate::TaskLaunchRequest {
657 blueprint: BlueprintRef::Inline {
658 value: Box::new(greeting_blueprint()),
659 },
660 init_ctx: serde_json::json!({ "greeting": greeting }),
661 project_root: project_root.map(str::to_string),
662 work_dir: None,
663 task_metadata: None,
664 ttl_secs: None,
665 operator: None,
666 operator_sid: None,
667 goal: Some("st4 rekick goal".to_string()),
668 }
669 }
670
671 #[tokio::test]
672 async fn rekick_no_body_preserves_stored_task_input_ctx_byte_for_byte() {
673 let state = test_state();
676 let posted = crate::tasks_start(
677 State(state.clone()),
678 Json(post_greeting_task_req("from-task", None)),
679 )
680 .await
681 .expect("tasks_start")
682 .0;
683 assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
684
685 let (status, rekicked) =
686 task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
687 .await
688 .expect("task_rekick");
689 assert_eq!(status, StatusCode::CREATED);
690
691 let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
692 .await
693 .expect("run_get")
694 .0;
695 assert_eq!(
696 run.result_ref.expect("result_ref present")["out"]["echoed"],
697 "from-task"
698 );
699 }
700
701 #[tokio::test]
702 async fn rekick_with_init_ctx_override_wins_over_stored_task_input_ctx() {
703 let state = test_state();
704 let posted = crate::tasks_start(
705 State(state.clone()),
706 Json(post_greeting_task_req("from-task", None)),
707 )
708 .await
709 .expect("tasks_start")
710 .0;
711 assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
712
713 let (status, rekicked) = task_rekick(
714 State(state.clone()),
715 Path(posted.task_id.to_string()),
716 Some(Json(RunKickRequest {
717 init_ctx_override: Some(serde_json::json!({ "greeting": "from-run" })),
718 task_input_override: None,
719 })),
720 )
721 .await
722 .expect("task_rekick");
723 assert_eq!(status, StatusCode::CREATED);
724
725 let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
726 .await
727 .expect("run_get")
728 .0;
729 assert_eq!(
730 run.result_ref.expect("result_ref present")["out"]["echoed"],
731 "from-run",
732 "Run's init_ctx_override must win over the stored Task input_ctx"
733 );
734 }
735
736 #[tokio::test]
737 async fn rekick_with_stored_task_input_spec_dispatches_and_leaves_it_unmutated() {
738 let state = test_state();
746 let posted = crate::tasks_start(
747 State(state.clone()),
748 Json(post_greeting_task_req("from-task", Some("/repo"))),
749 )
750 .await
751 .expect("tasks_start")
752 .0;
753
754 let before = state
755 .task_store
756 .get(&posted.task_id)
757 .await
758 .expect("task fetch");
759 let before_spec: Option<TaskInputSpec> = before
760 .task_input_spec
761 .as_ref()
762 .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
763 assert_eq!(
764 before_spec,
765 Some(TaskInputSpec {
766 project_root: Some("/repo".to_string()),
767 work_dir: None,
768 task_metadata: None,
769 })
770 );
771
772 let (status, _rekicked) =
773 task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
774 .await
775 .expect("task_rekick");
776 assert_eq!(status, StatusCode::CREATED);
777
778 let after = state
779 .task_store
780 .get(&posted.task_id)
781 .await
782 .expect("task fetch");
783 assert_eq!(
784 after.task_input_spec, before.task_input_spec,
785 "rekick must not mutate the stored Task-level task_input_spec snapshot"
786 );
787 }
788
789 #[tokio::test]
790 async fn rekick_with_task_input_override_does_not_mutate_stored_task_record() {
791 let state = test_state();
794 let posted = crate::tasks_start(
795 State(state.clone()),
796 Json(post_greeting_task_req("from-task", Some("/repo"))),
797 )
798 .await
799 .expect("tasks_start")
800 .0;
801
802 let (status, _rekicked) = task_rekick(
803 State(state.clone()),
804 Path(posted.task_id.to_string()),
805 Some(Json(RunKickRequest {
806 init_ctx_override: None,
807 task_input_override: Some(TaskInputSpec {
808 project_root: Some("/override".to_string()),
809 work_dir: None,
810 task_metadata: None,
811 }),
812 })),
813 )
814 .await
815 .expect("task_rekick");
816 assert_eq!(status, StatusCode::CREATED);
817
818 let after = state
819 .task_store
820 .get(&posted.task_id)
821 .await
822 .expect("task fetch");
823 let after_spec: Option<TaskInputSpec> = after
824 .task_input_spec
825 .as_ref()
826 .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
827 assert_eq!(
828 after_spec,
829 Some(TaskInputSpec {
830 project_root: Some("/repo".to_string()),
831 work_dir: None,
832 task_metadata: None,
833 }),
834 "a per-Run task_input_override must not leak into the stored TaskRecord"
835 );
836 }
837
838 #[tokio::test]
839 async fn run_get_unknown_id_returns_404() {
840 let state = test_state();
841 match run_get(State(state), Path("R-does-not-exist".to_string())).await {
842 Ok(_) => panic!("expected 404 for an unknown run"),
843 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
844 }
845 }
846
847 #[tokio::test]
848 async fn task_get_unknown_id_returns_404() {
849 let state = test_state();
850 match task_get(State(state), Path("T-does-not-exist".to_string())).await {
851 Ok(_) => panic!("expected 404 for an unknown task"),
852 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
853 }
854 }
855}