1use axum::{
71 extract::{Query, State},
72 http::{header, header::AUTHORIZATION, HeaderMap, StatusCode},
73 Json,
74};
75use mlua_swarm::core::agent_context::StepPointer;
76use mlua_swarm::core::state::SubmitOutcome;
77use mlua_swarm::core::step_naming::StepNaming;
78use mlua_swarm::store::run::{DegradationEntry, RunStatus, RunStoreError};
79use mlua_swarm::{CapToken, ContentRef, EngineError, OutputEvent, RunId, StepId, WorkerPayload};
80use mlua_swarm_schema::{ContextPolicy, VerdictChannel};
81use serde::Deserialize;
82use serde_json::Value;
83
84use crate::projection::McpQueryAdapter;
85use crate::{ApiError, AppState};
86
87#[derive(Debug, Deserialize)]
89pub struct PromptQuery {
90 pub task_id: StepId,
94}
95
96pub async fn worker_prompt(
102 State(state): State<AppState>,
103 headers: HeaderMap,
104 Query(q): Query<PromptQuery>,
105) -> Result<Json<WorkerPayload>, ApiError> {
106 let task_id = q.task_id;
107 let bearer = extract_bearer_raw(&headers)?;
108 let mut payload = if let Some(handle) = parse_worker_handle(&bearer) {
109 let resolved = state
111 .engine
112 .task_id_from_handle(handle)
113 .await
114 .map_err(map_handle_lookup_err)?;
115 if resolved != task_id {
116 return Err(ApiError::bad_request(format!(
117 "handle {handle} is bound to task {resolved}, not {task_id}"
118 )));
119 }
120 state
121 .engine
122 .fetch_worker_payload_trusted(&task_id)
123 .await
124 .map_err(|e| ApiError::engine(format!("fetch_worker_payload_trusted: {e}")))?
125 } else {
126 let token = CapToken::decode(bearer.trim())
128 .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
129 state
130 .engine
131 .fetch_worker_payload(&token, &task_id)
132 .await
133 .map_err(|e| ApiError::engine(format!("fetch_worker_payload: {e}")))?
134 };
135 assemble_step_pointers(&state, &mut payload).await;
136 Ok(Json(payload))
137}
138
139async fn assemble_step_pointers(state: &AppState, payload: &mut WorkerPayload) {
167 let Some(context) = payload.context.as_mut() else {
168 return;
169 };
170 let Some(run_id_str) = context.run_id.clone() else {
171 return;
172 };
173 let Ok(run_id) = RunId::parse(run_id_str) else {
174 return;
175 };
176
177 let adapter = McpQueryAdapter::new(
178 state.data_store.clone(),
179 state.run_store.clone(),
180 state.engine.clone(),
181 );
182 let Ok((run, resolved_steps)) = adapter.list_steps_by_run_id(&run_id).await else {
183 return;
184 };
185
186 let naming = state.engine.step_naming_for(&payload.task_id).await;
187 let policy = state
188 .engine
189 .context_policy_for(&payload.task_id, payload.attempt)
190 .await;
191 let self_canonical = naming
192 .as_deref()
193 .and_then(|n| n.canonical_of_producer(&payload.agent))
194 .map(str::to_string)
195 .unwrap_or_else(|| payload.agent.clone());
196
197 let mut pointers = Vec::new();
198 for step in &resolved_steps {
199 if step.name == self_canonical
200 || !allows_step_canonical(&policy, naming.as_deref(), &step.name)
201 {
202 continue;
203 }
204 if let Some((size_bytes, file_path, content_url, sha256)) =
205 crate::projection::resolve_step_pointer_fields(state, &run, step).await
206 {
207 pointers.push(StepPointer {
208 name: step.name.clone(),
209 size_bytes,
210 file_path,
211 content_url,
212 sha256,
213 });
214 }
215 }
216 context.steps = pointers;
217}
218
219fn allows_step_canonical(
234 policy: &ContextPolicy,
235 naming: Option<&StepNaming>,
236 canonical_name: &str,
237) -> bool {
238 let resolves_to = |raw: &str| -> bool {
239 match naming {
240 Some(n) => n
241 .resolve(raw)
242 .map(|c| c == canonical_name)
243 .unwrap_or(raw == canonical_name),
244 None => raw == canonical_name,
245 }
246 };
247 if policy
248 .steps_exclude
249 .iter()
250 .any(|excluded| resolves_to(excluded))
251 {
252 return false;
253 }
254 match &policy.steps {
255 None => true,
256 Some(list) => list.iter().any(|included| resolves_to(included)),
257 }
258}
259
260#[derive(Debug, Deserialize)]
262pub struct WorkerResultReq {
263 pub task_id: StepId,
266 pub value: Value,
268 #[serde(default = "default_ok_true")]
272 pub ok: bool,
273 #[serde(default)]
276 pub attempt: Option<u32>,
277}
278
279fn default_ok_true() -> bool {
280 true
281}
282
283pub async fn worker_result(
286 State(state): State<AppState>,
287 headers: HeaderMap,
288 Json(req): Json<WorkerResultReq>,
289) -> Result<StatusCode, ApiError> {
290 let token = decode_worker_bearer(&headers)?;
291 let task_id = req.task_id.clone();
292
293 let attempt = match req.attempt {
295 Some(n) => n,
296 None => state
297 .engine
298 .task_attempt(&task_id)
299 .await
300 .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?,
301 };
302
303 let event = OutputEvent::Final {
304 content: ContentRef::Inline {
305 value: req.value.clone(),
306 },
307 ok: req.ok,
308 };
309 map_completion_result(
314 state
315 .engine
316 .submit_output(&token, &task_id, attempt, event)
317 .await,
318 "submit_output",
319 )?;
320 state
321 .engine
322 .post_result(&token, &task_id, req.value)
323 .await
324 .map_err(|e| ApiError::engine(format!("post_result: {e}")))?;
325 Ok(StatusCode::NO_CONTENT)
326}
327
328const FILE_SENTINEL_PREFIX: &str = "@file:";
336
337const FILE_SENTINEL_MAX_BYTES: u64 = 2 * 1024 * 1024;
343
344const FILE_SENTINEL_ALLOW_KEY: &str = "allow_file_submit";
356
357const SUBMIT_FORMAT_KEY: &str = "submit_format";
368
369const SUBMIT_FORMAT_JSON: &str = "json";
375
376const SUBMIT_FORMAT_PREVIEW_CHARS: usize = 80;
380
381async fn resolve_file_sentinel(
414 state: &AppState,
415 task_id: &StepId,
416 attempt: u32,
417 body_str: String,
418) -> Result<String, ApiError> {
419 let Some(rest) = body_str.strip_prefix(FILE_SENTINEL_PREFIX) else {
420 return Ok(body_str);
421 };
422 let path_str = rest.trim();
423 if path_str.is_empty() {
424 return Err(ApiError::bad_request(
425 "@file: sentinel: empty path".to_string(),
426 ));
427 }
428 if path_str.contains('\n') || path_str.contains('\r') {
429 return Err(ApiError::bad_request(
430 "@file: sentinel: path must be a single line".to_string(),
431 ));
432 }
433 let path = std::path::Path::new(path_str);
434 if !path.is_absolute() {
435 return Err(ApiError::bad_request(format!(
436 "@file: sentinel: path must be absolute (got {path_str:?})"
437 )));
438 }
439 let view = state
440 .engine
441 .agent_context_for(task_id, attempt)
442 .await
443 .ok_or_else(|| {
444 ApiError::bad_request(
445 "@file: sentinel: no AgentContextView for this task/attempt \
446 (spawn must run through AgentContextMiddleware to enable \
447 sentinel resolution)"
448 .to_string(),
449 )
450 })?;
451 if view.extra.get(FILE_SENTINEL_ALLOW_KEY) != Some(&Value::Bool(true)) {
455 return Err(ApiError::bad_request(format!(
456 "@file: sentinel: file submission is not allowed for this step \
457 (declare `{FILE_SENTINEL_ALLOW_KEY}: true` via `$step_meta` / \
458 `AgentMeta.ctx` / `Blueprint.metas`; strict boolean `true` \
459 required)"
460 )));
461 }
462 let work_dir = view.work_dir.ok_or_else(|| {
463 ApiError::bad_request("@file: sentinel: task has no resolved work_dir".to_string())
464 })?;
465 let work_dir_canon = tokio::fs::canonicalize(&work_dir).await.map_err(|e| {
466 ApiError::engine(format!(
467 "@file: sentinel: canonicalize work_dir {work_dir:?}: {e}"
468 ))
469 })?;
470 let path_canon = match tokio::fs::canonicalize(path).await {
471 Ok(p) => p,
472 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
473 return Err(ApiError::not_found(format!(
474 "@file: sentinel: file not found: {path_str}"
475 )));
476 }
477 Err(e) => {
478 return Err(ApiError::engine(format!(
479 "@file: sentinel: canonicalize {path_str:?}: {e}"
480 )));
481 }
482 };
483 if !path_canon.starts_with(&work_dir_canon) {
484 return Err(ApiError::bad_request(format!(
485 "@file: sentinel: path {} is not under work_dir {} (canonicalized: {} vs {})",
486 path_str,
487 work_dir,
488 path_canon.display(),
489 work_dir_canon.display(),
490 )));
491 }
492 let meta = tokio::fs::metadata(&path_canon)
493 .await
494 .map_err(|e| ApiError::engine(format!("@file: sentinel: metadata {path_str:?}: {e}")))?;
495 if meta.len() > FILE_SENTINEL_MAX_BYTES {
496 return Err(ApiError::payload_too_large(format!(
497 "@file: sentinel: file size {} exceeds limit {}",
498 meta.len(),
499 FILE_SENTINEL_MAX_BYTES
500 )));
501 }
502 let bytes = tokio::fs::read(&path_canon)
503 .await
504 .map_err(|e| ApiError::engine(format!("@file: sentinel: read {path_str:?}: {e}")))?;
505 Ok(String::from_utf8_lossy(&bytes).trim_end().to_string())
507}
508
509async fn resolve_submit_value(
541 state: &AppState,
542 task_id: &StepId,
543 attempt: u32,
544 body_str: String,
545) -> Result<Value, ApiError> {
546 let declared = state
547 .engine
548 .agent_context_for(task_id, attempt)
549 .await
550 .and_then(|view| {
551 view.extra
552 .get(SUBMIT_FORMAT_KEY)
553 .cloned()
554 .map(|declared| (view.agent, declared))
555 });
556 let Some((agent, declared)) = declared else {
557 return Ok(Value::String(body_str));
558 };
559 if declared.as_str() != Some(SUBMIT_FORMAT_JSON) {
560 tracing::warn!(
561 agent = %agent,
562 declared = %declared,
563 "unknown `{SUBMIT_FORMAT_KEY}` value; folding the body as a string \
564 (the only recognized value is {SUBMIT_FORMAT_JSON:?})"
565 );
566 return Ok(Value::String(body_str));
567 }
568 serde_json::from_str::<Value>(&body_str).map_err(|e| {
569 let preview: String = body_str.chars().take(SUBMIT_FORMAT_PREVIEW_CHARS).collect();
570 ApiError::unprocessable(format!(
571 "submit_format violation: agent {agent:?} declared \
572 `{SUBMIT_FORMAT_KEY}: {SUBMIT_FORMAT_JSON:?}`, but the submitted body is \
573 not valid JSON: {e} (body starts with: {preview:?})"
574 ))
575 })
576}
577
578async fn check_verdict_contract(
599 state: &AppState,
600 task_id: &StepId,
601 channel: VerdictChannel,
602 value: &str,
603) -> Result<(), ApiError> {
604 let Some(contract) = state.engine.verdict_contract_for_task(task_id).await else {
605 return Ok(());
606 };
607 if contract.channel != channel {
608 return Ok(());
609 }
610 if contract.values.iter().any(|v| v == value) {
611 return Ok(());
612 }
613 Err(ApiError::unprocessable(format!(
614 "verdict contract violation: {value:?} is not a member of the declared values {:?}",
615 contract.values
616 )))
617}
618
619fn map_completion_result<T>(result: Result<T, EngineError>, context: &str) -> Result<T, ApiError> {
636 result.map_err(|e| match e {
637 EngineError::VerdictValueRejected { value, allowed } => ApiError::unprocessable(format!(
638 "verdict contract violation: {value:?} is not a member of the declared values {allowed:?}"
639 )),
640 EngineError::VerdictPartMissing { allowed } => ApiError::unprocessable(format!(
641 "verdict contract violation: no staged \"verdict\" part found for this attempt; declared values {allowed:?}"
642 )),
643 other => ApiError::engine(format!("{context}: {other}")),
644 })
645}
646
647#[derive(Debug, Deserialize, Default)]
678pub struct SubmitQuery {
679 #[serde(default)]
683 pub ok: Option<bool>,
684 #[serde(default)]
694 pub verdict: Option<String>,
695}
696
697fn resolve_submit_outcome(
709 verdict: Option<&str>,
710 ok: Option<bool>,
711) -> Result<SubmitOutcome, String> {
712 match verdict {
713 None => Ok(if ok.unwrap_or(true) {
714 SubmitOutcome::Pass
715 } else {
716 SubmitOutcome::Blocked
717 }),
718 Some(v) => match v {
719 "pass" => {
720 if ok == Some(false) {
721 Err(
722 "conflicting signal: verdict=pass with ok=false; drop one of them"
723 .to_string(),
724 )
725 } else {
726 Ok(SubmitOutcome::Pass)
727 }
728 }
729 "blocked" => {
730 if ok == Some(true) {
731 Err(
732 "conflicting signal: verdict=blocked with ok=true; drop one of them"
733 .to_string(),
734 )
735 } else {
736 Ok(SubmitOutcome::Blocked)
737 }
738 }
739 "skip" => {
740 if ok == Some(false) {
741 Err(
742 "conflicting signal: verdict=skip with ok=false; drop one of them"
743 .to_string(),
744 )
745 } else {
746 Ok(SubmitOutcome::Skip)
747 }
748 }
749 other => Err(format!(
750 "verdict must be one of: pass, blocked, skip (got {other:?})"
751 )),
752 },
753 }
754}
755
756pub async fn worker_submit(
762 State(state): State<AppState>,
763 headers: HeaderMap,
764 Query(q): Query<SubmitQuery>,
765 body: axum::body::Bytes,
766) -> Result<StatusCode, ApiError> {
767 let bearer = extract_bearer_raw(&headers)?;
770 let task_id = if let Some(handle) = parse_worker_handle(&bearer) {
771 state
772 .engine
773 .task_id_from_handle(handle)
774 .await
775 .map_err(map_handle_lookup_err)?
776 } else {
777 let token = CapToken::decode(bearer.trim())
778 .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
779 state
780 .engine
781 .task_id_from_token(&token)
782 .await
783 .map_err(|e| ApiError::engine(format!("task_id_from_token: {e}")))?
784 };
785 let attempt = state
786 .engine
787 .task_attempt(&task_id)
788 .await
789 .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?;
790 reject_if_run_terminal(&state, &task_id, attempt).await?;
793 let body_str = String::from_utf8_lossy(&body).trim_end().to_string();
798 let body_str = resolve_file_sentinel(&state, &task_id, attempt, body_str).await?;
801 let value = resolve_submit_value(&state, &task_id, attempt, body_str).await?;
817
818 let outcome =
827 resolve_submit_outcome(q.verdict.as_deref(), q.ok).map_err(ApiError::bad_request)?;
828 let submit_result = state
829 .engine
830 .submit_worker_result_trusted(&task_id, attempt, value, outcome)
831 .await;
832 map_completion_result(submit_result, "submit_worker_result_trusted")?;
833 Ok(StatusCode::NO_CONTENT)
834}
835
836#[derive(Debug, Deserialize)]
838pub struct ArtifactQuery {
839 pub name: String,
846}
847
848pub async fn worker_artifact(
870 State(state): State<AppState>,
871 headers: HeaderMap,
872 Query(q): Query<ArtifactQuery>,
873 body: axum::body::Bytes,
874) -> Result<StatusCode, ApiError> {
875 let name = q.name.trim();
876 if name.is_empty() {
877 return Err(ApiError::bad_request("name must not be empty".into()));
878 }
879 let name = name.to_string();
880
881 let bearer = extract_bearer_raw(&headers)?;
882 let task_id = if let Some(handle) = parse_worker_handle(&bearer) {
883 state
884 .engine
885 .task_id_from_handle(handle)
886 .await
887 .map_err(map_handle_lookup_err)?
888 } else {
889 let token = CapToken::decode(bearer.trim())
890 .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
891 state
892 .engine
893 .task_id_from_token(&token)
894 .await
895 .map_err(|e| ApiError::engine(format!("task_id_from_token: {e}")))?
896 };
897 let attempt = state
898 .engine
899 .task_attempt(&task_id)
900 .await
901 .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?;
902 reject_if_run_terminal(&state, &task_id, attempt).await?;
905 let body_str = String::from_utf8_lossy(&body).trim_end().to_string();
906 let body_str = resolve_file_sentinel(&state, &task_id, attempt, body_str).await?;
908 if name == "verdict" {
914 check_verdict_contract(&state, &task_id, VerdictChannel::Part, &body_str).await?;
915 }
916 let value = Value::String(body_str);
917
918 state
919 .engine
920 .stage_worker_artifact_trusted(&task_id, attempt, name, value)
921 .await
922 .map_err(|e| ApiError::engine(format!("stage_worker_artifact_trusted: {e}")))?;
923 Ok(StatusCode::NO_CONTENT)
924}
925
926#[derive(Debug, Deserialize, schemars::JsonSchema)]
933pub struct DegradationBody {
934 pub tool: String,
936 pub error: String,
938 pub fallback: String,
940 #[serde(default)]
942 pub note: Option<String>,
943}
944
945pub async fn worker_degradation(
973 State(state): State<AppState>,
974 headers: HeaderMap,
975 Json(body): Json<DegradationBody>,
976) -> Result<StatusCode, ApiError> {
977 let bearer = extract_bearer_raw(&headers)?;
978 let task_id = if let Some(handle) = parse_worker_handle(&bearer) {
979 state
980 .engine
981 .task_id_from_handle(handle)
982 .await
983 .map_err(map_handle_lookup_err)?
984 } else {
985 let token = CapToken::decode(bearer.trim())
986 .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
987 state
988 .engine
989 .task_id_from_token(&token)
990 .await
991 .map_err(|e| ApiError::engine(format!("task_id_from_token: {e}")))?
992 };
993 let attempt = state
994 .engine
995 .task_attempt(&task_id)
996 .await
997 .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?;
998 reject_if_run_terminal(&state, &task_id, attempt).await?;
1001
1002 let tid = task_id.clone();
1007 let (run_id_str, agent) = match state
1008 .engine
1009 .with_state("worker_degradation_run_lookup", move |s| {
1010 s.agent_ctx.get(&(tid, attempt)).and_then(|e| {
1011 e.view
1012 .run_id
1013 .clone()
1014 .map(|run_id| (run_id, e.view.agent.clone()))
1015 })
1016 })
1017 .await
1018 {
1019 Ok(Some(pair)) => pair,
1020 _ => {
1021 tracing::warn!(%task_id, "worker_degradation: no run linkage for this task; entry dropped");
1022 return Ok(StatusCode::NO_CONTENT);
1023 }
1024 };
1025 let Ok(run_id) = RunId::parse(run_id_str) else {
1026 tracing::warn!(%task_id, "worker_degradation: run_id failed to parse; entry dropped");
1027 return Ok(StatusCode::NO_CONTENT);
1028 };
1029
1030 mlua_swarm::store::trace::TraceHandle::new(run_id.clone(), state.run_trace_store.clone())
1034 .append(
1035 mlua_swarm::store::trace::kind::WORKER_DEGRADATION,
1036 Some(agent.as_str()),
1037 Some(attempt),
1038 serde_json::json!({
1039 "tool": body.tool.as_str(),
1040 "error": body.error.as_str(),
1041 "fallback": body.fallback.as_str(),
1042 }),
1043 )
1044 .await;
1045
1046 let entry = DegradationEntry {
1047 tool: body.tool,
1048 error: body.error,
1049 fallback: body.fallback,
1050 note: body.note,
1051 step_ref: Some(agent),
1052 attempt: Some(attempt),
1053 at: crate::tasks::now_secs(),
1054 };
1055 match state.run_store.append_degradation(&run_id, entry).await {
1056 Ok(()) => Ok(StatusCode::NO_CONTENT),
1057 Err(RunStoreError::NotFound(_)) => {
1058 tracing::warn!(%task_id, %run_id, "worker_degradation: run not found in run_store; entry dropped");
1059 Ok(StatusCode::NO_CONTENT)
1060 }
1061 Err(e) => Err(ApiError::engine(format!("append_degradation: {e}"))),
1062 }
1063}
1064
1065#[derive(Debug, Deserialize, schemars::JsonSchema)]
1076pub struct StatsBody {
1077 #[serde(default)]
1081 pub worker_kind: Option<String>,
1082 #[serde(default)]
1084 pub model: Option<String>,
1085 #[serde(default)]
1087 pub usage: Option<mlua_swarm::store::trace::TokenUsage>,
1088 #[serde(default)]
1090 pub num_turns: Option<u32>,
1091 #[serde(default)]
1093 pub adapter_data: Option<Value>,
1094}
1095
1096pub async fn worker_stats(
1107 State(state): State<AppState>,
1108 headers: HeaderMap,
1109 Json(body): Json<StatsBody>,
1110) -> Result<StatusCode, ApiError> {
1111 let bearer = extract_bearer_raw(&headers)?;
1112 let task_id = if let Some(handle) = parse_worker_handle(&bearer) {
1113 state
1114 .engine
1115 .task_id_from_handle(handle)
1116 .await
1117 .map_err(map_handle_lookup_err)?
1118 } else {
1119 let token = CapToken::decode(bearer.trim())
1120 .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
1121 state
1122 .engine
1123 .task_id_from_token(&token)
1124 .await
1125 .map_err(|e| ApiError::engine(format!("task_id_from_token: {e}")))?
1126 };
1127 let attempt = state
1128 .engine
1129 .task_attempt(&task_id)
1130 .await
1131 .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?;
1132 reject_if_run_terminal(&state, &task_id, attempt).await?;
1135
1136 let stats = mlua_swarm::store::trace::WorkerStats {
1137 worker_kind: Some(body.worker_kind.unwrap_or_else(|| "operator".to_string())),
1138 model: body.model,
1139 usage: body.usage,
1140 num_turns: body.num_turns,
1141 adapter_data: body.adapter_data,
1142 };
1143 state
1144 .engine
1145 .record_worker_stats(&task_id, attempt, stats)
1146 .await;
1147 Ok(StatusCode::NO_CONTENT)
1148}
1149
1150async fn reject_if_run_terminal(
1167 state: &AppState,
1168 task_id: &StepId,
1169 attempt: u32,
1170) -> Result<(), ApiError> {
1171 let tid = task_id.clone();
1172 let run_id_str = match state
1173 .engine
1174 .with_state("worker_terminal_run_guard", move |s| {
1175 s.agent_ctx
1176 .get(&(tid, attempt))
1177 .and_then(|e| e.view.run_id.clone())
1178 })
1179 .await
1180 {
1181 Ok(Some(rid)) => rid,
1182 _ => return Ok(()),
1183 };
1184 let Ok(run_id) = RunId::parse(run_id_str) else {
1185 return Ok(());
1186 };
1187 let Ok(rec) = state.run_store.get(&run_id).await else {
1188 return Ok(());
1189 };
1190 match rec.status {
1191 RunStatus::Done | RunStatus::Failed | RunStatus::Interrupted | RunStatus::Cancelled => {
1192 Err(ApiError::gone(format!(
1193 "run {run_id} is already terminal ({:?}): this attempt's output cannot be \
1194 delivered to a flow context; re-kick the task (POST /v1/tasks/:id/runs) and \
1195 fetch a fresh prompt",
1196 rec.status
1197 )))
1198 }
1199 RunStatus::Pending | RunStatus::Running => Ok(()),
1200 }
1201}
1202
1203#[derive(Debug, Deserialize)]
1208pub struct PromptSystemQuery {
1209 pub task_id: StepId,
1212 pub attempt: u32,
1214}
1215
1216pub async fn worker_prompt_system(
1226 State(state): State<AppState>,
1227 headers: HeaderMap,
1228 Query(q): Query<PromptSystemQuery>,
1229) -> Result<impl axum::response::IntoResponse, ApiError> {
1230 let task_id = q.task_id;
1231 let attempt = q.attempt;
1232 let bearer = extract_bearer_raw(&headers)?;
1233 if let Some(handle) = parse_worker_handle(&bearer) {
1234 let resolved = state
1235 .engine
1236 .task_id_from_handle(handle)
1237 .await
1238 .map_err(map_handle_lookup_err)?;
1239 if resolved != task_id {
1240 return Err(ApiError::bad_request(format!(
1241 "handle {handle} is bound to task {resolved}, not {task_id}"
1242 )));
1243 }
1244 } else {
1245 let token = CapToken::decode(bearer.trim())
1246 .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
1247 state
1248 .engine
1249 .verify_token_for_task(&token, mlua_swarm::Verb::FetchPrompt, &task_id)
1250 .await
1251 .map_err(|e| ApiError::engine(format!("verify_token_for_task: {e}")))?;
1252 }
1253 let system = state
1254 .engine
1255 .raw_system_prompt(&task_id, attempt)
1256 .await
1257 .map_err(|e| ApiError::engine(format!("raw_system_prompt: {e}")))?
1258 .ok_or_else(|| {
1259 ApiError::not_found(format!(
1260 "no baked system prompt for task {task_id} attempt {attempt}"
1261 ))
1262 })?;
1263 Ok((
1264 [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
1265 system,
1266 ))
1267}
1268
1269#[derive(Debug, serde::Serialize)]
1271pub struct AgentRenderSizeResponse {
1272 pub agent: String,
1274 pub last_rendered_bytes: Option<usize>,
1278}
1279
1280pub async fn agent_render_size(
1290 State(state): State<AppState>,
1291 axum::extract::Path(name): axum::extract::Path<String>,
1292) -> Json<AgentRenderSizeResponse> {
1293 let last_rendered_bytes = state.engine.agent_last_rendered_size(&name).await;
1294 Json(AgentRenderSizeResponse {
1295 agent: name,
1296 last_rendered_bytes,
1297 })
1298}
1299
1300fn extract_bearer_raw(headers: &HeaderMap) -> Result<String, ApiError> {
1304 let v = headers
1305 .get(AUTHORIZATION)
1306 .ok_or_else(|| ApiError::bad_request("missing Authorization header".into()))?
1307 .to_str()
1308 .map_err(|_| ApiError::bad_request("invalid Authorization header encoding".into()))?;
1309 let s = v
1310 .strip_prefix("Bearer ")
1311 .ok_or_else(|| ApiError::bad_request("Authorization must be 'Bearer <token>'".into()))?
1312 .trim();
1313 if s.is_empty() {
1314 return Err(ApiError::bad_request("Bearer is empty".into()));
1315 }
1316 Ok(s.to_string())
1317}
1318
1319fn map_handle_lookup_err(e: EngineError) -> ApiError {
1332 match e {
1333 EngineError::TokenNotFound(_) => ApiError::gone(
1334 "worker handle is no longer valid (the engine's in-flight state was reset, \
1335 e.g. by a server restart): re-kick the task (POST /v1/tasks/:id/runs) and \
1336 fetch a fresh prompt/handle"
1337 .to_string(),
1338 ),
1339 other => ApiError::engine(format!("task_id_from_handle: {other}")),
1340 }
1341}
1342
1343fn parse_worker_handle(s: &str) -> Option<&str> {
1347 let s = s.trim();
1348 if s.starts_with("wh-")
1349 && s.len() >= 5
1350 && s.len() <= 64
1351 && s[3..].chars().all(|c| c.is_ascii_alphanumeric())
1352 {
1353 Some(s)
1354 } else {
1355 None
1356 }
1357}
1358
1359fn decode_worker_bearer(headers: &HeaderMap) -> Result<CapToken, ApiError> {
1363 let v = headers
1364 .get(AUTHORIZATION)
1365 .ok_or_else(|| ApiError::bad_request("missing Authorization header".into()))?
1366 .to_str()
1367 .map_err(|_| ApiError::bad_request("invalid Authorization header encoding".into()))?;
1368 let encoded = v
1369 .strip_prefix("Bearer ")
1370 .ok_or_else(|| ApiError::bad_request("Authorization must be 'Bearer <token>'".into()))?
1371 .trim();
1372 if encoded.is_empty() {
1373 return Err(ApiError::bad_request("Bearer token is empty".into()));
1374 }
1375 CapToken::decode(encoded).map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))
1376}
1377
1378#[cfg(test)]
1383mod tests {
1384 use super::*;
1385 use axum::response::IntoResponse;
1386 use mlua_swarm::core::agent_context::AgentContextView;
1387 use mlua_swarm::core::config::EngineCfg;
1388 use mlua_swarm::core::engine::Engine;
1389 use mlua_swarm::store::output::{InMemoryOutputStore, OutputStore};
1390 use mlua_swarm::store::run::{InMemoryRunStore, RunRecord, RunStatus, RunStore, StepEntry};
1391 use mlua_swarm::store::task::InMemoryTaskStore;
1392 use mlua_swarm::{RunId, StepId, TaskId};
1393 use serde_json::json;
1394 use std::collections::HashMap;
1395 use std::sync::Arc;
1396 use tokio::sync::Mutex;
1397
1398 fn test_state(data_store: Arc<dyn OutputStore>, run_store: Arc<dyn RunStore>) -> AppState {
1404 let engine = Engine::new(EngineCfg::default());
1405 let compiler = mlua_swarm::Compiler::new(crate::default_registry());
1406 let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
1407 AppState {
1408 engine,
1409 sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
1410 task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
1411 ws_operator_factory: None,
1412 data_store,
1413 operator_sessions: Arc::new(Mutex::new(HashMap::new())),
1414 roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
1415 task_store: Arc::new(InMemoryTaskStore::new()),
1416 run_store,
1417 replay_store: Arc::new(mlua_swarm::store::replay::InMemoryReplayStore::new()),
1418 run_trace_store: Arc::new(mlua_swarm::store::trace::InMemoryRunTraceStore::new()),
1419 base_url: None,
1420 sync_timeout_secs: 300,
1421 }
1422 }
1423
1424 #[test]
1428 fn resolve_submit_outcome_absent_verdict_preserves_pre_gh76_wire() {
1429 assert!(matches!(
1431 resolve_submit_outcome(None, None),
1432 Ok(SubmitOutcome::Pass)
1433 ));
1434 assert!(matches!(
1435 resolve_submit_outcome(None, Some(true)),
1436 Ok(SubmitOutcome::Pass)
1437 ));
1438 assert!(matches!(
1439 resolve_submit_outcome(None, Some(false)),
1440 Ok(SubmitOutcome::Blocked)
1441 ));
1442 }
1443
1444 #[test]
1445 fn resolve_submit_outcome_verdict_pass_and_blocked_match_ok_bool_or_default() {
1446 assert!(matches!(
1447 resolve_submit_outcome(Some("pass"), None),
1448 Ok(SubmitOutcome::Pass)
1449 ));
1450 assert!(matches!(
1451 resolve_submit_outcome(Some("pass"), Some(true)),
1452 Ok(SubmitOutcome::Pass)
1453 ));
1454 assert!(resolve_submit_outcome(Some("pass"), Some(false)).is_err());
1455
1456 assert!(matches!(
1457 resolve_submit_outcome(Some("blocked"), None),
1458 Ok(SubmitOutcome::Blocked)
1459 ));
1460 assert!(matches!(
1461 resolve_submit_outcome(Some("blocked"), Some(false)),
1462 Ok(SubmitOutcome::Blocked)
1463 ));
1464 assert!(resolve_submit_outcome(Some("blocked"), Some(true)).is_err());
1465 }
1466
1467 #[test]
1468 fn resolve_submit_outcome_verdict_skip_is_ok_true_only() {
1469 assert!(matches!(
1470 resolve_submit_outcome(Some("skip"), None),
1471 Ok(SubmitOutcome::Skip)
1472 ));
1473 assert!(matches!(
1474 resolve_submit_outcome(Some("skip"), Some(true)),
1475 Ok(SubmitOutcome::Skip)
1476 ));
1477 let err = resolve_submit_outcome(Some("skip"), Some(false))
1479 .expect_err("skip + ok=false must be a conflict");
1480 assert!(
1481 err.contains("conflict") || err.contains("conflicting"),
1482 "err should name the conflict: {err}"
1483 );
1484 }
1485
1486 #[test]
1487 fn resolve_submit_outcome_invalid_verdict_names_valid_set() {
1488 let err = resolve_submit_outcome(Some("bogus"), None)
1489 .expect_err("unknown verdict must be an error");
1490 assert!(
1491 err.contains("pass") && err.contains("blocked") && err.contains("skip"),
1492 "err should enumerate the valid tier set: {err}"
1493 );
1494 }
1495
1496 #[test]
1503 fn stats_body_schema_matches_worker_stats_property_set() {
1504 fn property_names<T: schemars::JsonSchema>() -> std::collections::BTreeSet<String> {
1505 let schema = serde_json::to_value(schemars::schema_for!(T))
1506 .expect("schema must serialize as JSON");
1507 schema
1508 .get("properties")
1509 .and_then(|p| p.as_object())
1510 .expect("schema must expose a properties object")
1511 .keys()
1512 .cloned()
1513 .collect()
1514 }
1515
1516 assert_eq!(
1517 property_names::<StatsBody>(),
1518 property_names::<mlua_swarm::store::trace::WorkerStats>(),
1519 "StatsBody and WorkerStats must expose the same property set"
1520 );
1521 }
1522
1523 async fn append_final(
1524 data_store: &Arc<dyn OutputStore>,
1525 task_id: &str,
1526 producer: &str,
1527 value: Value,
1528 ) {
1529 data_store
1530 .append(
1531 task_id,
1532 1,
1533 producer,
1534 OutputEvent::Final {
1535 content: ContentRef::Inline { value },
1536 ok: true,
1537 },
1538 vec![],
1539 )
1540 .await
1541 .expect("append final");
1542 }
1543
1544 fn step_entry(step_id: &StepId, step_ref: &str) -> StepEntry {
1545 StepEntry::basic(
1546 step_id.clone(),
1547 Some(step_ref.to_string()),
1548 Some("passed".to_string()),
1549 None,
1550 0,
1551 )
1552 }
1553
1554 fn run_record(task_id: &TaskId, run_id: &RunId, step_entries: Vec<StepEntry>) -> RunRecord {
1555 RunRecord {
1556 id: run_id.clone(),
1557 task_id: task_id.clone(),
1558 status: RunStatus::Running,
1559 step_entries,
1560 degradations: Vec::new(),
1561 operator_sid: None,
1562 result_ref: None,
1563 input_json: None,
1564 created_at: 0,
1565 updated_at: 0,
1566 }
1567 }
1568
1569 fn consumer_payload(consumer_step_id: &StepId, run_id: &RunId) -> WorkerPayload {
1570 WorkerPayload {
1571 task_id: consumer_step_id.clone(),
1572 attempt: 1,
1573 agent: "consumer".to_string(),
1574 system: None,
1575 prompt: String::new(),
1576 context: Some(AgentContextView {
1577 task_id: consumer_step_id.to_string(),
1578 agent: "consumer".to_string(),
1579 attempt: 1,
1580 run_id: Some(run_id.to_string()),
1581 ..Default::default()
1582 }),
1583 system_ref: None,
1584 }
1585 }
1586
1587 #[tokio::test]
1592 async fn context_policy_unspecified_yields_every_submitted_step() {
1593 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1594 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1595 let task_id = TaskId::new();
1596 let run_id = RunId::new();
1597 let planner_id = StepId::new();
1598 let coder_id = StepId::new();
1599
1600 append_final(
1601 &data_store,
1602 planner_id.as_str(),
1603 "planner",
1604 json!({"plan": "x"}),
1605 )
1606 .await;
1607 append_final(
1608 &data_store,
1609 coder_id.as_str(),
1610 "coder",
1611 json!({"code": "y"}),
1612 )
1613 .await;
1614 run_store
1615 .create(run_record(
1616 &task_id,
1617 &run_id,
1618 vec![
1619 step_entry(&planner_id, "planner"),
1620 step_entry(&coder_id, "coder"),
1621 ],
1622 ))
1623 .await
1624 .expect("create run");
1625
1626 let state = test_state(data_store, run_store);
1627 let consumer_id = StepId::new();
1628 let mut payload = consumer_payload(&consumer_id, &run_id);
1629 assemble_step_pointers(&state, &mut payload).await;
1630
1631 let names: Vec<&str> = payload
1632 .context
1633 .as_ref()
1634 .expect("context")
1635 .steps
1636 .iter()
1637 .map(|p| p.name.as_str())
1638 .collect();
1639 assert!(names.contains(&"planner"), "names: {names:?}");
1640 assert!(names.contains(&"coder"), "names: {names:?}");
1641 }
1642
1643 #[tokio::test]
1645 async fn context_policy_steps_include_list_filters_to_named_steps() {
1646 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1647 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1648 let task_id = TaskId::new();
1649 let run_id = RunId::new();
1650 let planner_id = StepId::new();
1651 let coder_id = StepId::new();
1652 append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1653 append_final(&data_store, coder_id.as_str(), "coder", json!("y")).await;
1654 run_store
1655 .create(run_record(
1656 &task_id,
1657 &run_id,
1658 vec![
1659 step_entry(&planner_id, "planner"),
1660 step_entry(&coder_id, "coder"),
1661 ],
1662 ))
1663 .await
1664 .expect("create run");
1665
1666 let state = test_state(data_store, run_store);
1667 let consumer_id = StepId::new();
1668 state
1669 .engine
1670 .with_state("test.seed_policy", {
1671 let consumer_id = consumer_id.clone();
1672 move |s| {
1673 s.agent_ctx.insert(
1674 (consumer_id, 1),
1675 mlua_swarm::core::state::AgentCtxEntry {
1676 policy: mlua_swarm_schema::ContextPolicy {
1677 steps: Some(vec!["planner".to_string()]),
1678 ..Default::default()
1679 },
1680 ..Default::default()
1681 },
1682 );
1683 }
1684 })
1685 .await
1686 .expect("seed policy");
1687
1688 let mut payload = consumer_payload(&consumer_id, &run_id);
1689 assemble_step_pointers(&state, &mut payload).await;
1690
1691 let names: Vec<&str> = payload
1692 .context
1693 .as_ref()
1694 .expect("context")
1695 .steps
1696 .iter()
1697 .map(|p| p.name.as_str())
1698 .collect();
1699 assert_eq!(names, vec!["planner"], "names: {names:?}");
1700 }
1701
1702 #[tokio::test]
1704 async fn context_policy_steps_empty_list_yields_no_pointers() {
1705 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1706 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1707 let task_id = TaskId::new();
1708 let run_id = RunId::new();
1709 let planner_id = StepId::new();
1710 append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1711 run_store
1712 .create(run_record(
1713 &task_id,
1714 &run_id,
1715 vec![step_entry(&planner_id, "planner")],
1716 ))
1717 .await
1718 .expect("create run");
1719
1720 let state = test_state(data_store, run_store);
1721 let consumer_id = StepId::new();
1722 state
1723 .engine
1724 .with_state("test.seed_policy", {
1725 let consumer_id = consumer_id.clone();
1726 move |s| {
1727 s.agent_ctx.insert(
1728 (consumer_id, 1),
1729 mlua_swarm::core::state::AgentCtxEntry {
1730 policy: mlua_swarm_schema::ContextPolicy {
1731 steps: Some(vec![]),
1732 ..Default::default()
1733 },
1734 ..Default::default()
1735 },
1736 );
1737 }
1738 })
1739 .await
1740 .expect("seed policy");
1741
1742 let mut payload = consumer_payload(&consumer_id, &run_id);
1743 assemble_step_pointers(&state, &mut payload).await;
1744
1745 assert!(payload.context.expect("context").steps.is_empty());
1746 }
1747
1748 #[tokio::test]
1750 async fn context_policy_steps_exclude_wins_over_steps() {
1751 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1752 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1753 let task_id = TaskId::new();
1754 let run_id = RunId::new();
1755 let planner_id = StepId::new();
1756 let coder_id = StepId::new();
1757 append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1758 append_final(&data_store, coder_id.as_str(), "coder", json!("y")).await;
1759 run_store
1760 .create(run_record(
1761 &task_id,
1762 &run_id,
1763 vec![
1764 step_entry(&planner_id, "planner"),
1765 step_entry(&coder_id, "coder"),
1766 ],
1767 ))
1768 .await
1769 .expect("create run");
1770
1771 let state = test_state(data_store, run_store);
1772 let consumer_id = StepId::new();
1773 state
1774 .engine
1775 .with_state("test.seed_policy", {
1776 let consumer_id = consumer_id.clone();
1777 move |s| {
1778 s.agent_ctx.insert(
1779 (consumer_id, 1),
1780 mlua_swarm::core::state::AgentCtxEntry {
1781 policy: mlua_swarm_schema::ContextPolicy {
1782 steps: Some(vec!["planner".to_string(), "coder".to_string()]),
1783 steps_exclude: vec!["planner".to_string()],
1784 ..Default::default()
1785 },
1786 ..Default::default()
1787 },
1788 );
1789 }
1790 })
1791 .await
1792 .expect("seed policy");
1793
1794 let mut payload = consumer_payload(&consumer_id, &run_id);
1795 assemble_step_pointers(&state, &mut payload).await;
1796
1797 let names: Vec<&str> = payload
1798 .context
1799 .as_ref()
1800 .expect("context")
1801 .steps
1802 .iter()
1803 .map(|p| p.name.as_str())
1804 .collect();
1805 assert_eq!(names, vec!["coder"], "names: {names:?}");
1806 }
1807
1808 #[tokio::test]
1817 async fn in_flight_step_output_is_visible_before_run_finalizes() {
1818 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1819 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1820 let task_id = TaskId::new();
1821 let run_id = RunId::new();
1822 let step1_id = StepId::new();
1823 append_final(
1824 &data_store,
1825 step1_id.as_str(),
1826 "step1",
1827 json!({"step1_out": "hi"}),
1828 )
1829 .await;
1830 let mut run = run_record(&task_id, &run_id, vec![step_entry(&step1_id, "step1")]);
1831 run.status = RunStatus::Running;
1832 run.result_ref = None; run_store.create(run).await.expect("create run");
1834
1835 let state = test_state(data_store, run_store);
1836 let consumer_id = StepId::new();
1837 let mut payload = consumer_payload(&consumer_id, &run_id);
1838 assemble_step_pointers(&state, &mut payload).await;
1839
1840 let steps = &payload.context.expect("context").steps;
1841 assert_eq!(steps.len(), 1);
1842 assert_eq!(steps[0].name, "step1");
1843 }
1844
1845 #[tokio::test]
1849 async fn self_agent_name_is_always_excluded() {
1850 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1851 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1852 let task_id = TaskId::new();
1853 let run_id = RunId::new();
1854 let planner_id = StepId::new();
1855 let consumer_prior_id = StepId::new();
1856 append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1857 append_final(
1858 &data_store,
1859 consumer_prior_id.as_str(),
1860 "consumer",
1861 json!("self"),
1862 )
1863 .await;
1864 run_store
1865 .create(run_record(
1866 &task_id,
1867 &run_id,
1868 vec![
1869 step_entry(&planner_id, "planner"),
1870 step_entry(&consumer_prior_id, "consumer"),
1871 ],
1872 ))
1873 .await
1874 .expect("create run");
1875
1876 let state = test_state(data_store, run_store);
1877 let consumer_id = StepId::new();
1878 let mut payload = consumer_payload(&consumer_id, &run_id);
1879 assemble_step_pointers(&state, &mut payload).await;
1880
1881 let names: Vec<&str> = payload
1882 .context
1883 .as_ref()
1884 .expect("context")
1885 .steps
1886 .iter()
1887 .map(|p| p.name.as_str())
1888 .collect();
1889 assert!(!names.contains(&"consumer"), "names: {names:?}");
1890 assert!(names.contains(&"planner"), "names: {names:?}");
1891 }
1892
1893 #[tokio::test]
1897 async fn step_pointer_serializes_with_no_preview_or_content_bytes() {
1898 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1899 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1900 let task_id = TaskId::new();
1901 let run_id = RunId::new();
1902 let planner_id = StepId::new();
1903 append_final(
1904 &data_store,
1905 planner_id.as_str(),
1906 "planner",
1907 json!({"plan": "do the thing, at length".repeat(50)}),
1908 )
1909 .await;
1910 run_store
1911 .create(run_record(
1912 &task_id,
1913 &run_id,
1914 vec![step_entry(&planner_id, "planner")],
1915 ))
1916 .await
1917 .expect("create run");
1918
1919 let state = test_state(data_store, run_store);
1920 let consumer_id = StepId::new();
1921 let mut payload = consumer_payload(&consumer_id, &run_id);
1922 assemble_step_pointers(&state, &mut payload).await;
1923
1924 let steps = &payload.context.expect("context").steps;
1925 assert_eq!(steps.len(), 1);
1926 let json_value = serde_json::to_value(&steps[0]).expect("serialize StepPointer");
1927 let obj = json_value.as_object().expect("object");
1928 for forbidden in ["preview", "content", "value", "bytes"] {
1929 assert!(
1930 !obj.contains_key(forbidden),
1931 "StepPointer must not carry a {forbidden:?} field: {obj:?}"
1932 );
1933 }
1934 assert!(obj.contains_key("name"));
1935 assert!(obj.contains_key("size_bytes"));
1936 assert!(obj.contains_key("content_url"));
1937 assert!(obj.contains_key("sha256"));
1938 }
1939
1940 fn declared_name_bp() -> mlua_swarm::blueprint::Blueprint {
1948 use mlua_flow_ir::{Expr, Node};
1949 use mlua_swarm::blueprint::{
1950 current_schema_version, AgentDef, AgentKind, AgentMeta, Blueprint, BlueprintMetadata,
1951 CompilerHints, CompilerStrategy,
1952 };
1953 Blueprint {
1954 schema_version: current_schema_version(),
1955 id: "worker-test-declared-name-bp".into(),
1956 flow: Node::Step {
1957 ref_: "planner".to_string(),
1958 in_: Expr::Path {
1959 at: "$.in".parse().expect("literal test path: $.in"),
1960 },
1961 out: Expr::Path {
1962 at: "$.plan".parse().expect("literal test path: $.plan"),
1963 },
1964 },
1965 agents: vec![AgentDef {
1966 name: "planner".to_string(),
1967 kind: AgentKind::RustFn,
1968 spec: json!({"fn_id": "planner"}),
1969 profile: None,
1970 meta: Some(AgentMeta {
1971 projection_name: Some("plan-out".to_string()),
1972 ..Default::default()
1973 }),
1974 runner: None,
1975 runner_ref: None,
1976 verdict: None,
1977 }],
1978 operators: vec![],
1979 metas: vec![],
1980 hints: CompilerHints::default(),
1981 strategy: CompilerStrategy::default(),
1982 metadata: BlueprintMetadata::default(),
1983 spawner_hints: Default::default(),
1984 default_agent_kind: AgentKind::Operator,
1985 default_operator_kind: None,
1986 default_init_ctx: None,
1987 default_agent_ctx: None,
1988 default_context_policy: None,
1989 projection_placement: None,
1990 audits: vec![],
1991 degradation_policy: None,
1992 runners: vec![],
1993 default_runner: None,
1994 subprocesses: vec![],
1995 check_policy: None,
1996 blueprint_ref_includes: Vec::new(),
1997 }
1998 }
1999
2000 #[tokio::test]
2006 async fn declared_projection_name_pointer_name_is_canonical_and_policy_matches_it() {
2007 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2008 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2009 let task_id = TaskId::new();
2010 let run_id = RunId::new();
2011 let planner_id = StepId::new();
2012
2013 append_final(
2016 &data_store,
2017 planner_id.as_str(),
2018 "plan-out",
2019 json!({"plan": "x"}),
2020 )
2021 .await;
2022 run_store
2023 .create(run_record(
2024 &task_id,
2025 &run_id,
2026 vec![step_entry(&planner_id, "planner")],
2027 ))
2028 .await
2029 .expect("create run");
2030
2031 let state = test_state(data_store, run_store);
2032
2033 let (naming, _warnings) =
2039 mlua_swarm::core::step_naming::StepNaming::from_blueprint(&declared_name_bp())
2040 .expect("no collision");
2041 let naming = Arc::new(naming);
2042 let consumer_id = StepId::new();
2043 state
2044 .engine
2045 .with_state("test.seed_step_naming", {
2046 let naming = naming.clone();
2047 let planner_id = planner_id.clone();
2048 let consumer_id = consumer_id.clone();
2049 move |s| {
2050 s.step_namings.insert(planner_id, naming.clone());
2051 s.step_namings.insert(consumer_id, naming);
2052 }
2053 })
2054 .await
2055 .expect("seed step naming");
2056 state
2057 .engine
2058 .with_state("test.seed_policy", {
2059 let consumer_id = consumer_id.clone();
2060 move |s| {
2061 s.agent_ctx.insert(
2062 (consumer_id, 1),
2063 mlua_swarm::core::state::AgentCtxEntry {
2064 policy: mlua_swarm_schema::ContextPolicy {
2065 steps: Some(vec!["plan-out".to_string()]),
2066 ..Default::default()
2067 },
2068 ..Default::default()
2069 },
2070 );
2071 }
2072 })
2073 .await
2074 .expect("seed policy");
2075
2076 let mut payload = consumer_payload(&consumer_id, &run_id);
2077 assemble_step_pointers(&state, &mut payload).await;
2078
2079 let steps = &payload.context.expect("context").steps;
2080 assert_eq!(steps.len(), 1, "steps: {steps:?}");
2081 assert_eq!(
2082 steps[0].name, "plan-out",
2083 "StepPointer.name must be the canonical name"
2084 );
2085 }
2086
2087 async fn seed_task_with_handle(
2097 state: &AppState,
2098 task_id: &StepId,
2099 agent: &str,
2100 attempt: u32,
2101 system: Option<String>,
2102 ) -> String {
2103 let handle = format!("wh-{}", mlua_swarm::types::secure_hex(4));
2104 let task_id = task_id.clone();
2105 let agent = agent.to_string();
2106 let handle_clone = handle.clone();
2107 state
2108 .engine
2109 .with_state("test.seed_task_with_handle", move |s| {
2110 let mut task = mlua_swarm::core::state::TaskState::new(
2111 task_id.clone(),
2112 mlua_swarm::core::state::TaskSpec {
2113 agent: agent.clone(),
2114 initial_directive: json!("x"),
2115 step_ctx: None,
2116 check_policy: None,
2117 },
2118 );
2119 task.attempt = attempt;
2120 s.tasks.insert(task_id.clone(), task);
2121 s.systems.insert((task_id.clone(), attempt), system);
2122 let token = CapToken {
2123 agent_id: agent,
2124 role: mlua_swarm::Role::Worker,
2125 scopes: vec!["*".to_string()],
2126 issued_at: 0,
2127 expire_at: u64::MAX,
2128 max_uses: None,
2129 nonce: format!("test-nonce-{task_id}"),
2130 sig_hex: String::new(),
2131 };
2132 let fp = token.fingerprint();
2133 s.tokens.insert(
2134 fp.clone(),
2135 mlua_swarm::core::state::CapTokenRecord {
2136 token,
2137 uses_left: None,
2138 revoked: false,
2139 task_id: Some(task_id),
2140 },
2141 );
2142 s.worker_handles.insert(handle_clone, fp);
2143 })
2144 .await
2145 .expect("seed_task_with_handle");
2146 handle
2147 }
2148
2149 fn bearer_headers(handle: &str) -> HeaderMap {
2150 let mut headers = HeaderMap::new();
2151 headers.insert(
2152 AUTHORIZATION,
2153 format!("Bearer {handle}").parse().expect("header value"),
2154 );
2155 headers
2156 }
2157
2158 #[tokio::test]
2162 async fn worker_prompt_system_returns_raw_bytes_for_baked_system() {
2163 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2164 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2165 let state = test_state(data_store, run_store);
2166 let task_id = StepId::new();
2167 let rendered = "# Hello\n\nThis is the baked system prompt.".to_string();
2168 let handle =
2169 seed_task_with_handle(&state, &task_id, "planner", 1, Some(rendered.clone())).await;
2170
2171 let resp = worker_prompt_system(
2172 State(state.clone()),
2173 bearer_headers(&handle),
2174 Query(PromptSystemQuery {
2175 task_id: task_id.clone(),
2176 attempt: 1,
2177 }),
2178 )
2179 .await
2180 .expect("worker_prompt_system")
2181 .into_response();
2182
2183 assert_eq!(resp.status(), StatusCode::OK);
2184 let content_type = resp
2185 .headers()
2186 .get(header::CONTENT_TYPE)
2187 .expect("content-type header")
2188 .to_str()
2189 .expect("ascii");
2190 assert_eq!(content_type, "text/plain; charset=utf-8");
2191 let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2192 .await
2193 .expect("body bytes");
2194 assert_eq!(body_bytes.as_ref(), rendered.as_bytes());
2195 }
2196
2197 #[tokio::test]
2200 async fn worker_prompt_system_404s_when_no_baked_system() {
2201 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2202 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2203 let state = test_state(data_store, run_store);
2204 let task_id = StepId::new();
2205 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2206
2207 let result = worker_prompt_system(
2208 State(state.clone()),
2209 bearer_headers(&handle),
2210 Query(PromptSystemQuery {
2211 task_id: task_id.clone(),
2212 attempt: 1,
2213 }),
2214 )
2215 .await;
2216 let err = match result {
2217 Ok(_) => panic!("expected 404 ApiError, got Ok"),
2218 Err(e) => e,
2219 };
2220 assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND);
2221 }
2222
2223 #[tokio::test]
2226 async fn worker_prompt_system_rejects_handle_task_mismatch() {
2227 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2228 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2229 let state = test_state(data_store, run_store);
2230 let task_id = StepId::new();
2231 let other_task_id = StepId::new();
2232 let handle =
2233 seed_task_with_handle(&state, &task_id, "planner", 1, Some("x".to_string())).await;
2234
2235 let result = worker_prompt_system(
2236 State(state.clone()),
2237 bearer_headers(&handle),
2238 Query(PromptSystemQuery {
2239 task_id: other_task_id,
2240 attempt: 1,
2241 }),
2242 )
2243 .await;
2244 let err = match result {
2245 Ok(_) => panic!("expected 400 ApiError for task mismatch, got Ok"),
2246 Err(e) => e,
2247 };
2248 assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
2249 }
2250
2251 #[tokio::test]
2255 async fn agent_render_size_returns_null_for_unknown_agent() {
2256 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2257 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2258 let state = test_state(data_store, run_store);
2259
2260 let Json(body) = agent_render_size(
2261 State(state.clone()),
2262 axum::extract::Path("never-dispatched".to_string()),
2263 )
2264 .await;
2265 assert_eq!(body.agent, "never-dispatched");
2266 assert_eq!(body.last_rendered_bytes, None);
2267 }
2268
2269 #[tokio::test]
2272 async fn agent_render_size_reports_last_rendered_bytes() {
2273 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2274 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2275 let state = test_state(data_store, run_store);
2276 let task_id = StepId::new();
2277 state
2278 .engine
2279 .with_state("test.seed_agent_ctx_for_bake", {
2280 let task_id = task_id.clone();
2281 move |s| {
2282 s.tasks.insert(
2283 task_id.clone(),
2284 mlua_swarm::core::state::TaskState::new(
2285 task_id,
2286 mlua_swarm::core::state::TaskSpec {
2287 agent: "coder".to_string(),
2288 initial_directive: json!("x"),
2289 step_ctx: None,
2290 check_policy: None,
2291 },
2292 ),
2293 );
2294 }
2295 })
2296 .await
2297 .expect("seed task");
2298 state
2299 .engine
2300 .bake_worker_system_prompt(&task_id, 1, Some("z".repeat(42)))
2301 .await
2302 .expect("bake_worker_system_prompt");
2303
2304 let Json(body) = agent_render_size(
2305 State(state.clone()),
2306 axum::extract::Path("coder".to_string()),
2307 )
2308 .await;
2309 assert_eq!(body.agent, "coder");
2310 assert_eq!(body.last_rendered_bytes, Some(42));
2311 }
2312
2313 #[tokio::test]
2321 async fn worker_artifact_stages_and_204s_for_valid_request() {
2322 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2323 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2324 let state = test_state(data_store, run_store);
2325 let task_id = StepId::new();
2326 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2327
2328 let status = worker_artifact(
2329 State(state.clone()),
2330 bearer_headers(&handle),
2331 Query(ArtifactQuery {
2332 name: "summary".to_string(),
2333 }),
2334 axum::body::Bytes::from_static(b"hello artifact\n"),
2335 )
2336 .await
2337 .expect("worker_artifact");
2338 assert_eq!(status, StatusCode::NO_CONTENT);
2339
2340 let tail = state.engine.output_tail(&task_id, 1).await;
2341 assert_eq!(tail.len(), 1, "tail: {tail:?}");
2342 match &tail[0] {
2343 OutputEvent::Artifact { name, content } => {
2344 assert_eq!(name, "summary");
2345 match content {
2346 ContentRef::Inline { value } => {
2347 assert_eq!(value, &json!("hello artifact"));
2348 }
2349 other => panic!("expected Inline content, got {other:?}"),
2350 }
2351 }
2352 other => panic!("expected Artifact event, got {other:?}"),
2353 }
2354 }
2355
2356 #[tokio::test]
2363 async fn worker_artifact_rejects_blank_name() {
2364 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2365 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2366 let state = test_state(data_store, run_store);
2367 let task_id = StepId::new();
2368 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2369
2370 let result = worker_artifact(
2371 State(state.clone()),
2372 bearer_headers(&handle),
2373 Query(ArtifactQuery {
2374 name: " ".to_string(),
2375 }),
2376 axum::body::Bytes::from_static(b"x"),
2377 )
2378 .await;
2379 let err = match result {
2380 Ok(_) => panic!("expected 400 ApiError for blank name, got Ok"),
2381 Err(e) => e,
2382 };
2383 assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
2384
2385 assert!(state.engine.output_tail(&task_id, 1).await.is_empty());
2387 }
2388
2389 #[tokio::test]
2395 async fn worker_artifact_staging_same_name_twice_appends_both_events_in_order() {
2396 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2397 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2398 let state = test_state(data_store, run_store);
2399 let task_id = StepId::new();
2400 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2401
2402 for body in [b"first".as_slice(), b"second".as_slice()] {
2403 worker_artifact(
2404 State(state.clone()),
2405 bearer_headers(&handle),
2406 Query(ArtifactQuery {
2407 name: "a".to_string(),
2408 }),
2409 axum::body::Bytes::copy_from_slice(body),
2410 )
2411 .await
2412 .expect("worker_artifact");
2413 }
2414
2415 let tail = state.engine.output_tail(&task_id, 1).await;
2416 assert_eq!(tail.len(), 2, "tail: {tail:?}");
2417 let values: Vec<&str> = tail
2418 .iter()
2419 .map(|ev| match ev {
2420 OutputEvent::Artifact {
2421 content: ContentRef::Inline { value },
2422 ..
2423 } => value.as_str().expect("string value"),
2424 other => panic!("expected Artifact/Inline event, got {other:?}"),
2425 })
2426 .collect();
2427 assert_eq!(values, vec!["first", "second"]);
2428 }
2429
2430 async fn link_task_to_run(state: &AppState, task_id: &StepId, attempt: u32, run_id: &RunId) {
2438 let tid = task_id.clone();
2439 let rid_str = run_id.to_string();
2440 state
2441 .engine
2442 .with_state("test.link_task_to_run", move |s| {
2443 let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2444 entry.view.run_id = Some(rid_str);
2445 s.agent_ctx.insert((tid, attempt), entry);
2446 })
2447 .await
2448 .expect("link_task_to_run");
2449 }
2450
2451 #[tokio::test]
2456 async fn submit_and_artifact_against_terminal_run_return_410() {
2457 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2458 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2459 let state = test_state(data_store, run_store.clone());
2460 let task_id = StepId::new();
2461 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2462
2463 let owner_task = TaskId::new();
2464 let run_id = RunId::new();
2465 let mut rec = run_record(&owner_task, &run_id, vec![]);
2466 rec.status = RunStatus::Failed;
2467 run_store.create(rec).await.expect("run create");
2468 link_task_to_run(&state, &task_id, 1, &run_id).await;
2469
2470 let err = worker_submit(
2471 State(state.clone()),
2472 bearer_headers(&handle),
2473 Query(SubmitQuery {
2474 ok: None,
2475 verdict: None,
2476 }),
2477 axum::body::Bytes::from_static(b"LATE OUTPUT"),
2478 )
2479 .await
2480 .expect_err("a submit against a Failed run must be rejected");
2481 assert_eq!(err.status, StatusCode::GONE);
2482 assert!(
2483 err.message.contains(&run_id.to_string()),
2484 "the 410 must name the terminal run: {}",
2485 err.message
2486 );
2487
2488 let err = worker_artifact(
2489 State(state.clone()),
2490 bearer_headers(&handle),
2491 Query(ArtifactQuery {
2492 name: "part.md".to_string(),
2493 }),
2494 axum::body::Bytes::from_static(b"LATE PART"),
2495 )
2496 .await
2497 .expect_err("an artifact staged against a Failed run must be rejected");
2498 assert_eq!(err.status, StatusCode::GONE);
2499
2500 let tail = state.engine.output_tail(&task_id, 1).await;
2502 assert!(tail.is_empty(), "rejected submits must not land: {tail:?}");
2503 }
2504
2505 #[tokio::test]
2509 async fn terminal_run_guard_is_fail_open() {
2510 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2511 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2512 let state = test_state(data_store, run_store.clone());
2513 let task_id = StepId::new();
2514 seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2515
2516 reject_if_run_terminal(&state, &task_id, 1)
2518 .await
2519 .expect("no linkage must fail open");
2520
2521 let unknown_run = RunId::new();
2523 link_task_to_run(&state, &task_id, 1, &unknown_run).await;
2524 reject_if_run_terminal(&state, &task_id, 1)
2525 .await
2526 .expect("unknown run must fail open");
2527
2528 let owner_task = TaskId::new();
2530 let live_run = RunId::new();
2531 run_store
2532 .create(run_record(&owner_task, &live_run, vec![]))
2533 .await
2534 .expect("run create");
2535 link_task_to_run(&state, &task_id, 1, &live_run).await;
2536 reject_if_run_terminal(&state, &task_id, 1)
2537 .await
2538 .expect("a Running run must pass the guard");
2539 }
2540
2541 fn degradation_body(tool: &str, note: Option<&str>) -> DegradationBody {
2546 DegradationBody {
2547 tool: tool.to_string(),
2548 error: "boom".to_string(),
2549 fallback: "used cached value".to_string(),
2550 note: note.map(str::to_string),
2551 }
2552 }
2553
2554 async fn link_task_to_run_with_agent(
2560 state: &AppState,
2561 task_id: &StepId,
2562 attempt: u32,
2563 run_id: &RunId,
2564 agent: &str,
2565 ) {
2566 let tid = task_id.clone();
2567 let rid_str = run_id.to_string();
2568 let agent = agent.to_string();
2569 state
2570 .engine
2571 .with_state("test.link_task_to_run_with_agent", move |s| {
2572 let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2573 entry.view.run_id = Some(rid_str);
2574 entry.view.agent = agent;
2575 s.agent_ctx.insert((tid, attempt), entry);
2576 })
2577 .await
2578 .expect("link_task_to_run_with_agent");
2579 }
2580
2581 #[tokio::test]
2586 async fn worker_degradation_persists_entry_when_run_tracked() {
2587 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2588 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2589 let state = test_state(data_store, run_store.clone());
2590 let task_id = StepId::new();
2591 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2592
2593 let owner_task = TaskId::new();
2594 let run_id = RunId::new();
2595 run_store
2596 .create(run_record(&owner_task, &run_id, vec![]))
2597 .await
2598 .expect("run create");
2599 link_task_to_run_with_agent(&state, &task_id, 1, &run_id, "planner").await;
2600
2601 let status = worker_degradation(
2602 State(state.clone()),
2603 bearer_headers(&handle),
2604 Json(degradation_body("web_search", Some("rate limited"))),
2605 )
2606 .await
2607 .expect("worker_degradation");
2608 assert_eq!(status, StatusCode::NO_CONTENT);
2609
2610 let rec = run_store.get(&run_id).await.expect("run get");
2611 assert_eq!(
2612 rec.degradations.len(),
2613 1,
2614 "degradations: {:?}",
2615 rec.degradations
2616 );
2617 let entry = &rec.degradations[0];
2618 assert_eq!(entry.tool, "web_search");
2619 assert_eq!(entry.error, "boom");
2620 assert_eq!(entry.fallback, "used cached value");
2621 assert_eq!(entry.note.as_deref(), Some("rate limited"));
2622 assert_eq!(entry.step_ref.as_deref(), Some("planner"));
2623 assert_eq!(entry.attempt, Some(1));
2624 assert!(entry.at > 0, "at must be a real timestamp: {}", entry.at);
2625 }
2626
2627 #[tokio::test]
2629 async fn worker_degradation_appends_in_order() {
2630 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2631 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2632 let state = test_state(data_store, run_store.clone());
2633 let task_id = StepId::new();
2634 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2635
2636 let owner_task = TaskId::new();
2637 let run_id = RunId::new();
2638 run_store
2639 .create(run_record(&owner_task, &run_id, vec![]))
2640 .await
2641 .expect("run create");
2642 link_task_to_run(&state, &task_id, 1, &run_id).await;
2643
2644 for tool in ["first_tool", "second_tool"] {
2645 worker_degradation(
2646 State(state.clone()),
2647 bearer_headers(&handle),
2648 Json(degradation_body(tool, None)),
2649 )
2650 .await
2651 .expect("worker_degradation");
2652 }
2653
2654 let rec = run_store.get(&run_id).await.expect("run get");
2655 let tools: Vec<&str> = rec.degradations.iter().map(|e| e.tool.as_str()).collect();
2656 assert_eq!(tools, vec!["first_tool", "second_tool"]);
2657 }
2658
2659 #[tokio::test]
2663 async fn worker_degradation_silent_ok_when_no_run_tracked() {
2664 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2665 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2666 let state = test_state(data_store, run_store);
2667 let task_id = StepId::new();
2668 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2669
2670 let status = worker_degradation(
2671 State(state.clone()),
2672 bearer_headers(&handle),
2673 Json(degradation_body("some_tool", None)),
2674 )
2675 .await
2676 .expect("worker_degradation must not error on missing run linkage");
2677 assert_eq!(status, StatusCode::NO_CONTENT);
2678 }
2679
2680 #[tokio::test]
2683 async fn worker_degradation_rejects_terminal_run() {
2684 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2685 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2686 let state = test_state(data_store, run_store.clone());
2687 let task_id = StepId::new();
2688 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2689
2690 let owner_task = TaskId::new();
2691 let run_id = RunId::new();
2692 let mut rec = run_record(&owner_task, &run_id, vec![]);
2693 rec.status = RunStatus::Done;
2694 run_store.create(rec).await.expect("run create");
2695 link_task_to_run(&state, &task_id, 1, &run_id).await;
2696
2697 let err = worker_degradation(
2698 State(state.clone()),
2699 bearer_headers(&handle),
2700 Json(degradation_body("some_tool", None)),
2701 )
2702 .await
2703 .expect_err("a degradation against a Done run must be rejected");
2704 assert_eq!(err.status, StatusCode::GONE);
2705
2706 let rec = run_store.get(&run_id).await.expect("run get");
2707 assert!(
2708 rec.degradations.is_empty(),
2709 "rejected degradation must not land: {:?}",
2710 rec.degradations
2711 );
2712 }
2713
2714 async fn seed_work_dir(
2728 state: &AppState,
2729 task_id: &StepId,
2730 attempt: u32,
2731 work_dir: &str,
2732 allow_file_submit: Option<Value>,
2733 ) {
2734 let tid = task_id.clone();
2735 let work_dir = work_dir.to_string();
2736 state
2737 .engine
2738 .with_state("test.seed_work_dir", move |s| {
2739 let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2740 entry.view.work_dir = Some(work_dir);
2741 if let Some(v) = allow_file_submit {
2742 entry
2743 .view
2744 .extra
2745 .insert(FILE_SENTINEL_ALLOW_KEY.to_string(), v);
2746 }
2747 s.agent_ctx.insert((tid, attempt), entry);
2748 })
2749 .await
2750 .expect("seed_work_dir");
2751 }
2752
2753 #[tokio::test]
2757 async fn worker_submit_resolves_file_sentinel_under_work_dir() {
2758 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2759 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2760 let state = test_state(data_store.clone(), run_store);
2761 let task_id = StepId::new();
2762 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2763
2764 let tmp = tempfile::tempdir().expect("tempdir");
2765 let work_dir = tmp.path().to_path_buf();
2766 seed_work_dir(
2767 &state,
2768 &task_id,
2769 1,
2770 work_dir.to_str().expect("work_dir utf-8"),
2771 Some(Value::Bool(true)),
2772 )
2773 .await;
2774
2775 let payload_path = work_dir.join("scout.md");
2776 let payload = "## Context Package (broad)\n\nlarge body content\n";
2777 tokio::fs::write(&payload_path, payload)
2778 .await
2779 .expect("write payload");
2780 let body = format!(
2781 "@file:{}",
2782 payload_path.to_str().expect("payload path utf-8")
2783 );
2784
2785 let status = worker_submit(
2786 State(state.clone()),
2787 bearer_headers(&handle),
2788 Query(SubmitQuery {
2789 ok: None,
2790 verdict: None,
2791 }),
2792 axum::body::Bytes::from(body),
2793 )
2794 .await
2795 .expect("worker_submit sentinel");
2796 assert_eq!(status, StatusCode::NO_CONTENT);
2797
2798 let tid = task_id.clone();
2802 let value = state
2803 .engine
2804 .with_state("test.inspect_output_store", move |s| {
2805 s.output_store.get(&(tid.clone(), 1)).and_then(|evs| {
2806 evs.iter().find_map(|ev| match ev {
2807 OutputEvent::Final {
2808 content: ContentRef::Inline { value },
2809 ..
2810 } => Some(value.clone()),
2811 _ => None,
2812 })
2813 })
2814 })
2815 .await
2816 .expect("with_state")
2817 .expect("Final event present");
2818 assert_eq!(value, Value::String(payload.trim_end().to_string()));
2819 }
2820
2821 #[tokio::test]
2824 async fn worker_submit_passes_non_sentinel_body_unchanged() {
2825 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2826 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2827 let state = test_state(data_store.clone(), run_store);
2828 let task_id = StepId::new();
2829 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2830 let status = worker_submit(
2834 State(state.clone()),
2835 bearer_headers(&handle),
2836 Query(SubmitQuery {
2837 ok: None,
2838 verdict: None,
2839 }),
2840 axum::body::Bytes::from_static(b"DONE yes=1 maybe=0 no=0"),
2841 )
2842 .await
2843 .expect("worker_submit inline");
2844 assert_eq!(status, StatusCode::NO_CONTENT);
2845
2846 let tid = task_id.clone();
2847 let value = state
2848 .engine
2849 .with_state("test.inspect_output_store", move |s| {
2850 s.output_store.get(&(tid.clone(), 1)).and_then(|evs| {
2851 evs.iter().find_map(|ev| match ev {
2852 OutputEvent::Final {
2853 content: ContentRef::Inline { value },
2854 ..
2855 } => Some(value.clone()),
2856 _ => None,
2857 })
2858 })
2859 })
2860 .await
2861 .expect("with_state")
2862 .expect("Final event present");
2863 assert_eq!(value, Value::String("DONE yes=1 maybe=0 no=0".to_string()));
2864 }
2865
2866 #[tokio::test]
2871 async fn worker_submit_rejects_sentinel_path_outside_work_dir() {
2872 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2873 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2874 let state = test_state(data_store, run_store);
2875 let task_id = StepId::new();
2876 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2877
2878 let allowed = tempfile::tempdir().expect("allowed tempdir");
2879 let outside = tempfile::tempdir().expect("outside tempdir");
2880 seed_work_dir(
2881 &state,
2882 &task_id,
2883 1,
2884 allowed.path().to_str().expect("utf-8"),
2885 Some(Value::Bool(true)),
2886 )
2887 .await;
2888
2889 let outside_file = outside.path().join("leak.md");
2890 tokio::fs::write(&outside_file, b"outside content")
2891 .await
2892 .expect("write outside");
2893 let body = format!(
2894 "@file:{}",
2895 outside_file.to_str().expect("outside path utf-8")
2896 );
2897
2898 let err = worker_submit(
2899 State(state.clone()),
2900 bearer_headers(&handle),
2901 Query(SubmitQuery {
2902 ok: None,
2903 verdict: None,
2904 }),
2905 axum::body::Bytes::from(body),
2906 )
2907 .await
2908 .expect_err("outside-work_dir sentinel must be rejected");
2909 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2910 }
2911
2912 #[tokio::test]
2914 async fn worker_submit_rejects_sentinel_missing_file() {
2915 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2916 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2917 let state = test_state(data_store, run_store);
2918 let task_id = StepId::new();
2919 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2920
2921 let tmp = tempfile::tempdir().expect("tempdir");
2922 seed_work_dir(
2923 &state,
2924 &task_id,
2925 1,
2926 tmp.path().to_str().expect("utf-8"),
2927 Some(Value::Bool(true)),
2928 )
2929 .await;
2930 let missing = tmp.path().join("does-not-exist.md");
2931 let body = format!("@file:{}", missing.to_str().expect("utf-8"));
2932
2933 let err = worker_submit(
2934 State(state.clone()),
2935 bearer_headers(&handle),
2936 Query(SubmitQuery {
2937 ok: None,
2938 verdict: None,
2939 }),
2940 axum::body::Bytes::from(body),
2941 )
2942 .await
2943 .expect_err("missing-file sentinel must be rejected");
2944 assert_eq!(err.status, StatusCode::NOT_FOUND);
2945 }
2946
2947 #[tokio::test]
2949 async fn worker_submit_rejects_sentinel_relative_path() {
2950 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2951 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2952 let state = test_state(data_store, run_store);
2953 let task_id = StepId::new();
2954 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2955
2956 let err = worker_submit(
2957 State(state.clone()),
2958 bearer_headers(&handle),
2959 Query(SubmitQuery {
2960 ok: None,
2961 verdict: None,
2962 }),
2963 axum::body::Bytes::from_static(b"@file:relative/path.md"),
2964 )
2965 .await
2966 .expect_err("relative-path sentinel must be rejected");
2967 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2968 }
2969
2970 #[tokio::test]
2974 async fn worker_submit_rejects_sentinel_without_agent_context_view() {
2975 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2976 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2977 let state = test_state(data_store, run_store);
2978 let task_id = StepId::new();
2979 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2980 let err = worker_submit(
2983 State(state.clone()),
2984 bearer_headers(&handle),
2985 Query(SubmitQuery {
2986 ok: None,
2987 verdict: None,
2988 }),
2989 axum::body::Bytes::from_static(b"@file:/tmp/anywhere.md"),
2990 )
2991 .await
2992 .expect_err("missing AgentContextView must reject sentinel");
2993 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2994 }
2995
2996 #[tokio::test]
3000 async fn worker_artifact_resolves_file_sentinel_under_work_dir() {
3001 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3002 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3003 let state = test_state(data_store, run_store);
3004 let task_id = StepId::new();
3005 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3006
3007 let tmp = tempfile::tempdir().expect("tempdir");
3008 seed_work_dir(
3009 &state,
3010 &task_id,
3011 1,
3012 tmp.path().to_str().expect("utf-8"),
3013 Some(Value::Bool(true)),
3014 )
3015 .await;
3016
3017 let payload_path = tmp.path().join("part.md");
3018 let payload = "artifact part body\n";
3019 tokio::fs::write(&payload_path, payload)
3020 .await
3021 .expect("write payload");
3022 let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
3023
3024 let status = worker_artifact(
3025 State(state.clone()),
3026 bearer_headers(&handle),
3027 Query(ArtifactQuery {
3028 name: "scout".to_string(),
3029 }),
3030 axum::body::Bytes::from(body),
3031 )
3032 .await
3033 .expect("worker_artifact sentinel");
3034 assert_eq!(status, StatusCode::NO_CONTENT);
3035 }
3036
3037 #[tokio::test]
3042 async fn worker_submit_rejects_sentinel_without_allow_flag() {
3043 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3044 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3045 let state = test_state(data_store, run_store);
3046 let task_id = StepId::new();
3047 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3048
3049 let tmp = tempfile::tempdir().expect("tempdir");
3050 seed_work_dir(
3051 &state,
3052 &task_id,
3053 1,
3054 tmp.path().to_str().expect("utf-8"),
3055 None,
3056 )
3057 .await;
3058
3059 let payload_path = tmp.path().join("out.md");
3060 tokio::fs::write(&payload_path, b"resolvable body")
3061 .await
3062 .expect("write payload");
3063 let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
3064
3065 let err = worker_submit(
3066 State(state.clone()),
3067 bearer_headers(&handle),
3068 Query(SubmitQuery {
3069 ok: None,
3070 verdict: None,
3071 }),
3072 axum::body::Bytes::from(body),
3073 )
3074 .await
3075 .expect_err("missing opt-in must reject sentinel");
3076 assert_eq!(err.status, StatusCode::BAD_REQUEST);
3077 assert!(
3078 err.message.contains("not allowed"),
3079 "rejection must name the opt-in guard, got: {}",
3080 err.message
3081 );
3082 }
3083
3084 #[tokio::test]
3087 async fn worker_submit_rejects_sentinel_with_non_true_allow_values() {
3088 for allow in [Value::Bool(false), Value::String("true".to_string())] {
3089 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3090 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3091 let state = test_state(data_store, run_store);
3092 let task_id = StepId::new();
3093 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3094
3095 let tmp = tempfile::tempdir().expect("tempdir");
3096 seed_work_dir(
3097 &state,
3098 &task_id,
3099 1,
3100 tmp.path().to_str().expect("utf-8"),
3101 Some(allow.clone()),
3102 )
3103 .await;
3104
3105 let payload_path = tmp.path().join("out.md");
3106 tokio::fs::write(&payload_path, b"resolvable body")
3107 .await
3108 .expect("write payload");
3109 let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
3110
3111 let err = worker_submit(
3112 State(state.clone()),
3113 bearer_headers(&handle),
3114 Query(SubmitQuery {
3115 ok: None,
3116 verdict: None,
3117 }),
3118 axum::body::Bytes::from(body),
3119 )
3120 .await
3121 .expect_err("non-true opt-in value must reject sentinel");
3122 assert_eq!(err.status, StatusCode::BAD_REQUEST, "value: {allow:?}");
3123 }
3124 }
3125
3126 async fn seed_submit_format(
3140 state: &AppState,
3141 task_id: &StepId,
3142 attempt: u32,
3143 agent: &str,
3144 submit_format: Option<Value>,
3145 work_dir: Option<&str>,
3146 ) {
3147 let tid = task_id.clone();
3148 let agent = agent.to_string();
3149 let work_dir = work_dir.map(|w| w.to_string());
3150 state
3151 .engine
3152 .with_state("test.seed_submit_format", move |s| {
3153 let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
3154 entry.view.agent = agent;
3155 if let Some(w) = work_dir {
3156 entry.view.work_dir = Some(w);
3157 entry
3158 .view
3159 .extra
3160 .insert(FILE_SENTINEL_ALLOW_KEY.to_string(), Value::Bool(true));
3161 }
3162 if let Some(v) = submit_format {
3163 entry.view.extra.insert(SUBMIT_FORMAT_KEY.to_string(), v);
3164 }
3165 s.agent_ctx.insert((tid, attempt), entry);
3166 })
3167 .await
3168 .expect("seed_submit_format");
3169 }
3170
3171 async fn final_value(state: &AppState, task_id: &StepId, attempt: u32) -> Option<Value> {
3174 let tid = task_id.clone();
3175 state
3176 .engine
3177 .with_state("test.inspect_output_store", move |s| {
3178 s.output_store.get(&(tid.clone(), attempt)).and_then(|evs| {
3179 evs.iter().find_map(|ev| match ev {
3180 OutputEvent::Final {
3181 content: ContentRef::Inline { value },
3182 ..
3183 } => Some(value.clone()),
3184 _ => None,
3185 })
3186 })
3187 })
3188 .await
3189 .expect("with_state")
3190 }
3191
3192 #[tokio::test]
3197 async fn worker_submit_without_submit_format_folds_json_looking_body_as_string() {
3198 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3199 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3200 let state = test_state(data_store, run_store);
3201 let task_id = StepId::new();
3202 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3203 seed_submit_format(&state, &task_id, 1, "planner", None, None).await;
3204
3205 let body = r#"{"lanes":["a","b"]}"#;
3206 let status = worker_submit(
3207 State(state.clone()),
3208 bearer_headers(&handle),
3209 Query(SubmitQuery {
3210 ok: None,
3211 verdict: None,
3212 }),
3213 axum::body::Bytes::from(body),
3214 )
3215 .await
3216 .expect("undeclared submit must succeed");
3217 assert_eq!(status, StatusCode::NO_CONTENT);
3218
3219 assert_eq!(
3220 final_value(&state, &task_id, 1).await,
3221 Some(Value::String(body.to_string())),
3222 "an undeclared step must keep the raw-string fold",
3223 );
3224 }
3225
3226 #[tokio::test]
3230 async fn worker_submit_declared_json_folds_structured_value() {
3231 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3232 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3233 let state = test_state(data_store, run_store);
3234 let task_id = StepId::new();
3235 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3236 seed_submit_format(
3237 &state,
3238 &task_id,
3239 1,
3240 "planner",
3241 Some(Value::String(SUBMIT_FORMAT_JSON.to_string())),
3242 None,
3243 )
3244 .await;
3245
3246 let status = worker_submit(
3247 State(state.clone()),
3248 bearer_headers(&handle),
3249 Query(SubmitQuery {
3250 ok: None,
3251 verdict: None,
3252 }),
3253 axum::body::Bytes::from(r#"{"lanes":["auth","billing"],"verdict":"PASS"}"#),
3254 )
3255 .await
3256 .expect("declared JSON submit must succeed");
3257 assert_eq!(status, StatusCode::NO_CONTENT);
3258
3259 let value = final_value(&state, &task_id, 1)
3260 .await
3261 .expect("Final event present");
3262 assert_eq!(
3263 value,
3264 json!({"lanes": ["auth", "billing"], "verdict": "PASS"})
3265 );
3266 assert_eq!(value["lanes"], json!(["auth", "billing"]));
3268 assert_eq!(value["verdict"], json!("PASS"));
3269 }
3270
3271 #[tokio::test]
3275 async fn worker_submit_declared_json_rejects_unparseable_body_with_422() {
3276 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3277 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3278 let state = test_state(data_store, run_store);
3279 let task_id = StepId::new();
3280 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3281 seed_submit_format(
3282 &state,
3283 &task_id,
3284 1,
3285 "planner",
3286 Some(Value::String(SUBMIT_FORMAT_JSON.to_string())),
3287 None,
3288 )
3289 .await;
3290
3291 let err = worker_submit(
3292 State(state.clone()),
3293 bearer_headers(&handle),
3294 Query(SubmitQuery {
3295 ok: None,
3296 verdict: None,
3297 }),
3298 axum::body::Bytes::from("DONE — 3 lanes planned, see the report above"),
3299 )
3300 .await
3301 .expect_err("a declared step must not fold an unparseable body");
3302 assert_eq!(err.status, StatusCode::UNPROCESSABLE_ENTITY);
3303 assert!(
3304 err.message.contains("planner") && err.message.contains("DONE"),
3305 "the rejection must name the agent and echo the body head, got: {}",
3306 err.message
3307 );
3308 assert_eq!(
3309 final_value(&state, &task_id, 1).await,
3310 None,
3311 "a rejected submit must not reach the output tail",
3312 );
3313 }
3314
3315 #[tokio::test]
3319 async fn worker_submit_declared_json_parses_file_sentinel_contents() {
3320 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3321 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3322 let state = test_state(data_store, run_store);
3323 let task_id = StepId::new();
3324 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3325
3326 let tmp = tempfile::tempdir().expect("tempdir");
3327 let work_dir = tmp.path().to_path_buf();
3328 seed_submit_format(
3329 &state,
3330 &task_id,
3331 1,
3332 "planner",
3333 Some(Value::String(SUBMIT_FORMAT_JSON.to_string())),
3334 Some(work_dir.to_str().expect("work_dir utf-8")),
3335 )
3336 .await;
3337
3338 let payload_path = work_dir.join("plan.json");
3339 tokio::fs::write(&payload_path, "{\"lanes\": [\"auth\", \"billing\"]}\n")
3340 .await
3341 .expect("write payload");
3342 let body = format!(
3343 "@file:{}",
3344 payload_path.to_str().expect("payload path utf-8")
3345 );
3346
3347 let status = worker_submit(
3348 State(state.clone()),
3349 bearer_headers(&handle),
3350 Query(SubmitQuery {
3351 ok: None,
3352 verdict: None,
3353 }),
3354 axum::body::Bytes::from(body),
3355 )
3356 .await
3357 .expect("sentinel + declared JSON submit must succeed");
3358 assert_eq!(status, StatusCode::NO_CONTENT);
3359
3360 assert_eq!(
3361 final_value(&state, &task_id, 1).await,
3362 Some(json!({"lanes": ["auth", "billing"]})),
3363 );
3364 }
3365
3366 #[tokio::test]
3371 async fn worker_submit_unknown_submit_format_falls_back_to_string() {
3372 for declared in [
3373 Value::String("yaml".to_string()),
3374 Value::Bool(true),
3375 Value::Number(1.into()),
3376 ] {
3377 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3378 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3379 let state = test_state(data_store, run_store);
3380 let task_id = StepId::new();
3381 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3382 seed_submit_format(&state, &task_id, 1, "planner", Some(declared.clone()), None).await;
3383
3384 let status = worker_submit(
3385 State(state.clone()),
3386 bearer_headers(&handle),
3387 Query(SubmitQuery {
3388 ok: None,
3389 verdict: None,
3390 }),
3391 axum::body::Bytes::from(r#"{"lanes":["a"]}"#),
3392 )
3393 .await
3394 .unwrap_or_else(|e| panic!("unknown value must not reject ({declared}): {e:?}"));
3395 assert_eq!(status, StatusCode::NO_CONTENT);
3396
3397 assert_eq!(
3398 final_value(&state, &task_id, 1).await,
3399 Some(Value::String(r#"{"lanes":["a"]}"#.to_string())),
3400 "unknown value {declared} must keep the string fold",
3401 );
3402 }
3403 }
3404
3405 #[tokio::test]
3410 async fn worker_submit_verdict_contract_unchanged_without_submit_format() {
3411 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3412 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3413 let state = test_state(data_store, run_store);
3414 state.engine.register_verdict_contracts(HashMap::from([(
3415 "gate".to_string(),
3416 body_verdict_contract(&["PASS", "BLOCKED"]),
3417 )]));
3418
3419 let accepted = StepId::new();
3421 let handle = seed_task_with_handle(&state, &accepted, "gate", 1, None).await;
3422 seed_submit_format(&state, &accepted, 1, "gate", None, None).await;
3423 let status = worker_submit(
3424 State(state.clone()),
3425 bearer_headers(&handle),
3426 Query(SubmitQuery {
3427 ok: None,
3428 verdict: None,
3429 }),
3430 axum::body::Bytes::from("BLOCKED"),
3431 )
3432 .await
3433 .expect("a declared verdict value must still pass");
3434 assert_eq!(status, StatusCode::NO_CONTENT);
3435 assert_eq!(
3436 final_value(&state, &accepted, 1).await,
3437 Some(Value::String("BLOCKED".to_string())),
3438 );
3439
3440 let rejected = StepId::new();
3443 let handle = seed_task_with_handle(&state, &rejected, "gate", 1, None).await;
3444 seed_submit_format(&state, &rejected, 1, "gate", None, None).await;
3445 let err = worker_submit(
3446 State(state.clone()),
3447 bearer_headers(&handle),
3448 Query(SubmitQuery {
3449 ok: None,
3450 verdict: None,
3451 }),
3452 axum::body::Bytes::from("UNKNOWN"),
3453 )
3454 .await
3455 .expect_err("a non-member verdict value must still be rejected");
3456 assert_eq!(err.status, StatusCode::UNPROCESSABLE_ENTITY);
3457 assert!(
3458 err.message.contains("verdict contract violation"),
3459 "the verdict contract must own this rejection, got: {}",
3460 err.message
3461 );
3462 }
3463
3464 #[tokio::test]
3470 async fn declared_json_submit_feeds_a_fanout_items_path() {
3471 use mlua_flow_ir::{EvalError, Expr, JoinMode, Node as FlowNode};
3472
3473 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3474 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3475 let state = test_state(data_store, run_store);
3476 let task_id = StepId::new();
3477 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3478 seed_submit_format(
3479 &state,
3480 &task_id,
3481 1,
3482 "planner",
3483 Some(Value::String(SUBMIT_FORMAT_JSON.to_string())),
3484 None,
3485 )
3486 .await;
3487
3488 worker_submit(
3489 State(state.clone()),
3490 bearer_headers(&handle),
3491 Query(SubmitQuery {
3492 ok: None,
3493 verdict: None,
3494 }),
3495 axum::body::Bytes::from(r#"{"lanes":["auth","billing","search"]}"#),
3496 )
3497 .await
3498 .expect("declared JSON submit must succeed");
3499 let planner_out = final_value(&state, &task_id, 1)
3500 .await
3501 .expect("Final event present");
3502
3503 let path = |s: &str| Expr::Path {
3505 at: s.parse().expect("literal test path"),
3506 };
3507 let flow = FlowNode::Fanout {
3508 items: path("$.planner.lanes"),
3509 bind: path("$.item"),
3510 body: Box::new(FlowNode::Step {
3511 ref_: "check".to_string(),
3512 in_: path("$.item"),
3513 out: path("$.branch_out"),
3514 }),
3515 join: JoinMode::All,
3516 out: path("$.results"),
3517 };
3518 let dispatcher = |_ref: &str, input: Value| -> Result<Value, EvalError> { Ok(input) };
3519 let final_ctx = mlua_flow_ir::eval(&flow, json!({ "planner": planner_out }), &dispatcher)
3520 .expect("fanout over the parsed submit must evaluate");
3521
3522 let lanes: Vec<&Value> = final_ctx["results"]
3523 .as_array()
3524 .expect("results is an array")
3525 .iter()
3526 .map(|lane_ctx| &lane_ctx["branch_out"])
3527 .collect();
3528 assert_eq!(
3529 lanes,
3530 vec![&json!("auth"), &json!("billing"), &json!("search")]
3531 );
3532 }
3533
3534 fn body_verdict_contract(values: &[&str]) -> mlua_swarm_schema::VerdictContract {
3545 mlua_swarm_schema::VerdictContract {
3546 channel: VerdictChannel::Body,
3547 values: values.iter().map(|v| v.to_string()).collect(),
3548 }
3549 }
3550
3551 fn part_verdict_contract(values: &[&str]) -> mlua_swarm_schema::VerdictContract {
3552 mlua_swarm_schema::VerdictContract {
3553 channel: VerdictChannel::Part,
3554 values: values.iter().map(|v| v.to_string()).collect(),
3555 }
3556 }
3557
3558 #[tokio::test]
3561 async fn worker_submit_rejects_body_outside_contract_values_with_422() {
3562 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3563 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3564 let state = test_state(data_store, run_store);
3565 let task_id = StepId::new();
3566 let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
3567 state.engine.register_verdict_contracts(HashMap::from([(
3568 "gate".to_string(),
3569 body_verdict_contract(&["PASS", "BLOCKED"]),
3570 )]));
3571
3572 let err = worker_submit(
3573 State(state.clone()),
3574 bearer_headers(&handle),
3575 Query(SubmitQuery {
3576 ok: None,
3577 verdict: None,
3578 }),
3579 axum::body::Bytes::from("UNKNOWN"),
3580 )
3581 .await
3582 .expect_err("value outside declared values must reject");
3583 assert_eq!(err.status, StatusCode::UNPROCESSABLE_ENTITY);
3584 assert!(
3585 err.message.contains("PASS") && err.message.contains("BLOCKED"),
3586 "rejection must echo the declared values, got: {}",
3587 err.message
3588 );
3589 }
3590
3591 #[tokio::test]
3594 async fn worker_submit_accepts_body_inside_contract_values() {
3595 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3596 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3597 let state = test_state(data_store, run_store);
3598 let task_id = StepId::new();
3599 let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
3600 state.engine.register_verdict_contracts(HashMap::from([(
3601 "gate".to_string(),
3602 body_verdict_contract(&["PASS", "BLOCKED"]),
3603 )]));
3604
3605 let status = worker_submit(
3606 State(state.clone()),
3607 bearer_headers(&handle),
3608 Query(SubmitQuery {
3609 ok: None,
3610 verdict: None,
3611 }),
3612 axum::body::Bytes::from("PASS"),
3613 )
3614 .await
3615 .expect("value inside declared values must succeed");
3616 assert_eq!(status, StatusCode::NO_CONTENT);
3617 }
3618
3619 #[tokio::test]
3623 async fn worker_submit_without_a_declared_contract_is_unaffected() {
3624 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3625 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3626 let state = test_state(data_store, run_store);
3627 let task_id = StepId::new();
3628 let handle = seed_task_with_handle(&state, &task_id, "undeclared-agent", 1, None).await;
3630
3631 let status = worker_submit(
3632 State(state.clone()),
3633 bearer_headers(&handle),
3634 Query(SubmitQuery {
3635 ok: None,
3636 verdict: None,
3637 }),
3638 axum::body::Bytes::from("anything at all, no contract to violate"),
3639 )
3640 .await
3641 .expect("no contract declared must never reject");
3642 assert_eq!(status, StatusCode::NO_CONTENT);
3643 }
3644
3645 #[tokio::test]
3648 async fn worker_artifact_verdict_part_rejects_value_outside_contract_with_422() {
3649 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3650 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3651 let state = test_state(data_store, run_store);
3652 let task_id = StepId::new();
3653 let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
3654 state.engine.register_verdict_contracts(HashMap::from([(
3655 "gate".to_string(),
3656 part_verdict_contract(&["PASS", "BLOCKED"]),
3657 )]));
3658
3659 let err = worker_artifact(
3660 State(state.clone()),
3661 bearer_headers(&handle),
3662 Query(ArtifactQuery {
3663 name: "verdict".to_string(),
3664 }),
3665 axum::body::Bytes::from("UNKNOWN"),
3666 )
3667 .await
3668 .expect_err("value outside declared values must reject");
3669 assert_eq!(err.status, StatusCode::UNPROCESSABLE_ENTITY);
3670 }
3671
3672 #[tokio::test]
3676 async fn worker_artifact_non_verdict_part_skips_the_gate() {
3677 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3678 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3679 let state = test_state(data_store, run_store);
3680 let task_id = StepId::new();
3681 let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
3682 state.engine.register_verdict_contracts(HashMap::from([(
3683 "gate".to_string(),
3684 part_verdict_contract(&["PASS", "BLOCKED"]),
3685 )]));
3686
3687 let status = worker_artifact(
3688 State(state.clone()),
3689 bearer_headers(&handle),
3690 Query(ArtifactQuery {
3691 name: "notes".to_string(),
3692 }),
3693 axum::body::Bytes::from("anything at all"),
3694 )
3695 .await
3696 .expect("non-verdict part name must never be gated");
3697 assert_eq!(status, StatusCode::NO_CONTENT);
3698 }
3699}