1use axum::{
36 extract::{Path, Query, State},
37 http::StatusCode,
38 Json,
39};
40use mlua_swarm::application::{
41 BlueprintRef, TaskApplicationError, TaskApplicationInput, TaskApplicationOutput,
42};
43use mlua_swarm::core::config::CheckPolicy;
44use mlua_swarm::service::merge_init_ctx_3layer;
45use mlua_swarm::store::replay::ReplayCursor;
46use mlua_swarm::store::run::{RunContext, RunRecord, RunStatus, RunStoreError};
47use mlua_swarm::store::task::{TaskRecord, TaskRecordStatus, TaskStoreError};
48use mlua_swarm::{OperatorKind, Role, RunId, TaskId, TaskInputSpec};
49use serde::{Deserialize, Serialize};
50use serde_json::Value;
51use std::collections::HashMap;
52use std::sync::{Arc, Mutex};
53use std::time::Duration;
54
55use crate::{ApiError, AppState};
56
57pub(crate) fn now_secs() -> u64 {
61 std::time::SystemTime::now()
62 .duration_since(std::time::UNIX_EPOCH)
63 .map(|d| d.as_secs())
64 .unwrap_or(0)
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
81pub(crate) struct RunLaunchSnapshot {
82 blueprint: BlueprintRef,
83 operator_id: String,
84 role: Role,
85 ttl: Duration,
86 init_ctx: Value,
87 operator_kind: Option<OperatorKind>,
88 bridge_id: Option<String>,
89 hook_id: Option<String>,
90 operator_backend_id: Option<String>,
91 #[serde(default)]
92 operator_kind_overrides: HashMap<String, OperatorKind>,
93 task_input: Option<TaskInputSpec>,
94 check_policy: Option<CheckPolicy>,
95}
96
97impl RunLaunchSnapshot {
98 fn from_input(input: &TaskApplicationInput) -> Self {
101 Self {
102 blueprint: input.blueprint.clone(),
103 operator_id: input.operator_id.clone(),
104 role: input.role,
105 ttl: input.ttl,
106 init_ctx: input.init_ctx.clone(),
107 operator_kind: input.operator_kind,
108 bridge_id: input.bridge_id.clone(),
109 hook_id: input.hook_id.clone(),
110 operator_backend_id: input.operator_backend_id.clone(),
111 operator_kind_overrides: input.operator_kind_overrides.clone(),
112 task_input: input.task_input.clone(),
113 check_policy: input.check_policy,
114 }
115 }
116
117 fn into_input(self) -> TaskApplicationInput {
119 TaskApplicationInput {
120 blueprint: self.blueprint,
121 operator_id: self.operator_id,
122 role: self.role,
123 ttl: self.ttl,
124 init_ctx: self.init_ctx,
125 operator_kind: self.operator_kind,
126 bridge_id: self.bridge_id,
127 hook_id: self.hook_id,
128 operator_backend_id: self.operator_backend_id,
129 operator_kind_overrides: self.operator_kind_overrides,
130 task_input: self.task_input,
131 check_policy: self.check_policy,
132 }
133 }
134}
135
136pub(crate) fn snapshot_launch_input(input: &TaskApplicationInput) -> Result<String, ApiError> {
143 serde_json::to_string(&RunLaunchSnapshot::from_input(input))
144 .map_err(|e| ApiError::bad_request(format!("launch input snapshot: {e}")))
145}
146
147pub(crate) async fn finalize_run(
157 state: &AppState,
158 task_id: &TaskId,
159 run_id: &RunId,
160 outcome: Result<TaskApplicationOutput, TaskApplicationError>,
161) -> Result<TaskApplicationOutput, TaskApplicationError> {
162 match &outcome {
163 Ok(out) => {
164 if let Err(e) = state
165 .run_store
166 .set_result(run_id, out.final_ctx.clone())
167 .await
168 {
169 tracing::warn!(%run_id, error = %e, "finalize_run: set_result failed");
170 }
171 if let Err(e) = state.run_store.update_status(run_id, RunStatus::Done).await {
172 tracing::warn!(%run_id, error = %e, "finalize_run: run update_status(Done) failed");
173 }
174 if let Err(e) = state
175 .task_store
176 .update_status(task_id, TaskRecordStatus::Done)
177 .await
178 {
179 tracing::warn!(%task_id, error = %e, "finalize_run: task update_status(Done) failed");
180 }
181 }
182 Err(e) => {
183 if let Err(store_err) = state
184 .run_store
185 .update_status(run_id, RunStatus::Failed)
186 .await
187 {
188 tracing::warn!(%run_id, error = %store_err, "finalize_run: run update_status(Failed) failed");
189 }
190 if let Err(store_err) = state
191 .task_store
192 .update_status(task_id, TaskRecordStatus::Failed)
193 .await
194 {
195 tracing::warn!(%task_id, error = %store_err, "finalize_run: task update_status(Failed) failed");
196 }
197 tracing::warn!(%task_id, %run_id, error = %e, "finalize_run: dispatch failed");
198 }
199 }
200 outcome
201}
202
203#[derive(Debug, Deserialize, Default)]
205pub struct TasksListQuery {
206 #[serde(default)]
209 pub limit: Option<usize>,
210}
211
212pub async fn tasks_list(
214 State(state): State<AppState>,
215 Query(q): Query<TasksListQuery>,
216) -> Result<Json<Vec<TaskRecord>>, ApiError> {
217 let mut records = state.task_store.list().await.map_err(ApiError::engine)?;
218 if let Some(limit) = q.limit {
219 records.truncate(limit);
220 }
221 Ok(Json(records))
222}
223
224#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
226pub struct TaskDetailResponse {
227 pub task: TaskRecord,
229 pub runs: Vec<RunRecord>,
231}
232
233pub async fn task_get(
236 State(state): State<AppState>,
237 Path(id): Path<String>,
238) -> Result<Json<TaskDetailResponse>, ApiError> {
239 let task_id =
240 TaskId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid task id: {e}")))?;
241 let task = state
242 .task_store
243 .get(&task_id)
244 .await
245 .map_err(map_task_store_err)?;
246 let runs = state
247 .run_store
248 .list_by_task(&task_id)
249 .await
250 .map_err(ApiError::engine)?;
251 Ok(Json(TaskDetailResponse { task, runs }))
252}
253
254#[derive(Debug, Deserialize, Default, schemars::JsonSchema)]
260pub struct RunKickRequest {
261 #[serde(default)]
270 #[schemars(with = "Option<Value>")]
271 pub init_ctx_override: Option<Value>,
272 #[serde(default)]
279 pub task_input_override: Option<TaskInputSpec>,
280 #[serde(default)]
286 pub timeout_secs: Option<u64>,
287 #[serde(default)]
294 pub detach: bool,
295}
296
297#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
299pub struct RunKickResponse {
300 #[schemars(with = "String")]
302 pub task_id: TaskId,
303 #[schemars(with = "String")]
305 pub run_id: RunId,
306 pub status: RunStatus,
311}
312
313pub async fn task_rekick(
340 State(state): State<AppState>,
341 Path(id): Path<String>,
342 body: Option<Json<RunKickRequest>>,
343) -> Result<(StatusCode, Json<RunKickResponse>), ApiError> {
344 let task_id =
345 TaskId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid task id: {e}")))?;
346 let task = state
347 .task_store
348 .get(&task_id)
349 .await
350 .map_err(map_task_store_err)?;
351
352 let blueprint_ref: mlua_swarm::application::BlueprintRef =
353 serde_json::from_value(task.blueprint_ref.clone()).map_err(|e| {
354 ApiError::bad_request(format!(
355 "task {task_id}: stored blueprint_ref failed to decode: {e}"
356 ))
357 })?;
358
359 let (resolved_bp, _bound_version) = state
365 .task_app
366 .resolve(&blueprint_ref)
367 .await
368 .map_err(|e| ApiError::bad_request(format!("task {task_id}: bp resolve: {e}")))?;
369
370 let req = body.map(|Json(r)| r).unwrap_or_default();
371
372 let detach = req.detach;
382 let sync_timeout_secs = match (detach, req.timeout_secs) {
383 (true, Some(_)) => {
384 return Err(ApiError::bad_request(
385 "timeout_secs is the synchronous rekick ceiling and does not apply to a \
386 detached rekick (detach: true), whose lifetime bound is the run TTL — omit \
387 timeout_secs"
388 .into(),
389 ));
390 }
391 (false, Some(0)) => {
392 return Err(ApiError::bad_request(
393 "timeout_secs: 0 is invalid; omit the field to use the server default".into(),
394 ));
395 }
396 (false, Some(v)) => v,
397 (_, None) => state.sync_timeout_secs,
398 };
399
400 if resolved_bp
412 .spawner_hints
413 .layers
414 .iter()
415 .any(|l| l == "operator_delegate")
416 {
417 let attached = state.engine.list_operator_ids().await;
418 if attached.is_empty() {
419 return Err(ApiError::unavailable(format!(
420 "no operator attached to serve this rekick (task {task_id}'s \
421 Blueprint declares the operator_delegate layer): attach an \
422 operator via POST /v1/operators + WS, or use the poll-style \
423 flow (GET /v1/worker/prompt + POST /v1/worker/submit)"
424 )));
425 }
426 }
427
428 let merged_init_ctx = merge_init_ctx_3layer(
429 resolved_bp.default_init_ctx.as_ref(),
430 &task.input_ctx,
431 req.init_ctx_override.as_ref(),
432 );
433
434 let task_input_spec: Option<TaskInputSpec> = match req.task_input_override {
438 Some(over) => Some(over),
439 None => task
440 .task_input_spec
441 .as_ref()
442 .map(|v| serde_json::from_value(v.clone()))
443 .transpose()
444 .map_err(|e| {
445 ApiError::bad_request(format!(
446 "task {task_id}: stored task_input_spec failed to decode: {e}"
447 ))
448 })?,
449 };
450
451 let run_id = RunId::new();
452 let now = now_secs();
453
454 let input = TaskApplicationInput {
455 blueprint: blueprint_ref,
456 operator_id: "http-run".to_string(),
457 role: Role::Operator,
458 ttl: Duration::from_secs(crate::default_run_ttl()),
459 init_ctx: merged_init_ctx,
460 operator_kind: None,
461 bridge_id: None,
462 hook_id: None,
463 operator_backend_id: None,
464 operator_kind_overrides: HashMap::new(),
465 task_input: task_input_spec,
466 check_policy: None,
470 };
471 let input_json = Some(snapshot_launch_input(&input)?);
476
477 state
478 .task_store
479 .update_status(&task_id, TaskRecordStatus::Running)
480 .await
481 .map_err(ApiError::engine)?;
482 state
483 .run_store
484 .create(RunRecord {
485 id: run_id.clone(),
486 task_id: task_id.clone(),
487 status: RunStatus::Running,
488 step_entries: Vec::new(),
489 degradations: Vec::new(),
490 operator_sid: None,
491 result_ref: None,
492 input_json,
493 created_at: now,
494 updated_at: now,
495 })
496 .await
497 .map_err(ApiError::engine)?;
498
499 let run_ctx = RunContext::new(run_id.clone(), state.run_store.clone())
500 .with_replay_store(state.replay_store.clone());
501
502 if detach {
508 let ttl_secs = crate::default_run_ttl();
509 let bg_state = state.clone();
510 let bg_task_id = task_id.clone();
511 let bg_run_id = run_id.clone();
512 tokio::spawn(async move {
513 let outcome = match tokio::time::timeout(
514 Duration::from_secs(ttl_secs),
515 bg_state.task_app.handle_with_run(input, Some(run_ctx)),
516 )
517 .await
518 {
519 Ok(outcome) => outcome,
520 Err(_elapsed) => {
521 let reason = serde_json::json!({
522 "error": format!("detached rekick exceeded {ttl_secs}s ttl ceiling"),
523 });
524 if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
525 tracing::warn!(%bg_run_id, error = %e, "task_rekick: detached ttl set_result failed");
526 }
527 if let Err(e) = bg_state
528 .run_store
529 .update_status(&bg_run_id, RunStatus::Failed)
530 .await
531 {
532 tracing::warn!(%bg_run_id, error = %e, "task_rekick: detached ttl run update_status failed");
533 }
534 if let Err(e) = bg_state
535 .task_store
536 .update_status(&bg_task_id, TaskRecordStatus::Failed)
537 .await
538 {
539 tracing::warn!(%bg_task_id, error = %e, "task_rekick: detached ttl task update_status failed");
540 }
541 return;
542 }
543 };
544 let _ = finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
547 });
548 return Ok((
549 StatusCode::ACCEPTED,
550 Json(RunKickResponse {
551 task_id,
552 run_id,
553 status: RunStatus::Running,
554 }),
555 ));
556 }
557
558 let outcome = match tokio::time::timeout(
564 Duration::from_secs(sync_timeout_secs),
565 state.task_app.handle_with_run(input, Some(run_ctx)),
566 )
567 .await
568 {
569 Ok(outcome) => outcome,
570 Err(_elapsed) => {
571 let reason = serde_json::json!({
572 "error": format!("sync rekick exceeded {sync_timeout_secs}s timeout ceiling")
573 });
574 if let Err(e) = state.run_store.set_result(&run_id, reason).await {
575 tracing::warn!(%run_id, error = %e, "task_rekick: timeout set_result failed");
576 }
577 if let Err(e) = state
578 .run_store
579 .update_status(&run_id, RunStatus::Failed)
580 .await
581 {
582 tracing::warn!(%run_id, error = %e, "task_rekick: timeout run update_status failed");
583 }
584 if let Err(e) = state
585 .task_store
586 .update_status(&task_id, TaskRecordStatus::Failed)
587 .await
588 {
589 tracing::warn!(%task_id, error = %e, "task_rekick: timeout task update_status failed");
590 }
591 return Err(ApiError::timeout(format!(
592 "sync rekick exceeded {sync_timeout_secs}s timeout ceiling: task {task_id}, run {run_id}"
593 )));
594 }
595 };
596 finalize_run(&state, &task_id, &run_id, outcome)
597 .await
598 .map_err(|e| ApiError::bad_request(format!("run: {e}")))?;
599
600 Ok((
601 StatusCode::CREATED,
602 Json(RunKickResponse {
603 task_id,
604 run_id,
605 status: RunStatus::Done,
606 }),
607 ))
608}
609
610#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
612pub struct RunResumeResponse {
613 #[schemars(with = "String")]
618 pub run_id: RunId,
619 #[schemars(with = "String")]
621 pub task_id: TaskId,
622 pub replayed_steps: usize,
627}
628
629pub async fn run_resume(
655 State(state): State<AppState>,
656 Path(id): Path<String>,
657) -> Result<(StatusCode, Json<RunResumeResponse>), ApiError> {
658 let run_id =
659 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
660
661 let run = state
663 .run_store
664 .get(&run_id)
665 .await
666 .map_err(map_run_store_err)?;
667
668 if run.status != RunStatus::Interrupted {
670 return Err(ApiError::conflict(format!(
671 "run {run_id} is {:?}, not Interrupted; only an interrupted run can be resumed",
672 run.status
673 )));
674 }
675
676 let Some(input_json) = run.input_json.clone() else {
681 return Err(ApiError::unprocessable(format!(
682 "run {run_id} cannot be resumed: no launch input was recorded for it (it \
683 predates resume support, or was created by a path that does not persist one)"
684 )));
685 };
686 let snapshot: RunLaunchSnapshot = serde_json::from_str(&input_json).map_err(|e| {
687 ApiError::bad_request(format!(
688 "run {run_id}: stored launch input failed to decode: {e}"
689 ))
690 })?;
691
692 let won = state
696 .run_store
697 .try_transition(&run_id, RunStatus::Interrupted, RunStatus::Running)
698 .await
699 .map_err(ApiError::engine)?;
700 if !won {
701 return Err(ApiError::conflict(format!(
702 "run {run_id} was concurrently resumed (or left the Interrupted state); it is \
703 no longer resumable"
704 )));
705 }
706
707 let entries = state
711 .replay_store
712 .list_by_run(&run_id)
713 .await
714 .map_err(|e| ApiError::engine(format!("replay list_by_run: {e}")))?;
715 let replayed_steps = entries.len();
716 let cursor = ReplayCursor::from_entries(entries);
717
718 let run_ctx = RunContext::new(run_id.clone(), state.run_store.clone())
721 .with_replay_store(state.replay_store.clone())
722 .with_replay_cursor(Arc::new(Mutex::new(cursor)));
723
724 let input = snapshot.into_input();
725 let task_id = run.task_id.clone();
726
727 state
730 .task_store
731 .update_status(&task_id, TaskRecordStatus::Running)
732 .await
733 .map_err(ApiError::engine)?;
734
735 let ttl_secs = crate::default_run_ttl();
739 let bg_state = state.clone();
740 let bg_task_id = task_id.clone();
741 let bg_run_id = run_id.clone();
742 tokio::spawn(async move {
743 let outcome = match tokio::time::timeout(
744 Duration::from_secs(ttl_secs),
745 bg_state.task_app.handle_with_run(input, Some(run_ctx)),
746 )
747 .await
748 {
749 Ok(outcome) => outcome,
750 Err(_elapsed) => {
751 let reason = serde_json::json!({
752 "error": format!("resumed run exceeded {ttl_secs}s ttl ceiling"),
753 });
754 if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
755 tracing::warn!(%bg_run_id, error = %e, "run_resume: ttl set_result failed");
756 }
757 if let Err(e) = bg_state
758 .run_store
759 .update_status(&bg_run_id, RunStatus::Failed)
760 .await
761 {
762 tracing::warn!(%bg_run_id, error = %e, "run_resume: ttl run update_status failed");
763 }
764 if let Err(e) = bg_state
765 .task_store
766 .update_status(&bg_task_id, TaskRecordStatus::Failed)
767 .await
768 {
769 tracing::warn!(%bg_task_id, error = %e, "run_resume: ttl task update_status failed");
770 }
771 return;
772 }
773 };
774 let _ = finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
776 });
777
778 Ok((
779 StatusCode::ACCEPTED,
780 Json(RunResumeResponse {
781 run_id,
782 task_id,
783 replayed_steps,
784 }),
785 ))
786}
787
788#[derive(Debug, Deserialize, schemars::JsonSchema)]
790pub struct RunRerunFromRequest {
791 pub from_step: String,
798}
799
800#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
802pub struct RunRerunFromResponse {
803 #[schemars(with = "String")]
808 pub run_id: RunId,
809 #[schemars(with = "String")]
811 pub task_id: TaskId,
812 pub replayed_steps: usize,
816 pub dropped_steps: usize,
819}
820
821pub async fn run_rerun_from(
882 State(state): State<AppState>,
883 Path(id): Path<String>,
884 Json(req): Json<RunRerunFromRequest>,
885) -> Result<(StatusCode, Json<RunRerunFromResponse>), ApiError> {
886 let run_id =
887 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
888
889 if req.from_step.trim().is_empty() {
890 return Err(ApiError::bad_request(
891 "from_step must be a non-empty step ref".to_string(),
892 ));
893 }
894
895 let run = state
897 .run_store
898 .get(&run_id)
899 .await
900 .map_err(map_run_store_err)?;
901
902 let current = run.status;
905 match current {
906 RunStatus::Done | RunStatus::Failed | RunStatus::Interrupted => { }
907 RunStatus::Running | RunStatus::Pending => {
908 return Err(ApiError::conflict(format!(
909 "run {run_id} is {current:?}; rerun-from requires a terminal run \
910 (Done / Failed / Interrupted)"
911 )));
912 }
913 }
914
915 let Some(input_json) = run.input_json.clone() else {
920 return Err(ApiError::unprocessable(format!(
921 "run {run_id} cannot be rerun: no launch input was recorded for it (it \
922 predates resume/rerun support, or was created by a path that does not \
923 persist one)"
924 )));
925 };
926 let snapshot: RunLaunchSnapshot = serde_json::from_str(&input_json).map_err(|e| {
927 ApiError::bad_request(format!(
928 "run {run_id}: stored launch input failed to decode: {e}"
929 ))
930 })?;
931
932 let entries = state
935 .replay_store
936 .list_by_run(&run_id)
937 .await
938 .map_err(|e| ApiError::engine(format!("replay list_by_run: {e}")))?;
939 let cut = entries
940 .iter()
941 .position(|e| e.step_ref == req.from_step)
942 .ok_or_else(|| {
943 ApiError::unprocessable(format!(
944 "run {run_id}: from_step {:?} not present in this run's replay log \
945 (nothing to rerun-from)",
946 req.from_step
947 ))
948 })?;
949
950 let won = state
955 .run_store
956 .try_transition(&run_id, current, RunStatus::Running)
957 .await
958 .map_err(ApiError::engine)?;
959 if !won {
960 return Err(ApiError::conflict(format!(
961 "run {run_id} was concurrently transitioned (or left the {current:?} state); \
962 it is no longer rerunnable"
963 )));
964 }
965
966 let dropped_steps = state
971 .replay_store
972 .delete_from(&run_id, cut)
973 .await
974 .map_err(|e| ApiError::engine(format!("replay delete_from: {e}")))?;
975
976 let kept = entries.into_iter().take(cut).collect::<Vec<_>>();
979 let replayed_steps = kept.len();
980 let cursor = ReplayCursor::from_entries(kept);
981
982 let run_ctx = RunContext::new(run_id.clone(), state.run_store.clone())
983 .with_replay_store(state.replay_store.clone())
984 .with_replay_cursor(Arc::new(Mutex::new(cursor)));
985
986 let input = snapshot.into_input();
987 let task_id = run.task_id.clone();
988
989 state
992 .task_store
993 .update_status(&task_id, TaskRecordStatus::Running)
994 .await
995 .map_err(ApiError::engine)?;
996
997 let ttl_secs = crate::default_run_ttl();
998 let bg_state = state.clone();
999 let bg_task_id = task_id.clone();
1000 let bg_run_id = run_id.clone();
1001 tokio::spawn(async move {
1002 let outcome = match tokio::time::timeout(
1003 Duration::from_secs(ttl_secs),
1004 bg_state.task_app.handle_with_run(input, Some(run_ctx)),
1005 )
1006 .await
1007 {
1008 Ok(outcome) => outcome,
1009 Err(_elapsed) => {
1010 let reason = serde_json::json!({
1011 "error": format!("rerun-from run exceeded {ttl_secs}s ttl ceiling"),
1012 });
1013 if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
1014 tracing::warn!(%bg_run_id, error = %e, "run_rerun_from: ttl set_result failed");
1015 }
1016 if let Err(e) = bg_state
1017 .run_store
1018 .update_status(&bg_run_id, RunStatus::Failed)
1019 .await
1020 {
1021 tracing::warn!(%bg_run_id, error = %e, "run_rerun_from: ttl run update_status failed");
1022 }
1023 if let Err(e) = bg_state
1024 .task_store
1025 .update_status(&bg_task_id, TaskRecordStatus::Failed)
1026 .await
1027 {
1028 tracing::warn!(%bg_task_id, error = %e, "run_rerun_from: ttl task update_status failed");
1029 }
1030 return;
1031 }
1032 };
1033 let _ = finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
1034 });
1035
1036 Ok((
1037 StatusCode::ACCEPTED,
1038 Json(RunRerunFromResponse {
1039 run_id,
1040 task_id,
1041 replayed_steps,
1042 dropped_steps,
1043 }),
1044 ))
1045}
1046
1047pub async fn run_get(
1050 State(state): State<AppState>,
1051 Path(id): Path<String>,
1052) -> Result<Json<RunRecord>, ApiError> {
1053 let run_id =
1054 RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
1055 let run = state
1056 .run_store
1057 .get(&run_id)
1058 .await
1059 .map_err(map_run_store_err)?;
1060 Ok(Json(run))
1061}
1062
1063pub(crate) fn map_task_store_err(e: TaskStoreError) -> ApiError {
1067 match e {
1068 TaskStoreError::NotFound(id) => ApiError::not_found(format!("task not found: {id}")),
1069 other => ApiError::engine(other),
1070 }
1071}
1072
1073fn map_run_store_err(e: RunStoreError) -> ApiError {
1074 match e {
1075 RunStoreError::NotFound(id) => ApiError::not_found(format!("run not found: {id}")),
1076 other => ApiError::engine(other),
1077 }
1078}
1079
1080#[cfg(test)]
1085mod tests {
1086 use super::*;
1087 use mlua_swarm::application::BlueprintRef;
1088 use mlua_swarm::blueprint::{
1089 current_schema_version, AgentDef, AgentKind, Blueprint, BlueprintMetadata, CompilerHints,
1090 CompilerStrategy,
1091 };
1092 use mlua_swarm::core::config::EngineCfg;
1093 use mlua_swarm::core::engine::Engine;
1094 use mlua_swarm::store::output::InMemoryOutputStore;
1095 use mlua_swarm::store::run::InMemoryRunStore;
1096 use mlua_swarm::store::task::InMemoryTaskStore;
1097 use std::collections::HashMap;
1098 use std::sync::Arc;
1099 use tokio::sync::Mutex;
1100
1101 fn identity_blueprint() -> Blueprint {
1107 Blueprint {
1108 schema_version: current_schema_version(),
1109 id: "tasks-test-bp".into(),
1110 flow: serde_json::from_value(serde_json::json!({
1111 "kind": "step",
1112 "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
1113 "in": {"op": "lit", "value": "hello"},
1114 "out": {"op": "path", "at": "$.out"},
1115 }))
1116 .expect("flow parse"),
1117 agents: vec![AgentDef {
1118 name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
1119 kind: AgentKind::RustFn,
1120 spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
1121 profile: None,
1122 meta: None,
1123 runner: None,
1124 runner_ref: None,
1125 verdict: None,
1126 }],
1127 operators: vec![],
1128 metas: vec![],
1129 hints: CompilerHints::default(),
1130 strategy: CompilerStrategy::default(),
1131 metadata: BlueprintMetadata::default(),
1132 spawner_hints: Default::default(),
1133 default_agent_kind: AgentKind::Operator,
1134 default_operator_kind: None,
1135 default_init_ctx: None,
1136 default_agent_ctx: None,
1137 default_context_policy: None,
1138 projection_placement: None,
1139 audits: vec![],
1140 degradation_policy: None,
1141 runners: vec![],
1142 default_runner: None,
1143 check_policy: None,
1144 blueprint_ref_includes: Vec::new(),
1145 }
1146 }
1147
1148 fn test_state() -> AppState {
1153 let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
1154 let compiler = mlua_swarm::Compiler::new(crate::default_registry());
1155 let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
1156 AppState {
1157 engine,
1158 sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
1159 task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
1160 ws_operator_factory: None,
1161 data_store: Arc::new(InMemoryOutputStore::new()),
1162 operator_sessions: Arc::new(Mutex::new(HashMap::new())),
1163 roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
1164 task_store: Arc::new(InMemoryTaskStore::new()),
1165 run_store: Arc::new(InMemoryRunStore::new()),
1166 replay_store: Arc::new(mlua_swarm::store::replay::InMemoryReplayStore::new()),
1167 base_url: None,
1168 sync_timeout_secs: 300,
1169 }
1170 }
1171
1172 fn post_tasks_req(goal: &str) -> crate::TaskLaunchRequest {
1173 crate::TaskLaunchRequest {
1174 blueprint: BlueprintRef::Inline {
1175 value: Box::new(identity_blueprint()),
1176 },
1177 init_ctx: serde_json::json!({"in": "hello"}),
1178 project_root: None,
1179 work_dir: None,
1180 task_metadata: None,
1181 ttl_secs: None,
1182 operator: None,
1183 operator_sid: None,
1184 timeout_secs: None,
1185 goal: Some(goal.to_string()),
1186 detach: false,
1187 check_policy: None,
1188 }
1189 }
1190
1191 #[test]
1192 fn task_id_serializes_as_bare_string() {
1193 let v = serde_json::to_value(TaskId::parse("T-abc").unwrap()).expect("serialize");
1197 assert_eq!(v, serde_json::json!("T-abc"));
1198 }
1199
1200 #[tokio::test]
1201 async fn post_then_get_drill_down() {
1202 let state = test_state();
1203
1204 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("smoke goal")))
1205 .await
1206 .expect("tasks_start")
1207 .0;
1208 let task_id = posted.task_id.clone();
1209 let run_id = posted.run_id.clone();
1210
1211 let list = tasks_list(State(state.clone()), Query(TasksListQuery { limit: None }))
1213 .await
1214 .expect("tasks_list")
1215 .0;
1216 assert!(
1217 list.iter().any(|t| t.id == task_id),
1218 "task {task_id} missing from list of {} tasks",
1219 list.len()
1220 );
1221
1222 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
1224 .await
1225 .expect("task_get")
1226 .0;
1227 assert_eq!(detail.task.id, task_id);
1228 assert_eq!(detail.task.goal, "smoke goal");
1229 assert_eq!(detail.task.status, TaskRecordStatus::Done);
1230 assert_eq!(detail.runs.len(), 1);
1231 assert_eq!(detail.runs[0].id, run_id);
1232 assert_eq!(detail.runs[0].status, RunStatus::Done);
1233
1234 let run = run_get(State(state.clone()), Path(run_id.to_string()))
1236 .await
1237 .expect("run_get")
1238 .0;
1239 assert_eq!(run.id, run_id);
1240 assert_eq!(run.task_id, task_id);
1241 assert_eq!(run.result_ref, Some(posted.final_ctx));
1242
1243 assert_eq!(
1247 run.step_entries.len(),
1248 1,
1249 "expected one step_entry for the 1-step identity Blueprint, got {:?}",
1250 run.step_entries
1251 );
1252 assert_eq!(
1253 run.step_entries[0].step_ref,
1254 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1255 );
1256 assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
1257 }
1258
1259 fn identity_blueprint_with_operator_delegate() -> Blueprint {
1271 Blueprint {
1272 spawner_hints: mlua_swarm::SpawnerHints {
1273 layers: vec!["operator_delegate".to_string()],
1274 },
1275 ..identity_blueprint()
1276 }
1277 }
1278
1279 struct StallingOperator;
1282
1283 #[async_trait::async_trait]
1284 impl mlua_swarm::Operator for StallingOperator {
1285 async fn execute(
1286 &self,
1287 _ctx: &mlua_swarm::Ctx,
1288 _system: Option<String>,
1289 _prompt: Value,
1290 _worker: Option<mlua_swarm::WorkerBinding>,
1291 _worker_token: mlua_swarm::CapToken,
1292 ) -> Result<mlua_swarm::WorkerResult, mlua_swarm::WorkerError> {
1293 std::future::pending::<()>().await;
1294 unreachable!("StallingOperator.execute must never resolve")
1295 }
1296 }
1297
1298 fn operator_launch_req(
1302 backend_id: &str,
1303 timeout_secs: Option<u64>,
1304 ) -> crate::TaskLaunchRequest {
1305 crate::TaskLaunchRequest {
1306 blueprint: BlueprintRef::Inline {
1307 value: Box::new(identity_blueprint_with_operator_delegate()),
1308 },
1309 init_ctx: serde_json::json!({"in": "hello"}),
1310 project_root: None,
1311 work_dir: None,
1312 task_metadata: None,
1313 ttl_secs: None,
1314 operator: Some(crate::OperatorReq {
1315 operator_backend_id: Some(backend_id.to_string()),
1316 ..Default::default()
1317 }),
1318 operator_sid: None,
1319 timeout_secs,
1320 goal: Some("operator delegate test goal".to_string()),
1321 detach: false,
1322 check_policy: None,
1323 }
1324 }
1325
1326 #[tokio::test]
1330 async fn sync_launch_zero_operators_fails_fast() {
1331 let state = test_state();
1332 let req = operator_launch_req("nonexistent-op", None);
1335
1336 let started = std::time::Instant::now();
1337 let result = crate::tasks_start(State(state), Json(req)).await;
1338 let elapsed = started.elapsed();
1339
1340 let err = match result {
1341 Err(e) => e,
1342 Ok(_) => panic!("zero attached operators must fail the operator-delegate launch"),
1343 };
1344 assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
1345 assert!(
1346 err.message.contains("no operator attached"),
1347 "error message must mention the missing operator: {}",
1348 err.message
1349 );
1350 assert!(
1351 elapsed < Duration::from_secs(1),
1352 "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
1353 );
1354 }
1355
1356 #[tokio::test]
1360 async fn sync_launch_stalled_times_out() {
1361 let state = test_state();
1362 state
1363 .engine
1364 .register_operator("stall-op", Arc::new(StallingOperator))
1365 .await;
1366 let req = operator_launch_req("stall-op", Some(1));
1367
1368 let started = std::time::Instant::now();
1369 let result = tokio::time::timeout(
1373 Duration::from_secs(5),
1374 crate::tasks_start(State(state), Json(req)),
1375 )
1376 .await
1377 .expect("tasks_start must resolve well within 5s when guard 2's ceiling is 1s");
1378 let elapsed = started.elapsed();
1379
1380 let err = match result {
1381 Err(e) => e,
1382 Ok(_) => panic!("a stalled operator session must time out, not succeed"),
1383 };
1384 assert_eq!(err.status, StatusCode::GATEWAY_TIMEOUT);
1385 assert!(
1386 err.message.contains('1'),
1387 "error message must mention the configured 1s ceiling: {}",
1388 err.message
1389 );
1390 assert!(
1391 elapsed < Duration::from_secs(3),
1392 "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
1393 );
1394 }
1395
1396 #[tokio::test]
1400 async fn sync_launch_without_operator_path_unaffected() {
1401 let state = test_state();
1402 let result = crate::tasks_start(
1403 State(state),
1404 Json(post_tasks_req("non-operator launch goal")),
1405 )
1406 .await;
1407 if let Err(e) = &result {
1408 panic!(
1409 "non-operator launch must succeed unaffected by guard 1: {}",
1410 e.message
1411 );
1412 }
1413 }
1414
1415 #[tokio::test]
1419 async fn sync_launch_zero_timeout_secs_rejected() {
1420 let state = test_state();
1421 let mut req = post_tasks_req("zero timeout goal");
1422 req.timeout_secs = Some(0);
1423
1424 let result = crate::tasks_start(State(state), Json(req)).await;
1425 let err = match result {
1426 Err(e) => e,
1427 Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
1428 };
1429 assert_eq!(err.status, StatusCode::BAD_REQUEST);
1430 assert!(
1431 err.message.contains("timeout_secs"),
1432 "error message must reference timeout_secs: {}",
1433 err.message
1434 );
1435 }
1436
1437 async fn wait_for_terminal_run(state: &AppState, run_id: &RunId) -> RunRecord {
1446 for _ in 0..50 {
1447 let rec = state.run_store.get(run_id).await.expect("run get");
1448 if !matches!(rec.status, RunStatus::Pending | RunStatus::Running) {
1449 return rec;
1450 }
1451 tokio::time::sleep(Duration::from_millis(100)).await;
1452 }
1453 panic!("run {run_id} did not reach a terminal status within ~5s");
1454 }
1455
1456 #[tokio::test]
1462 async fn detached_launch_returns_202_and_completes_in_background() {
1463 let state = test_state();
1464 let mut req = post_tasks_req("detached goal");
1465 req.detach = true;
1466
1467 let reply = crate::tasks_start(State(state.clone()), Json(req))
1468 .await
1469 .expect("tasks_start (detached)");
1470 assert_eq!(reply.1, StatusCode::ACCEPTED);
1471 let posted = reply.0;
1472 assert_eq!(posted.status, RunStatus::Running);
1473 assert_eq!(
1474 posted.final_ctx,
1475 serde_json::Value::Null,
1476 "a detached launch has no final_ctx at response time"
1477 );
1478
1479 let rec = wait_for_terminal_run(&state, &posted.run_id).await;
1480 assert_eq!(rec.status, RunStatus::Done);
1481 assert!(
1482 rec.result_ref.is_some(),
1483 "finalize_run must persist the background eval's final_ctx"
1484 );
1485 assert_eq!(
1486 rec.step_entries.len(),
1487 1,
1488 "the background eval must trace its step_entries like the sync path: {:?}",
1489 rec.step_entries
1490 );
1491 let task = state
1492 .task_store
1493 .get(&posted.task_id)
1494 .await
1495 .expect("task get");
1496 assert_eq!(task.status, TaskRecordStatus::Done);
1497 }
1498
1499 #[tokio::test]
1503 async fn detached_launch_with_timeout_secs_rejected() {
1504 let state = test_state();
1505 let mut req = post_tasks_req("detached + ceiling goal");
1506 req.detach = true;
1507 req.timeout_secs = Some(60);
1508
1509 let err = match crate::tasks_start(State(state.clone()), Json(req)).await {
1510 Err(e) => e,
1511 Ok(_) => panic!("detach + timeout_secs must be rejected"),
1512 };
1513 assert_eq!(err.status, StatusCode::BAD_REQUEST);
1514 assert!(
1515 err.message.contains("detach"),
1516 "error message must explain the detach/timeout_secs conflict: {}",
1517 err.message
1518 );
1519 let tasks = state.task_store.list().await.expect("task list");
1520 assert!(
1521 tasks.is_empty(),
1522 "the 400 must fire before any TaskRecord is minted"
1523 );
1524 }
1525
1526 #[tokio::test]
1530 async fn rekick_detached_returns_202_and_completes_in_background() {
1531 let state = test_state();
1532 let posted = crate::tasks_start(
1533 State(state.clone()),
1534 Json(post_tasks_req("detached rekick goal")),
1535 )
1536 .await
1537 .expect("tasks_start")
1538 .0;
1539
1540 let (status, rekicked) = task_rekick(
1541 State(state.clone()),
1542 Path(posted.task_id.to_string()),
1543 Some(Json(RunKickRequest {
1544 init_ctx_override: None,
1545 task_input_override: None,
1546 timeout_secs: None,
1547 detach: true,
1548 })),
1549 )
1550 .await
1551 .expect("task_rekick (detached)");
1552 assert_eq!(status, StatusCode::ACCEPTED);
1553 assert_eq!(rekicked.0.status, RunStatus::Running);
1554 assert_ne!(rekicked.0.run_id, posted.run_id);
1555
1556 let rec = wait_for_terminal_run(&state, &rekicked.0.run_id).await;
1557 assert_eq!(rec.status, RunStatus::Done);
1558 assert!(
1559 rec.result_ref.is_some(),
1560 "finalize_run must persist the background rekick's final_ctx"
1561 );
1562 }
1563
1564 #[tokio::test]
1568 async fn rekick_detached_with_timeout_secs_rejected() {
1569 let state = test_state();
1570 let posted = crate::tasks_start(
1571 State(state.clone()),
1572 Json(post_tasks_req("detached rekick ceiling goal")),
1573 )
1574 .await
1575 .expect("tasks_start")
1576 .0;
1577
1578 let err = match task_rekick(
1579 State(state.clone()),
1580 Path(posted.task_id.to_string()),
1581 Some(Json(RunKickRequest {
1582 init_ctx_override: None,
1583 task_input_override: None,
1584 timeout_secs: Some(60),
1585 detach: true,
1586 })),
1587 )
1588 .await
1589 {
1590 Err(e) => e,
1591 Ok(_) => panic!("detach + timeout_secs must be rejected on rekick"),
1592 };
1593 assert_eq!(err.status, StatusCode::BAD_REQUEST);
1594 assert!(
1595 err.message.contains("detach"),
1596 "error message must explain the detach/timeout_secs conflict: {}",
1597 err.message
1598 );
1599 let runs = state
1600 .run_store
1601 .list_by_task(&posted.task_id)
1602 .await
1603 .expect("runs list");
1604 assert_eq!(
1605 runs.len(),
1606 1,
1607 "the 400 must fire before a second Run is minted"
1608 );
1609 }
1610
1611 #[tokio::test]
1612 async fn rekick_adds_a_second_run_to_the_same_task() {
1613 let state = test_state();
1614 let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("rekick goal")))
1615 .await
1616 .expect("tasks_start")
1617 .0;
1618 let task_id = posted.task_id.clone();
1619 let first_run_id = posted.run_id.clone();
1620
1621 let (status, rekicked) = task_rekick(State(state.clone()), Path(task_id.to_string()), None)
1622 .await
1623 .expect("task_rekick");
1624 assert_eq!(status, StatusCode::CREATED);
1625 let second_run_id = rekicked.0.run_id.clone();
1626 assert_ne!(first_run_id, second_run_id);
1627
1628 let detail = task_get(State(state.clone()), Path(task_id.to_string()))
1629 .await
1630 .expect("task_get")
1631 .0;
1632 assert_eq!(
1633 detail.runs.len(),
1634 2,
1635 "expected 2 runs, got {:?}",
1636 detail.runs
1637 );
1638 let ids: Vec<&RunId> = detail.runs.iter().map(|r| &r.id).collect();
1639 assert!(ids.contains(&&first_run_id));
1640 assert!(ids.contains(&&second_run_id));
1641
1642 let first_run = detail
1647 .runs
1648 .iter()
1649 .find(|r| r.id == first_run_id)
1650 .expect("first run present in detail.runs");
1651 let second_run = detail
1652 .runs
1653 .iter()
1654 .find(|r| r.id == second_run_id)
1655 .expect("second run present in detail.runs");
1656 assert_eq!(
1657 first_run.step_entries.len(),
1658 1,
1659 "first run step_entries: {:?}",
1660 first_run.step_entries
1661 );
1662 assert_eq!(
1663 second_run.step_entries.len(),
1664 1,
1665 "second run step_entries: {:?}",
1666 second_run.step_entries
1667 );
1668 assert_eq!(
1669 first_run.step_entries[0].step_ref,
1670 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1671 );
1672 assert_eq!(
1673 second_run.step_entries[0].step_ref,
1674 Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
1675 );
1676 assert_eq!(first_run.step_entries[0].status, Some("passed".to_string()));
1677 assert_eq!(
1678 second_run.step_entries[0].status,
1679 Some("passed".to_string())
1680 );
1681 assert_ne!(
1682 first_run.step_entries[0].step_id, second_run.step_entries[0].step_id,
1683 "each kick dispatches its own StepId — runs must not share step_entries"
1684 );
1685 }
1686
1687 #[tokio::test]
1688 async fn rekick_unknown_task_returns_404() {
1689 let state = test_state();
1690 match task_rekick(State(state), Path("T-does-not-exist".to_string()), None).await {
1694 Ok(_) => panic!("expected 404 for an unknown task"),
1695 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
1696 }
1697 }
1698
1699 fn greeting_blueprint() -> Blueprint {
1708 Blueprint {
1709 schema_version: current_schema_version(),
1710 id: "tasks-test-greeting-bp".into(),
1711 flow: serde_json::from_value(serde_json::json!({
1712 "kind": "step",
1713 "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
1714 "in": {"op": "path", "at": "$.greeting"},
1715 "out": {"op": "path", "at": "$.out"},
1716 }))
1717 .expect("flow parse"),
1718 agents: vec![AgentDef {
1719 name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
1720 kind: AgentKind::RustFn,
1721 spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
1722 profile: None,
1723 meta: None,
1724 runner: None,
1725 runner_ref: None,
1726 verdict: None,
1727 }],
1728 operators: vec![],
1729 metas: vec![],
1730 hints: CompilerHints::default(),
1731 strategy: CompilerStrategy::default(),
1732 metadata: BlueprintMetadata::default(),
1733 spawner_hints: Default::default(),
1734 default_agent_kind: AgentKind::Operator,
1735 default_operator_kind: None,
1736 default_init_ctx: None,
1737 default_agent_ctx: None,
1738 default_context_policy: None,
1739 projection_placement: None,
1740 audits: vec![],
1741 degradation_policy: None,
1742 runners: vec![],
1743 default_runner: None,
1744 check_policy: None,
1745 blueprint_ref_includes: Vec::new(),
1746 }
1747 }
1748
1749 fn post_greeting_task_req(
1750 greeting: &str,
1751 project_root: Option<&str>,
1752 ) -> crate::TaskLaunchRequest {
1753 crate::TaskLaunchRequest {
1754 blueprint: BlueprintRef::Inline {
1755 value: Box::new(greeting_blueprint()),
1756 },
1757 init_ctx: serde_json::json!({ "greeting": greeting }),
1758 project_root: project_root.map(str::to_string),
1759 work_dir: None,
1760 task_metadata: None,
1761 ttl_secs: None,
1762 operator: None,
1763 operator_sid: None,
1764 timeout_secs: None,
1765 goal: Some("st4 rekick goal".to_string()),
1766 detach: false,
1767 check_policy: None,
1768 }
1769 }
1770
1771 #[tokio::test]
1772 async fn rekick_no_body_preserves_stored_task_input_ctx_byte_for_byte() {
1773 let state = test_state();
1776 let posted = crate::tasks_start(
1777 State(state.clone()),
1778 Json(post_greeting_task_req("from-task", None)),
1779 )
1780 .await
1781 .expect("tasks_start")
1782 .0;
1783 assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
1784
1785 let (status, rekicked) =
1786 task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
1787 .await
1788 .expect("task_rekick");
1789 assert_eq!(status, StatusCode::CREATED);
1790
1791 let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
1792 .await
1793 .expect("run_get")
1794 .0;
1795 assert_eq!(
1796 run.result_ref.expect("result_ref present")["out"]["echoed"],
1797 "from-task"
1798 );
1799 }
1800
1801 #[tokio::test]
1802 async fn rekick_with_init_ctx_override_wins_over_stored_task_input_ctx() {
1803 let state = test_state();
1804 let posted = crate::tasks_start(
1805 State(state.clone()),
1806 Json(post_greeting_task_req("from-task", None)),
1807 )
1808 .await
1809 .expect("tasks_start")
1810 .0;
1811 assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
1812
1813 let (status, rekicked) = task_rekick(
1814 State(state.clone()),
1815 Path(posted.task_id.to_string()),
1816 Some(Json(RunKickRequest {
1817 init_ctx_override: Some(serde_json::json!({ "greeting": "from-run" })),
1818 task_input_override: None,
1819 timeout_secs: None,
1820 detach: false,
1821 })),
1822 )
1823 .await
1824 .expect("task_rekick");
1825 assert_eq!(status, StatusCode::CREATED);
1826
1827 let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
1828 .await
1829 .expect("run_get")
1830 .0;
1831 assert_eq!(
1832 run.result_ref.expect("result_ref present")["out"]["echoed"],
1833 "from-run",
1834 "Run's init_ctx_override must win over the stored Task input_ctx"
1835 );
1836 }
1837
1838 #[tokio::test]
1839 async fn rekick_with_stored_task_input_spec_dispatches_and_leaves_it_unmutated() {
1840 let state = test_state();
1848 let posted = crate::tasks_start(
1849 State(state.clone()),
1850 Json(post_greeting_task_req("from-task", Some("/repo"))),
1851 )
1852 .await
1853 .expect("tasks_start")
1854 .0;
1855
1856 let before = state
1857 .task_store
1858 .get(&posted.task_id)
1859 .await
1860 .expect("task fetch");
1861 let before_spec: Option<TaskInputSpec> = before
1862 .task_input_spec
1863 .as_ref()
1864 .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
1865 assert_eq!(
1866 before_spec,
1867 Some(TaskInputSpec {
1868 project_root: Some("/repo".to_string()),
1869 work_dir: None,
1870 task_metadata: None,
1871 })
1872 );
1873
1874 let (status, _rekicked) =
1875 task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
1876 .await
1877 .expect("task_rekick");
1878 assert_eq!(status, StatusCode::CREATED);
1879
1880 let after = state
1881 .task_store
1882 .get(&posted.task_id)
1883 .await
1884 .expect("task fetch");
1885 assert_eq!(
1886 after.task_input_spec, before.task_input_spec,
1887 "rekick must not mutate the stored Task-level task_input_spec snapshot"
1888 );
1889 }
1890
1891 #[tokio::test]
1892 async fn rekick_with_task_input_override_does_not_mutate_stored_task_record() {
1893 let state = test_state();
1896 let posted = crate::tasks_start(
1897 State(state.clone()),
1898 Json(post_greeting_task_req("from-task", Some("/repo"))),
1899 )
1900 .await
1901 .expect("tasks_start")
1902 .0;
1903
1904 let (status, _rekicked) = task_rekick(
1905 State(state.clone()),
1906 Path(posted.task_id.to_string()),
1907 Some(Json(RunKickRequest {
1908 init_ctx_override: None,
1909 task_input_override: Some(TaskInputSpec {
1910 project_root: Some("/override".to_string()),
1911 work_dir: None,
1912 task_metadata: None,
1913 }),
1914 timeout_secs: None,
1915 detach: false,
1916 })),
1917 )
1918 .await
1919 .expect("task_rekick");
1920 assert_eq!(status, StatusCode::CREATED);
1921
1922 let after = state
1923 .task_store
1924 .get(&posted.task_id)
1925 .await
1926 .expect("task fetch");
1927 let after_spec: Option<TaskInputSpec> = after
1928 .task_input_spec
1929 .as_ref()
1930 .map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
1931 assert_eq!(
1932 after_spec,
1933 Some(TaskInputSpec {
1934 project_root: Some("/repo".to_string()),
1935 work_dir: None,
1936 task_metadata: None,
1937 }),
1938 "a per-Run task_input_override must not leak into the stored TaskRecord"
1939 );
1940 }
1941
1942 fn delegate_launch_req(goal: &str) -> crate::TaskLaunchRequest {
1956 crate::TaskLaunchRequest {
1957 blueprint: BlueprintRef::Inline {
1958 value: Box::new(identity_blueprint_with_operator_delegate()),
1959 },
1960 init_ctx: serde_json::json!({"in": "hello"}),
1961 project_root: None,
1962 work_dir: None,
1963 task_metadata: None,
1964 ttl_secs: None,
1965 operator: None,
1966 operator_sid: None,
1967 timeout_secs: None,
1968 goal: Some(goal.to_string()),
1969 detach: false,
1970 check_policy: None,
1971 }
1972 }
1973
1974 #[tokio::test]
1979 async fn rekick_zero_operators_with_operator_delegate_blueprint_fails_fast() {
1980 let state = test_state();
1981 let posted = crate::tasks_start(
1982 State(state.clone()),
1983 Json(delegate_launch_req("operator delegate rekick goal")),
1984 )
1985 .await
1986 .expect("tasks_start (no operator referenced, dispatches through baseline)")
1987 .0;
1988 let started = std::time::Instant::now();
1992 let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
1993 let elapsed = started.elapsed();
1994
1995 let err = match result {
1996 Err(e) => e,
1997 Ok(_) => panic!(
1998 "rekicking a Task whose Blueprint declares operator_delegate with zero \
1999 attached operators must fail, not dispatch"
2000 ),
2001 };
2002 assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
2003 assert!(
2004 err.message.contains("no operator attached"),
2005 "error message must mention the missing operator: {}",
2006 err.message
2007 );
2008 assert!(
2009 elapsed < Duration::from_secs(1),
2010 "guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
2011 );
2012 }
2013
2014 #[tokio::test]
2018 async fn rekick_stalled_operator_times_out() {
2019 let state = test_state();
2020 state
2021 .engine
2022 .register_operator("stall-op", Arc::new(StallingOperator))
2023 .await;
2024 let posted = crate::tasks_start(
2025 State(state.clone()),
2026 Json(delegate_launch_req("stalled rekick goal")),
2027 )
2028 .await
2029 .expect("tasks_start")
2030 .0;
2031
2032 let started = std::time::Instant::now();
2033 let result = tokio::time::timeout(
2037 Duration::from_secs(5),
2038 task_rekick(
2039 State(state),
2040 Path(posted.task_id.to_string()),
2041 Some(Json(RunKickRequest {
2042 init_ctx_override: None,
2043 task_input_override: None,
2044 timeout_secs: Some(1),
2045 detach: false,
2046 })),
2047 ),
2048 )
2049 .await
2050 .expect("task_rekick must resolve well within 5s when guard 2's ceiling is 1s");
2051 let elapsed = started.elapsed();
2052
2053 match &result {
2054 Err(e) => {
2055 assert_eq!(e.status, StatusCode::GATEWAY_TIMEOUT);
2056 assert!(
2057 e.message.contains('1'),
2058 "error message must mention the configured 1s ceiling: {}",
2059 e.message
2060 );
2061 assert!(
2062 elapsed < Duration::from_secs(3),
2063 "guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
2064 );
2065 }
2066 Ok(_) => {
2067 assert!(
2079 elapsed < Duration::from_secs(1),
2080 "a rekick that never engages an Operator (task_rekick has no \
2081 per-request operator override) must resolve fast, not stall: took {elapsed:?}"
2082 );
2083 }
2084 }
2085 }
2086
2087 #[tokio::test]
2091 async fn rekick_timeout_secs_zero_rejected() {
2092 let state = test_state();
2093 let posted = crate::tasks_start(
2094 State(state.clone()),
2095 Json(post_tasks_req("zero timeout rekick goal")),
2096 )
2097 .await
2098 .expect("tasks_start")
2099 .0;
2100
2101 let before = task_get(State(state.clone()), Path(posted.task_id.to_string()))
2102 .await
2103 .expect("task_get")
2104 .0;
2105 let runs_before = before.runs.len();
2106
2107 let result = task_rekick(
2108 State(state.clone()),
2109 Path(posted.task_id.to_string()),
2110 Some(Json(RunKickRequest {
2111 init_ctx_override: None,
2112 task_input_override: None,
2113 timeout_secs: Some(0),
2114 detach: false,
2115 })),
2116 )
2117 .await;
2118 let err = match result {
2119 Err(e) => e,
2120 Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
2121 };
2122 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2123 assert!(
2124 err.message.contains("timeout_secs"),
2125 "error message must reference timeout_secs: {}",
2126 err.message
2127 );
2128
2129 let after = task_get(State(state), Path(posted.task_id.to_string()))
2130 .await
2131 .expect("task_get")
2132 .0;
2133 assert_eq!(
2134 after.runs.len(),
2135 runs_before,
2136 "a rejected timeout_secs: Some(0) rekick must not create a new Run"
2137 );
2138 }
2139
2140 #[tokio::test]
2144 async fn rekick_non_operator_path_unaffected_by_guard_1() {
2145 let state = test_state();
2146 let posted = crate::tasks_start(
2147 State(state.clone()),
2148 Json(post_tasks_req("non-operator rekick goal")),
2149 )
2150 .await
2151 .expect("tasks_start")
2152 .0;
2153
2154 let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
2155 if let Err(e) = &result {
2156 panic!(
2157 "a plain (non-operator_delegate) Task rekick must succeed unaffected by \
2158 guard 1: {}",
2159 e.message
2160 );
2161 }
2162 }
2163
2164 #[tokio::test]
2165 async fn run_get_unknown_id_returns_404() {
2166 let state = test_state();
2167 match run_get(State(state), Path("R-does-not-exist".to_string())).await {
2168 Ok(_) => panic!("expected 404 for an unknown run"),
2169 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
2170 }
2171 }
2172
2173 #[tokio::test]
2174 async fn task_get_unknown_id_returns_404() {
2175 let state = test_state();
2176 match task_get(State(state), Path("T-does-not-exist".to_string())).await {
2177 Ok(_) => panic!("expected 404 for an unknown task"),
2178 Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
2179 }
2180 }
2181}