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