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 = mlua_swarm::core::engine::SUBMIT_FORMAT_KEY;
374
375const SUBMIT_FORMAT_JSON: &str = "json";
382
383const SUBMIT_FORMAT_TEXT: &str = mlua_swarm::core::engine::SUBMIT_FORMAT_TEXT;
389
390const SUBMIT_FORMAT_PREVIEW_CHARS: usize = 80;
394
395async fn resolve_file_sentinel(
428 state: &AppState,
429 task_id: &StepId,
430 attempt: u32,
431 body_str: String,
432) -> Result<String, ApiError> {
433 let Some(rest) = body_str.strip_prefix(FILE_SENTINEL_PREFIX) else {
434 return Ok(body_str);
435 };
436 let path_str = rest.trim();
437 if path_str.is_empty() {
438 return Err(ApiError::bad_request(
439 "@file: sentinel: empty path".to_string(),
440 ));
441 }
442 if path_str.contains('\n') || path_str.contains('\r') {
443 return Err(ApiError::bad_request(
444 "@file: sentinel: path must be a single line".to_string(),
445 ));
446 }
447 let path = std::path::Path::new(path_str);
448 if !path.is_absolute() {
449 return Err(ApiError::bad_request(format!(
450 "@file: sentinel: path must be absolute (got {path_str:?})"
451 )));
452 }
453 let view = state
454 .engine
455 .agent_context_for(task_id, attempt)
456 .await
457 .ok_or_else(|| {
458 ApiError::bad_request(
459 "@file: sentinel: no AgentContextView for this task/attempt \
460 (spawn must run through AgentContextMiddleware to enable \
461 sentinel resolution)"
462 .to_string(),
463 )
464 })?;
465 if view.extra.get(FILE_SENTINEL_ALLOW_KEY) != Some(&Value::Bool(true)) {
469 return Err(ApiError::bad_request(format!(
470 "@file: sentinel: file submission is not allowed for this step \
471 (declare `{FILE_SENTINEL_ALLOW_KEY}: true` via `$step_meta` / \
472 `AgentMeta.ctx` / `Blueprint.metas`; strict boolean `true` \
473 required)"
474 )));
475 }
476 let work_dir = view.work_dir.ok_or_else(|| {
477 ApiError::bad_request("@file: sentinel: task has no resolved work_dir".to_string())
478 })?;
479 let work_dir_canon = tokio::fs::canonicalize(&work_dir).await.map_err(|e| {
480 ApiError::engine(format!(
481 "@file: sentinel: canonicalize work_dir {work_dir:?}: {e}"
482 ))
483 })?;
484 let path_canon = match tokio::fs::canonicalize(path).await {
485 Ok(p) => p,
486 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
487 return Err(ApiError::not_found(format!(
488 "@file: sentinel: file not found: {path_str}"
489 )));
490 }
491 Err(e) => {
492 return Err(ApiError::engine(format!(
493 "@file: sentinel: canonicalize {path_str:?}: {e}"
494 )));
495 }
496 };
497 if !path_canon.starts_with(&work_dir_canon) {
498 return Err(ApiError::bad_request(format!(
499 "@file: sentinel: path {} is not under work_dir {} (canonicalized: {} vs {})",
500 path_str,
501 work_dir,
502 path_canon.display(),
503 work_dir_canon.display(),
504 )));
505 }
506 let meta = tokio::fs::metadata(&path_canon)
507 .await
508 .map_err(|e| ApiError::engine(format!("@file: sentinel: metadata {path_str:?}: {e}")))?;
509 if meta.len() > FILE_SENTINEL_MAX_BYTES {
510 return Err(ApiError::payload_too_large(format!(
511 "@file: sentinel: file size {} exceeds limit {}",
512 meta.len(),
513 FILE_SENTINEL_MAX_BYTES
514 )));
515 }
516 let bytes = tokio::fs::read(&path_canon)
517 .await
518 .map_err(|e| ApiError::engine(format!("@file: sentinel: read {path_str:?}: {e}")))?;
519 Ok(String::from_utf8_lossy(&bytes).trim_end().to_string())
521}
522
523async fn resolve_submit_value(
561 state: &AppState,
562 task_id: &StepId,
563 attempt: u32,
564 body_str: String,
565) -> Result<Value, ApiError> {
566 let declared = state
567 .engine
568 .agent_context_for(task_id, attempt)
569 .await
570 .and_then(|view| {
571 view.extra
572 .get(SUBMIT_FORMAT_KEY)
573 .cloned()
574 .map(|declared| (view.agent, declared))
575 });
576 let Some((agent, declared)) = declared else {
577 return Ok(Value::String(body_str));
578 };
579 match declared.as_str() {
580 Some(SUBMIT_FORMAT_JSON) => serde_json::from_str::<Value>(&body_str).map_err(|e| {
581 let preview: String = body_str.chars().take(SUBMIT_FORMAT_PREVIEW_CHARS).collect();
582 ApiError::unprocessable(format!(
583 "submit_format violation: agent {agent:?} declared \
584 `{SUBMIT_FORMAT_KEY}: {SUBMIT_FORMAT_JSON:?}`, but the submitted body is \
585 not valid JSON: {e} (body starts with: {preview:?})"
586 ))
587 }),
588 Some(SUBMIT_FORMAT_TEXT) => Ok(Value::String(body_str)),
589 _ => {
590 tracing::warn!(
591 agent = %agent,
592 declared = %declared,
593 "unknown `{SUBMIT_FORMAT_KEY}` value; folding the body as a string \
594 (recognized values: {SUBMIT_FORMAT_JSON:?}, {SUBMIT_FORMAT_TEXT:?})"
595 );
596 Ok(Value::String(body_str))
597 }
598 }
599}
600
601async fn check_verdict_contract(
622 state: &AppState,
623 task_id: &StepId,
624 channel: VerdictChannel,
625 value: &str,
626) -> Result<(), ApiError> {
627 let Some(contract) = state.engine.verdict_contract_for_task(task_id).await else {
628 return Ok(());
629 };
630 if contract.channel != channel {
631 return Ok(());
632 }
633 if contract.values.iter().any(|v| v == value) {
634 return Ok(());
635 }
636 Err(ApiError::unprocessable(format!(
637 "verdict contract violation: {value:?} is not a member of the declared values {:?}",
638 contract.values
639 )))
640}
641
642fn map_completion_result<T>(result: Result<T, EngineError>, context: &str) -> Result<T, ApiError> {
659 result.map_err(|e| match e {
660 EngineError::VerdictValueRejected { value, allowed } => ApiError::unprocessable(format!(
661 "verdict contract violation: {value:?} is not a member of the declared values {allowed:?}"
662 )),
663 EngineError::VerdictPartMissing { allowed } => ApiError::unprocessable(format!(
664 "verdict contract violation: no staged \"verdict\" part found for this attempt; declared values {allowed:?}"
665 )),
666 other => ApiError::engine(format!("{context}: {other}")),
667 })
668}
669
670#[derive(Debug, Deserialize, Default)]
701pub struct SubmitQuery {
702 #[serde(default)]
706 pub ok: Option<bool>,
707 #[serde(default)]
717 pub verdict: Option<String>,
718}
719
720fn resolve_submit_outcome(
732 verdict: Option<&str>,
733 ok: Option<bool>,
734) -> Result<SubmitOutcome, String> {
735 match verdict {
736 None => Ok(if ok.unwrap_or(true) {
737 SubmitOutcome::Pass
738 } else {
739 SubmitOutcome::Blocked
740 }),
741 Some(v) => match v {
742 "pass" => {
743 if ok == Some(false) {
744 Err(
745 "conflicting signal: verdict=pass with ok=false; drop one of them"
746 .to_string(),
747 )
748 } else {
749 Ok(SubmitOutcome::Pass)
750 }
751 }
752 "blocked" => {
753 if ok == Some(true) {
754 Err(
755 "conflicting signal: verdict=blocked with ok=true; drop one of them"
756 .to_string(),
757 )
758 } else {
759 Ok(SubmitOutcome::Blocked)
760 }
761 }
762 "skip" => {
763 if ok == Some(false) {
764 Err(
765 "conflicting signal: verdict=skip with ok=false; drop one of them"
766 .to_string(),
767 )
768 } else {
769 Ok(SubmitOutcome::Skip)
770 }
771 }
772 other => Err(format!(
773 "verdict must be one of: pass, blocked, skip (got {other:?})"
774 )),
775 },
776 }
777}
778
779pub async fn worker_submit(
785 State(state): State<AppState>,
786 headers: HeaderMap,
787 Query(q): Query<SubmitQuery>,
788 body: axum::body::Bytes,
789) -> Result<StatusCode, ApiError> {
790 let bearer = extract_bearer_raw(&headers)?;
793 let task_id = if let Some(handle) = parse_worker_handle(&bearer) {
794 state
795 .engine
796 .task_id_from_handle(handle)
797 .await
798 .map_err(map_handle_lookup_err)?
799 } else {
800 let token = CapToken::decode(bearer.trim())
801 .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
802 state
803 .engine
804 .task_id_from_token(&token)
805 .await
806 .map_err(|e| ApiError::engine(format!("task_id_from_token: {e}")))?
807 };
808 let attempt = state
809 .engine
810 .task_attempt(&task_id)
811 .await
812 .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?;
813 reject_if_run_terminal(&state, &task_id, attempt).await?;
816 let body_str = String::from_utf8_lossy(&body).trim_end().to_string();
821 let body_str = resolve_file_sentinel(&state, &task_id, attempt, body_str).await?;
824 let value = resolve_submit_value(&state, &task_id, attempt, body_str).await?;
842
843 let outcome =
852 resolve_submit_outcome(q.verdict.as_deref(), q.ok).map_err(ApiError::bad_request)?;
853 let submit_result = state
854 .engine
855 .submit_worker_result_trusted(&task_id, attempt, value, outcome)
856 .await;
857 map_completion_result(submit_result, "submit_worker_result_trusted")?;
858 Ok(StatusCode::NO_CONTENT)
859}
860
861#[derive(Debug, Deserialize)]
863pub struct ArtifactQuery {
864 pub name: String,
871}
872
873pub async fn worker_artifact(
901 State(state): State<AppState>,
902 headers: HeaderMap,
903 Query(q): Query<ArtifactQuery>,
904 body: axum::body::Bytes,
905) -> Result<StatusCode, ApiError> {
906 let name = q.name.trim();
907 if name.is_empty() {
908 return Err(ApiError::bad_request("name must not be empty".into()));
909 }
910 let name = name.to_string();
911
912 let bearer = extract_bearer_raw(&headers)?;
913 let task_id = if let Some(handle) = parse_worker_handle(&bearer) {
914 state
915 .engine
916 .task_id_from_handle(handle)
917 .await
918 .map_err(map_handle_lookup_err)?
919 } else {
920 let token = CapToken::decode(bearer.trim())
921 .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
922 state
923 .engine
924 .task_id_from_token(&token)
925 .await
926 .map_err(|e| ApiError::engine(format!("task_id_from_token: {e}")))?
927 };
928 let attempt = state
929 .engine
930 .task_attempt(&task_id)
931 .await
932 .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?;
933 reject_if_run_terminal(&state, &task_id, attempt).await?;
936 let body_str = String::from_utf8_lossy(&body).trim_end().to_string();
937 let body_str = resolve_file_sentinel(&state, &task_id, attempt, body_str).await?;
939 if name == "verdict" {
945 check_verdict_contract(&state, &task_id, VerdictChannel::Part, &body_str).await?;
946 }
947 let value = Value::String(body_str);
948
949 state
950 .engine
951 .stage_worker_artifact_trusted(&task_id, attempt, name, value)
952 .await
953 .map_err(|e| ApiError::engine(format!("stage_worker_artifact_trusted: {e}")))?;
954 Ok(StatusCode::NO_CONTENT)
955}
956
957#[derive(Debug, Deserialize, schemars::JsonSchema)]
964pub struct DegradationBody {
965 pub tool: String,
967 pub error: String,
969 pub fallback: String,
971 #[serde(default)]
973 pub note: Option<String>,
974}
975
976pub async fn worker_degradation(
1004 State(state): State<AppState>,
1005 headers: HeaderMap,
1006 Json(body): Json<DegradationBody>,
1007) -> Result<StatusCode, ApiError> {
1008 let bearer = extract_bearer_raw(&headers)?;
1009 let task_id = if let Some(handle) = parse_worker_handle(&bearer) {
1010 state
1011 .engine
1012 .task_id_from_handle(handle)
1013 .await
1014 .map_err(map_handle_lookup_err)?
1015 } else {
1016 let token = CapToken::decode(bearer.trim())
1017 .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
1018 state
1019 .engine
1020 .task_id_from_token(&token)
1021 .await
1022 .map_err(|e| ApiError::engine(format!("task_id_from_token: {e}")))?
1023 };
1024 let attempt = state
1025 .engine
1026 .task_attempt(&task_id)
1027 .await
1028 .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?;
1029 reject_if_run_terminal(&state, &task_id, attempt).await?;
1032
1033 let tid = task_id.clone();
1038 let (run_id_str, agent) = match state
1039 .engine
1040 .with_state("worker_degradation_run_lookup", move |s| {
1041 s.agent_ctx.get(&(tid, attempt)).and_then(|e| {
1042 e.view
1043 .run_id
1044 .clone()
1045 .map(|run_id| (run_id, e.view.agent.clone()))
1046 })
1047 })
1048 .await
1049 {
1050 Ok(Some(pair)) => pair,
1051 _ => {
1052 tracing::warn!(%task_id, "worker_degradation: no run linkage for this task; entry dropped");
1053 return Ok(StatusCode::NO_CONTENT);
1054 }
1055 };
1056 let Ok(run_id) = RunId::parse(run_id_str) else {
1057 tracing::warn!(%task_id, "worker_degradation: run_id failed to parse; entry dropped");
1058 return Ok(StatusCode::NO_CONTENT);
1059 };
1060
1061 mlua_swarm::store::trace::TraceHandle::new(run_id.clone(), state.run_trace_store.clone())
1065 .append(
1066 mlua_swarm::store::trace::kind::WORKER_DEGRADATION,
1067 Some(agent.as_str()),
1068 Some(attempt),
1069 serde_json::json!({
1070 "tool": body.tool.as_str(),
1071 "error": body.error.as_str(),
1072 "fallback": body.fallback.as_str(),
1073 }),
1074 )
1075 .await;
1076
1077 let entry = DegradationEntry {
1078 tool: body.tool,
1079 error: body.error,
1080 fallback: body.fallback,
1081 note: body.note,
1082 step_ref: Some(agent),
1083 attempt: Some(attempt),
1084 at: crate::tasks::now_secs(),
1085 };
1086 match state.run_store.append_degradation(&run_id, entry).await {
1087 Ok(()) => Ok(StatusCode::NO_CONTENT),
1088 Err(RunStoreError::NotFound(_)) => {
1089 tracing::warn!(%task_id, %run_id, "worker_degradation: run not found in run_store; entry dropped");
1090 Ok(StatusCode::NO_CONTENT)
1091 }
1092 Err(e) => Err(ApiError::engine(format!("append_degradation: {e}"))),
1093 }
1094}
1095
1096#[derive(Debug, Deserialize, schemars::JsonSchema)]
1107pub struct StatsBody {
1108 #[serde(default)]
1112 pub worker_kind: Option<String>,
1113 #[serde(default)]
1115 pub model: Option<String>,
1116 #[serde(default)]
1118 pub usage: Option<mlua_swarm::store::trace::TokenUsage>,
1119 #[serde(default)]
1121 pub num_turns: Option<u32>,
1122 #[serde(default)]
1124 pub adapter_data: Option<Value>,
1125}
1126
1127pub async fn worker_stats(
1138 State(state): State<AppState>,
1139 headers: HeaderMap,
1140 Json(body): Json<StatsBody>,
1141) -> Result<StatusCode, ApiError> {
1142 let bearer = extract_bearer_raw(&headers)?;
1143 let task_id = if let Some(handle) = parse_worker_handle(&bearer) {
1144 state
1145 .engine
1146 .task_id_from_handle(handle)
1147 .await
1148 .map_err(map_handle_lookup_err)?
1149 } else {
1150 let token = CapToken::decode(bearer.trim())
1151 .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
1152 state
1153 .engine
1154 .task_id_from_token(&token)
1155 .await
1156 .map_err(|e| ApiError::engine(format!("task_id_from_token: {e}")))?
1157 };
1158 let attempt = state
1159 .engine
1160 .task_attempt(&task_id)
1161 .await
1162 .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?;
1163 reject_if_run_terminal(&state, &task_id, attempt).await?;
1166
1167 let stats = mlua_swarm::store::trace::WorkerStats {
1168 worker_kind: Some(body.worker_kind.unwrap_or_else(|| "operator".to_string())),
1169 model: body.model,
1170 usage: body.usage,
1171 num_turns: body.num_turns,
1172 adapter_data: body.adapter_data,
1173 };
1174 state
1175 .engine
1176 .record_worker_stats(&task_id, attempt, stats)
1177 .await;
1178 Ok(StatusCode::NO_CONTENT)
1179}
1180
1181async fn reject_if_run_terminal(
1198 state: &AppState,
1199 task_id: &StepId,
1200 attempt: u32,
1201) -> Result<(), ApiError> {
1202 let tid = task_id.clone();
1203 let run_id_str = match state
1204 .engine
1205 .with_state("worker_terminal_run_guard", move |s| {
1206 s.agent_ctx
1207 .get(&(tid, attempt))
1208 .and_then(|e| e.view.run_id.clone())
1209 })
1210 .await
1211 {
1212 Ok(Some(rid)) => rid,
1213 _ => return Ok(()),
1214 };
1215 let Ok(run_id) = RunId::parse(run_id_str) else {
1216 return Ok(());
1217 };
1218 let Ok(rec) = state.run_store.get(&run_id).await else {
1219 return Ok(());
1220 };
1221 match rec.status {
1222 RunStatus::Done | RunStatus::Failed | RunStatus::Interrupted | RunStatus::Cancelled => {
1223 Err(ApiError::gone(format!(
1224 "run {run_id} is already terminal ({:?}): this attempt's output cannot be \
1225 delivered to a flow context; re-kick the task (POST /v1/tasks/:id/runs) and \
1226 fetch a fresh prompt",
1227 rec.status
1228 )))
1229 }
1230 RunStatus::Pending | RunStatus::Running => Ok(()),
1231 }
1232}
1233
1234#[derive(Debug, Deserialize)]
1239pub struct PromptSystemQuery {
1240 pub task_id: StepId,
1243 pub attempt: u32,
1245}
1246
1247pub async fn worker_prompt_system(
1257 State(state): State<AppState>,
1258 headers: HeaderMap,
1259 Query(q): Query<PromptSystemQuery>,
1260) -> Result<impl axum::response::IntoResponse, ApiError> {
1261 let task_id = q.task_id;
1262 let attempt = q.attempt;
1263 let bearer = extract_bearer_raw(&headers)?;
1264 if let Some(handle) = parse_worker_handle(&bearer) {
1265 let resolved = state
1266 .engine
1267 .task_id_from_handle(handle)
1268 .await
1269 .map_err(map_handle_lookup_err)?;
1270 if resolved != task_id {
1271 return Err(ApiError::bad_request(format!(
1272 "handle {handle} is bound to task {resolved}, not {task_id}"
1273 )));
1274 }
1275 } else {
1276 let token = CapToken::decode(bearer.trim())
1277 .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
1278 state
1279 .engine
1280 .verify_token_for_task(&token, mlua_swarm::Verb::FetchPrompt, &task_id)
1281 .await
1282 .map_err(|e| ApiError::engine(format!("verify_token_for_task: {e}")))?;
1283 }
1284 let system = state
1285 .engine
1286 .raw_system_prompt(&task_id, attempt)
1287 .await
1288 .map_err(|e| ApiError::engine(format!("raw_system_prompt: {e}")))?
1289 .ok_or_else(|| {
1290 ApiError::not_found(format!(
1291 "no baked system prompt for task {task_id} attempt {attempt}"
1292 ))
1293 })?;
1294 Ok((
1295 [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
1296 system,
1297 ))
1298}
1299
1300#[derive(Debug, serde::Serialize)]
1302pub struct AgentRenderSizeResponse {
1303 pub agent: String,
1305 pub last_rendered_bytes: Option<usize>,
1309}
1310
1311pub async fn agent_render_size(
1321 State(state): State<AppState>,
1322 axum::extract::Path(name): axum::extract::Path<String>,
1323) -> Json<AgentRenderSizeResponse> {
1324 let last_rendered_bytes = state.engine.agent_last_rendered_size(&name).await;
1325 Json(AgentRenderSizeResponse {
1326 agent: name,
1327 last_rendered_bytes,
1328 })
1329}
1330
1331fn extract_bearer_raw(headers: &HeaderMap) -> Result<String, ApiError> {
1335 let v = headers
1336 .get(AUTHORIZATION)
1337 .ok_or_else(|| ApiError::bad_request("missing Authorization header".into()))?
1338 .to_str()
1339 .map_err(|_| ApiError::bad_request("invalid Authorization header encoding".into()))?;
1340 let s = v
1341 .strip_prefix("Bearer ")
1342 .ok_or_else(|| ApiError::bad_request("Authorization must be 'Bearer <token>'".into()))?
1343 .trim();
1344 if s.is_empty() {
1345 return Err(ApiError::bad_request("Bearer is empty".into()));
1346 }
1347 Ok(s.to_string())
1348}
1349
1350fn map_handle_lookup_err(e: EngineError) -> ApiError {
1363 match e {
1364 EngineError::TokenNotFound(_) => ApiError::gone(
1365 "worker handle is no longer valid (the engine's in-flight state was reset, \
1366 e.g. by a server restart): re-kick the task (POST /v1/tasks/:id/runs) and \
1367 fetch a fresh prompt/handle"
1368 .to_string(),
1369 ),
1370 other => ApiError::engine(format!("task_id_from_handle: {other}")),
1371 }
1372}
1373
1374fn parse_worker_handle(s: &str) -> Option<&str> {
1378 let s = s.trim();
1379 if s.starts_with("wh-")
1380 && s.len() >= 5
1381 && s.len() <= 64
1382 && s[3..].chars().all(|c| c.is_ascii_alphanumeric())
1383 {
1384 Some(s)
1385 } else {
1386 None
1387 }
1388}
1389
1390fn decode_worker_bearer(headers: &HeaderMap) -> Result<CapToken, ApiError> {
1394 let v = headers
1395 .get(AUTHORIZATION)
1396 .ok_or_else(|| ApiError::bad_request("missing Authorization header".into()))?
1397 .to_str()
1398 .map_err(|_| ApiError::bad_request("invalid Authorization header encoding".into()))?;
1399 let encoded = v
1400 .strip_prefix("Bearer ")
1401 .ok_or_else(|| ApiError::bad_request("Authorization must be 'Bearer <token>'".into()))?
1402 .trim();
1403 if encoded.is_empty() {
1404 return Err(ApiError::bad_request("Bearer token is empty".into()));
1405 }
1406 CapToken::decode(encoded).map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))
1407}
1408
1409#[cfg(test)]
1414mod tests {
1415 use super::*;
1416 use axum::response::IntoResponse;
1417 use mlua_swarm::core::agent_context::AgentContextView;
1418 use mlua_swarm::core::config::EngineCfg;
1419 use mlua_swarm::core::engine::Engine;
1420 use mlua_swarm::store::output::{InMemoryOutputStore, OutputStore};
1421 use mlua_swarm::store::run::{InMemoryRunStore, RunRecord, RunStatus, RunStore, StepEntry};
1422 use mlua_swarm::store::task::InMemoryTaskStore;
1423 use mlua_swarm::{RunId, StepId, TaskId};
1424 use serde_json::json;
1425 use std::collections::HashMap;
1426 use std::sync::Arc;
1427 use tokio::sync::Mutex;
1428
1429 fn test_state(data_store: Arc<dyn OutputStore>, run_store: Arc<dyn RunStore>) -> AppState {
1435 let engine = Engine::new(EngineCfg::default());
1436 let compiler = mlua_swarm::Compiler::new(crate::default_registry());
1437 let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
1438 AppState {
1439 engine,
1440 sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
1441 task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
1442 ws_operator_factory: None,
1443 data_store,
1444 operator_sessions: Arc::new(Mutex::new(HashMap::new())),
1445 roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
1446 task_store: Arc::new(InMemoryTaskStore::new()),
1447 run_store,
1448 replay_store: Arc::new(mlua_swarm::store::replay::InMemoryReplayStore::new()),
1449 run_trace_store: Arc::new(mlua_swarm::store::trace::InMemoryRunTraceStore::new()),
1450 base_url: None,
1451 sync_timeout_secs: 300,
1452 }
1453 }
1454
1455 #[test]
1459 fn resolve_submit_outcome_absent_verdict_preserves_pre_gh76_wire() {
1460 assert!(matches!(
1462 resolve_submit_outcome(None, None),
1463 Ok(SubmitOutcome::Pass)
1464 ));
1465 assert!(matches!(
1466 resolve_submit_outcome(None, Some(true)),
1467 Ok(SubmitOutcome::Pass)
1468 ));
1469 assert!(matches!(
1470 resolve_submit_outcome(None, Some(false)),
1471 Ok(SubmitOutcome::Blocked)
1472 ));
1473 }
1474
1475 #[test]
1476 fn resolve_submit_outcome_verdict_pass_and_blocked_match_ok_bool_or_default() {
1477 assert!(matches!(
1478 resolve_submit_outcome(Some("pass"), None),
1479 Ok(SubmitOutcome::Pass)
1480 ));
1481 assert!(matches!(
1482 resolve_submit_outcome(Some("pass"), Some(true)),
1483 Ok(SubmitOutcome::Pass)
1484 ));
1485 assert!(resolve_submit_outcome(Some("pass"), Some(false)).is_err());
1486
1487 assert!(matches!(
1488 resolve_submit_outcome(Some("blocked"), None),
1489 Ok(SubmitOutcome::Blocked)
1490 ));
1491 assert!(matches!(
1492 resolve_submit_outcome(Some("blocked"), Some(false)),
1493 Ok(SubmitOutcome::Blocked)
1494 ));
1495 assert!(resolve_submit_outcome(Some("blocked"), Some(true)).is_err());
1496 }
1497
1498 #[test]
1499 fn resolve_submit_outcome_verdict_skip_is_ok_true_only() {
1500 assert!(matches!(
1501 resolve_submit_outcome(Some("skip"), None),
1502 Ok(SubmitOutcome::Skip)
1503 ));
1504 assert!(matches!(
1505 resolve_submit_outcome(Some("skip"), Some(true)),
1506 Ok(SubmitOutcome::Skip)
1507 ));
1508 let err = resolve_submit_outcome(Some("skip"), Some(false))
1510 .expect_err("skip + ok=false must be a conflict");
1511 assert!(
1512 err.contains("conflict") || err.contains("conflicting"),
1513 "err should name the conflict: {err}"
1514 );
1515 }
1516
1517 #[test]
1518 fn resolve_submit_outcome_invalid_verdict_names_valid_set() {
1519 let err = resolve_submit_outcome(Some("bogus"), None)
1520 .expect_err("unknown verdict must be an error");
1521 assert!(
1522 err.contains("pass") && err.contains("blocked") && err.contains("skip"),
1523 "err should enumerate the valid tier set: {err}"
1524 );
1525 }
1526
1527 #[test]
1534 fn stats_body_schema_matches_worker_stats_property_set() {
1535 fn property_names<T: schemars::JsonSchema>() -> std::collections::BTreeSet<String> {
1536 let schema = serde_json::to_value(schemars::schema_for!(T))
1537 .expect("schema must serialize as JSON");
1538 schema
1539 .get("properties")
1540 .and_then(|p| p.as_object())
1541 .expect("schema must expose a properties object")
1542 .keys()
1543 .cloned()
1544 .collect()
1545 }
1546
1547 assert_eq!(
1548 property_names::<StatsBody>(),
1549 property_names::<mlua_swarm::store::trace::WorkerStats>(),
1550 "StatsBody and WorkerStats must expose the same property set"
1551 );
1552 }
1553
1554 async fn append_final(
1555 data_store: &Arc<dyn OutputStore>,
1556 task_id: &str,
1557 producer: &str,
1558 value: Value,
1559 ) {
1560 data_store
1561 .append(
1562 task_id,
1563 1,
1564 producer,
1565 OutputEvent::Final {
1566 content: ContentRef::Inline { value },
1567 ok: true,
1568 },
1569 vec![],
1570 )
1571 .await
1572 .expect("append final");
1573 }
1574
1575 fn step_entry(step_id: &StepId, step_ref: &str) -> StepEntry {
1576 StepEntry::basic(
1577 step_id.clone(),
1578 Some(step_ref.to_string()),
1579 Some("passed".to_string()),
1580 None,
1581 0,
1582 )
1583 }
1584
1585 fn run_record(task_id: &TaskId, run_id: &RunId, step_entries: Vec<StepEntry>) -> RunRecord {
1586 RunRecord {
1587 id: run_id.clone(),
1588 task_id: task_id.clone(),
1589 status: RunStatus::Running,
1590 step_entries,
1591 degradations: Vec::new(),
1592 operator_sid: None,
1593 result_ref: None,
1594 input_json: None,
1595 created_at: 0,
1596 updated_at: 0,
1597 }
1598 }
1599
1600 fn consumer_payload(consumer_step_id: &StepId, run_id: &RunId) -> WorkerPayload {
1601 WorkerPayload {
1602 task_id: consumer_step_id.clone(),
1603 attempt: 1,
1604 agent: "consumer".to_string(),
1605 system: None,
1606 prompt: String::new(),
1607 context: Some(AgentContextView {
1608 task_id: consumer_step_id.to_string(),
1609 agent: "consumer".to_string(),
1610 attempt: 1,
1611 run_id: Some(run_id.to_string()),
1612 ..Default::default()
1613 }),
1614 system_ref: None,
1615 }
1616 }
1617
1618 #[tokio::test]
1623 async fn context_policy_unspecified_yields_every_submitted_step() {
1624 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1625 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1626 let task_id = TaskId::new();
1627 let run_id = RunId::new();
1628 let planner_id = StepId::new();
1629 let coder_id = StepId::new();
1630
1631 append_final(
1632 &data_store,
1633 planner_id.as_str(),
1634 "planner",
1635 json!({"plan": "x"}),
1636 )
1637 .await;
1638 append_final(
1639 &data_store,
1640 coder_id.as_str(),
1641 "coder",
1642 json!({"code": "y"}),
1643 )
1644 .await;
1645 run_store
1646 .create(run_record(
1647 &task_id,
1648 &run_id,
1649 vec![
1650 step_entry(&planner_id, "planner"),
1651 step_entry(&coder_id, "coder"),
1652 ],
1653 ))
1654 .await
1655 .expect("create run");
1656
1657 let state = test_state(data_store, run_store);
1658 let consumer_id = StepId::new();
1659 let mut payload = consumer_payload(&consumer_id, &run_id);
1660 assemble_step_pointers(&state, &mut payload).await;
1661
1662 let names: Vec<&str> = payload
1663 .context
1664 .as_ref()
1665 .expect("context")
1666 .steps
1667 .iter()
1668 .map(|p| p.name.as_str())
1669 .collect();
1670 assert!(names.contains(&"planner"), "names: {names:?}");
1671 assert!(names.contains(&"coder"), "names: {names:?}");
1672 }
1673
1674 #[tokio::test]
1676 async fn context_policy_steps_include_list_filters_to_named_steps() {
1677 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1678 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1679 let task_id = TaskId::new();
1680 let run_id = RunId::new();
1681 let planner_id = StepId::new();
1682 let coder_id = StepId::new();
1683 append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1684 append_final(&data_store, coder_id.as_str(), "coder", json!("y")).await;
1685 run_store
1686 .create(run_record(
1687 &task_id,
1688 &run_id,
1689 vec![
1690 step_entry(&planner_id, "planner"),
1691 step_entry(&coder_id, "coder"),
1692 ],
1693 ))
1694 .await
1695 .expect("create run");
1696
1697 let state = test_state(data_store, run_store);
1698 let consumer_id = StepId::new();
1699 state
1700 .engine
1701 .with_state("test.seed_policy", {
1702 let consumer_id = consumer_id.clone();
1703 move |s| {
1704 s.agent_ctx.insert(
1705 (consumer_id, 1),
1706 mlua_swarm::core::state::AgentCtxEntry {
1707 policy: mlua_swarm_schema::ContextPolicy {
1708 steps: Some(vec!["planner".to_string()]),
1709 ..Default::default()
1710 },
1711 ..Default::default()
1712 },
1713 );
1714 }
1715 })
1716 .await
1717 .expect("seed policy");
1718
1719 let mut payload = consumer_payload(&consumer_id, &run_id);
1720 assemble_step_pointers(&state, &mut payload).await;
1721
1722 let names: Vec<&str> = payload
1723 .context
1724 .as_ref()
1725 .expect("context")
1726 .steps
1727 .iter()
1728 .map(|p| p.name.as_str())
1729 .collect();
1730 assert_eq!(names, vec!["planner"], "names: {names:?}");
1731 }
1732
1733 #[tokio::test]
1735 async fn context_policy_steps_empty_list_yields_no_pointers() {
1736 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1737 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1738 let task_id = TaskId::new();
1739 let run_id = RunId::new();
1740 let planner_id = StepId::new();
1741 append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1742 run_store
1743 .create(run_record(
1744 &task_id,
1745 &run_id,
1746 vec![step_entry(&planner_id, "planner")],
1747 ))
1748 .await
1749 .expect("create run");
1750
1751 let state = test_state(data_store, run_store);
1752 let consumer_id = StepId::new();
1753 state
1754 .engine
1755 .with_state("test.seed_policy", {
1756 let consumer_id = consumer_id.clone();
1757 move |s| {
1758 s.agent_ctx.insert(
1759 (consumer_id, 1),
1760 mlua_swarm::core::state::AgentCtxEntry {
1761 policy: mlua_swarm_schema::ContextPolicy {
1762 steps: Some(vec![]),
1763 ..Default::default()
1764 },
1765 ..Default::default()
1766 },
1767 );
1768 }
1769 })
1770 .await
1771 .expect("seed policy");
1772
1773 let mut payload = consumer_payload(&consumer_id, &run_id);
1774 assemble_step_pointers(&state, &mut payload).await;
1775
1776 assert!(payload.context.expect("context").steps.is_empty());
1777 }
1778
1779 #[tokio::test]
1781 async fn context_policy_steps_exclude_wins_over_steps() {
1782 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1783 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1784 let task_id = TaskId::new();
1785 let run_id = RunId::new();
1786 let planner_id = StepId::new();
1787 let coder_id = StepId::new();
1788 append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1789 append_final(&data_store, coder_id.as_str(), "coder", json!("y")).await;
1790 run_store
1791 .create(run_record(
1792 &task_id,
1793 &run_id,
1794 vec![
1795 step_entry(&planner_id, "planner"),
1796 step_entry(&coder_id, "coder"),
1797 ],
1798 ))
1799 .await
1800 .expect("create run");
1801
1802 let state = test_state(data_store, run_store);
1803 let consumer_id = StepId::new();
1804 state
1805 .engine
1806 .with_state("test.seed_policy", {
1807 let consumer_id = consumer_id.clone();
1808 move |s| {
1809 s.agent_ctx.insert(
1810 (consumer_id, 1),
1811 mlua_swarm::core::state::AgentCtxEntry {
1812 policy: mlua_swarm_schema::ContextPolicy {
1813 steps: Some(vec!["planner".to_string(), "coder".to_string()]),
1814 steps_exclude: vec!["planner".to_string()],
1815 ..Default::default()
1816 },
1817 ..Default::default()
1818 },
1819 );
1820 }
1821 })
1822 .await
1823 .expect("seed policy");
1824
1825 let mut payload = consumer_payload(&consumer_id, &run_id);
1826 assemble_step_pointers(&state, &mut payload).await;
1827
1828 let names: Vec<&str> = payload
1829 .context
1830 .as_ref()
1831 .expect("context")
1832 .steps
1833 .iter()
1834 .map(|p| p.name.as_str())
1835 .collect();
1836 assert_eq!(names, vec!["coder"], "names: {names:?}");
1837 }
1838
1839 #[tokio::test]
1848 async fn in_flight_step_output_is_visible_before_run_finalizes() {
1849 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1850 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1851 let task_id = TaskId::new();
1852 let run_id = RunId::new();
1853 let step1_id = StepId::new();
1854 append_final(
1855 &data_store,
1856 step1_id.as_str(),
1857 "step1",
1858 json!({"step1_out": "hi"}),
1859 )
1860 .await;
1861 let mut run = run_record(&task_id, &run_id, vec![step_entry(&step1_id, "step1")]);
1862 run.status = RunStatus::Running;
1863 run.result_ref = None; run_store.create(run).await.expect("create run");
1865
1866 let state = test_state(data_store, run_store);
1867 let consumer_id = StepId::new();
1868 let mut payload = consumer_payload(&consumer_id, &run_id);
1869 assemble_step_pointers(&state, &mut payload).await;
1870
1871 let steps = &payload.context.expect("context").steps;
1872 assert_eq!(steps.len(), 1);
1873 assert_eq!(steps[0].name, "step1");
1874 }
1875
1876 #[tokio::test]
1880 async fn self_agent_name_is_always_excluded() {
1881 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1882 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1883 let task_id = TaskId::new();
1884 let run_id = RunId::new();
1885 let planner_id = StepId::new();
1886 let consumer_prior_id = StepId::new();
1887 append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1888 append_final(
1889 &data_store,
1890 consumer_prior_id.as_str(),
1891 "consumer",
1892 json!("self"),
1893 )
1894 .await;
1895 run_store
1896 .create(run_record(
1897 &task_id,
1898 &run_id,
1899 vec![
1900 step_entry(&planner_id, "planner"),
1901 step_entry(&consumer_prior_id, "consumer"),
1902 ],
1903 ))
1904 .await
1905 .expect("create run");
1906
1907 let state = test_state(data_store, run_store);
1908 let consumer_id = StepId::new();
1909 let mut payload = consumer_payload(&consumer_id, &run_id);
1910 assemble_step_pointers(&state, &mut payload).await;
1911
1912 let names: Vec<&str> = payload
1913 .context
1914 .as_ref()
1915 .expect("context")
1916 .steps
1917 .iter()
1918 .map(|p| p.name.as_str())
1919 .collect();
1920 assert!(!names.contains(&"consumer"), "names: {names:?}");
1921 assert!(names.contains(&"planner"), "names: {names:?}");
1922 }
1923
1924 #[tokio::test]
1928 async fn step_pointer_serializes_with_no_preview_or_content_bytes() {
1929 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1930 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1931 let task_id = TaskId::new();
1932 let run_id = RunId::new();
1933 let planner_id = StepId::new();
1934 append_final(
1935 &data_store,
1936 planner_id.as_str(),
1937 "planner",
1938 json!({"plan": "do the thing, at length".repeat(50)}),
1939 )
1940 .await;
1941 run_store
1942 .create(run_record(
1943 &task_id,
1944 &run_id,
1945 vec![step_entry(&planner_id, "planner")],
1946 ))
1947 .await
1948 .expect("create run");
1949
1950 let state = test_state(data_store, run_store);
1951 let consumer_id = StepId::new();
1952 let mut payload = consumer_payload(&consumer_id, &run_id);
1953 assemble_step_pointers(&state, &mut payload).await;
1954
1955 let steps = &payload.context.expect("context").steps;
1956 assert_eq!(steps.len(), 1);
1957 let json_value = serde_json::to_value(&steps[0]).expect("serialize StepPointer");
1958 let obj = json_value.as_object().expect("object");
1959 for forbidden in ["preview", "content", "value", "bytes"] {
1960 assert!(
1961 !obj.contains_key(forbidden),
1962 "StepPointer must not carry a {forbidden:?} field: {obj:?}"
1963 );
1964 }
1965 assert!(obj.contains_key("name"));
1966 assert!(obj.contains_key("size_bytes"));
1967 assert!(obj.contains_key("content_url"));
1968 assert!(obj.contains_key("sha256"));
1969 }
1970
1971 fn declared_name_bp() -> mlua_swarm::blueprint::Blueprint {
1979 use mlua_flow_ir::{Expr, Node};
1980 use mlua_swarm::blueprint::{
1981 current_schema_version, AgentDef, AgentKind, AgentMeta, Blueprint, BlueprintMetadata,
1982 CompilerHints, CompilerStrategy,
1983 };
1984 Blueprint {
1985 schema_version: current_schema_version(),
1986 id: "worker-test-declared-name-bp".into(),
1987 flow: Node::Step {
1988 ref_: "planner".to_string(),
1989 in_: Expr::Path {
1990 at: "$.in".parse().expect("literal test path: $.in"),
1991 },
1992 out: Expr::Path {
1993 at: "$.plan".parse().expect("literal test path: $.plan"),
1994 },
1995 },
1996 agents: vec![AgentDef {
1997 name: "planner".to_string(),
1998 kind: AgentKind::RustFn,
1999 spec: json!({"fn_id": "planner"}),
2000 profile: None,
2001 meta: Some(AgentMeta {
2002 projection_name: Some("plan-out".to_string()),
2003 ..Default::default()
2004 }),
2005 runner: None,
2006 runner_ref: None,
2007 verdict: None,
2008 lints: None,
2009 }],
2010 operators: vec![],
2011 metas: vec![],
2012 hints: CompilerHints::default(),
2013 strategy: CompilerStrategy::default(),
2014 metadata: BlueprintMetadata::default(),
2015 spawner_hints: Default::default(),
2016 default_agent_kind: AgentKind::Operator,
2017 default_operator_kind: None,
2018 default_init_ctx: None,
2019 default_agent_ctx: None,
2020 default_context_policy: None,
2021 projection_placement: None,
2022 audits: vec![],
2023 degradation_policy: None,
2024 runners: vec![],
2025 default_runner: None,
2026 subprocesses: vec![],
2027 check_policy: None,
2028 blueprint_ref_includes: Vec::new(),
2029 }
2030 }
2031
2032 #[tokio::test]
2038 async fn declared_projection_name_pointer_name_is_canonical_and_policy_matches_it() {
2039 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2040 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2041 let task_id = TaskId::new();
2042 let run_id = RunId::new();
2043 let planner_id = StepId::new();
2044
2045 append_final(
2048 &data_store,
2049 planner_id.as_str(),
2050 "plan-out",
2051 json!({"plan": "x"}),
2052 )
2053 .await;
2054 run_store
2055 .create(run_record(
2056 &task_id,
2057 &run_id,
2058 vec![step_entry(&planner_id, "planner")],
2059 ))
2060 .await
2061 .expect("create run");
2062
2063 let state = test_state(data_store, run_store);
2064
2065 let (naming, _warnings) =
2071 mlua_swarm::core::step_naming::StepNaming::from_blueprint(&declared_name_bp())
2072 .expect("no collision");
2073 let naming = Arc::new(naming);
2074 let consumer_id = StepId::new();
2075 state
2076 .engine
2077 .with_state("test.seed_step_naming", {
2078 let naming = naming.clone();
2079 let planner_id = planner_id.clone();
2080 let consumer_id = consumer_id.clone();
2081 move |s| {
2082 s.step_namings.insert(planner_id, naming.clone());
2083 s.step_namings.insert(consumer_id, naming);
2084 }
2085 })
2086 .await
2087 .expect("seed step naming");
2088 state
2089 .engine
2090 .with_state("test.seed_policy", {
2091 let consumer_id = consumer_id.clone();
2092 move |s| {
2093 s.agent_ctx.insert(
2094 (consumer_id, 1),
2095 mlua_swarm::core::state::AgentCtxEntry {
2096 policy: mlua_swarm_schema::ContextPolicy {
2097 steps: Some(vec!["plan-out".to_string()]),
2098 ..Default::default()
2099 },
2100 ..Default::default()
2101 },
2102 );
2103 }
2104 })
2105 .await
2106 .expect("seed policy");
2107
2108 let mut payload = consumer_payload(&consumer_id, &run_id);
2109 assemble_step_pointers(&state, &mut payload).await;
2110
2111 let steps = &payload.context.expect("context").steps;
2112 assert_eq!(steps.len(), 1, "steps: {steps:?}");
2113 assert_eq!(
2114 steps[0].name, "plan-out",
2115 "StepPointer.name must be the canonical name"
2116 );
2117 }
2118
2119 async fn seed_task_with_handle(
2129 state: &AppState,
2130 task_id: &StepId,
2131 agent: &str,
2132 attempt: u32,
2133 system: Option<String>,
2134 ) -> String {
2135 let handle = format!("wh-{}", mlua_swarm::types::secure_hex(4));
2136 let task_id = task_id.clone();
2137 let agent = agent.to_string();
2138 let handle_clone = handle.clone();
2139 state
2140 .engine
2141 .with_state("test.seed_task_with_handle", move |s| {
2142 let mut task = mlua_swarm::core::state::TaskState::new(
2143 task_id.clone(),
2144 mlua_swarm::core::state::TaskSpec {
2145 agent: agent.clone(),
2146 initial_directive: json!("x"),
2147 step_ctx: None,
2148 check_policy: None,
2149 },
2150 );
2151 task.attempt = attempt;
2152 s.tasks.insert(task_id.clone(), task);
2153 s.systems.insert((task_id.clone(), attempt), system);
2154 let token = CapToken {
2155 agent_id: agent,
2156 role: mlua_swarm::Role::Worker,
2157 scopes: vec!["*".to_string()],
2158 issued_at: 0,
2159 expire_at: u64::MAX,
2160 max_uses: None,
2161 nonce: format!("test-nonce-{task_id}"),
2162 sig_hex: String::new(),
2163 };
2164 let fp = token.fingerprint();
2165 s.tokens.insert(
2166 fp.clone(),
2167 mlua_swarm::core::state::CapTokenRecord {
2168 token,
2169 uses_left: None,
2170 revoked: false,
2171 task_id: Some(task_id),
2172 },
2173 );
2174 s.worker_handles.insert(handle_clone, fp);
2175 })
2176 .await
2177 .expect("seed_task_with_handle");
2178 handle
2179 }
2180
2181 fn bearer_headers(handle: &str) -> HeaderMap {
2182 let mut headers = HeaderMap::new();
2183 headers.insert(
2184 AUTHORIZATION,
2185 format!("Bearer {handle}").parse().expect("header value"),
2186 );
2187 headers
2188 }
2189
2190 #[tokio::test]
2194 async fn worker_prompt_system_returns_raw_bytes_for_baked_system() {
2195 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2196 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2197 let state = test_state(data_store, run_store);
2198 let task_id = StepId::new();
2199 let rendered = "# Hello\n\nThis is the baked system prompt.".to_string();
2200 let handle =
2201 seed_task_with_handle(&state, &task_id, "planner", 1, Some(rendered.clone())).await;
2202
2203 let resp = worker_prompt_system(
2204 State(state.clone()),
2205 bearer_headers(&handle),
2206 Query(PromptSystemQuery {
2207 task_id: task_id.clone(),
2208 attempt: 1,
2209 }),
2210 )
2211 .await
2212 .expect("worker_prompt_system")
2213 .into_response();
2214
2215 assert_eq!(resp.status(), StatusCode::OK);
2216 let content_type = resp
2217 .headers()
2218 .get(header::CONTENT_TYPE)
2219 .expect("content-type header")
2220 .to_str()
2221 .expect("ascii");
2222 assert_eq!(content_type, "text/plain; charset=utf-8");
2223 let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2224 .await
2225 .expect("body bytes");
2226 assert_eq!(body_bytes.as_ref(), rendered.as_bytes());
2227 }
2228
2229 #[tokio::test]
2232 async fn worker_prompt_system_404s_when_no_baked_system() {
2233 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2234 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2235 let state = test_state(data_store, run_store);
2236 let task_id = StepId::new();
2237 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2238
2239 let result = worker_prompt_system(
2240 State(state.clone()),
2241 bearer_headers(&handle),
2242 Query(PromptSystemQuery {
2243 task_id: task_id.clone(),
2244 attempt: 1,
2245 }),
2246 )
2247 .await;
2248 let err = match result {
2249 Ok(_) => panic!("expected 404 ApiError, got Ok"),
2250 Err(e) => e,
2251 };
2252 assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND);
2253 }
2254
2255 #[tokio::test]
2258 async fn worker_prompt_system_rejects_handle_task_mismatch() {
2259 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2260 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2261 let state = test_state(data_store, run_store);
2262 let task_id = StepId::new();
2263 let other_task_id = StepId::new();
2264 let handle =
2265 seed_task_with_handle(&state, &task_id, "planner", 1, Some("x".to_string())).await;
2266
2267 let result = worker_prompt_system(
2268 State(state.clone()),
2269 bearer_headers(&handle),
2270 Query(PromptSystemQuery {
2271 task_id: other_task_id,
2272 attempt: 1,
2273 }),
2274 )
2275 .await;
2276 let err = match result {
2277 Ok(_) => panic!("expected 400 ApiError for task mismatch, got Ok"),
2278 Err(e) => e,
2279 };
2280 assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
2281 }
2282
2283 #[tokio::test]
2287 async fn agent_render_size_returns_null_for_unknown_agent() {
2288 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2289 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2290 let state = test_state(data_store, run_store);
2291
2292 let Json(body) = agent_render_size(
2293 State(state.clone()),
2294 axum::extract::Path("never-dispatched".to_string()),
2295 )
2296 .await;
2297 assert_eq!(body.agent, "never-dispatched");
2298 assert_eq!(body.last_rendered_bytes, None);
2299 }
2300
2301 #[tokio::test]
2304 async fn agent_render_size_reports_last_rendered_bytes() {
2305 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2306 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2307 let state = test_state(data_store, run_store);
2308 let task_id = StepId::new();
2309 state
2310 .engine
2311 .with_state("test.seed_agent_ctx_for_bake", {
2312 let task_id = task_id.clone();
2313 move |s| {
2314 s.tasks.insert(
2315 task_id.clone(),
2316 mlua_swarm::core::state::TaskState::new(
2317 task_id,
2318 mlua_swarm::core::state::TaskSpec {
2319 agent: "coder".to_string(),
2320 initial_directive: json!("x"),
2321 step_ctx: None,
2322 check_policy: None,
2323 },
2324 ),
2325 );
2326 }
2327 })
2328 .await
2329 .expect("seed task");
2330 state
2331 .engine
2332 .bake_worker_system_prompt(&task_id, 1, Some("z".repeat(42)))
2333 .await
2334 .expect("bake_worker_system_prompt");
2335
2336 let Json(body) = agent_render_size(
2337 State(state.clone()),
2338 axum::extract::Path("coder".to_string()),
2339 )
2340 .await;
2341 assert_eq!(body.agent, "coder");
2342 assert_eq!(body.last_rendered_bytes, Some(42));
2343 }
2344
2345 #[tokio::test]
2353 async fn worker_artifact_stages_and_204s_for_valid_request() {
2354 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2355 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2356 let state = test_state(data_store, run_store);
2357 let task_id = StepId::new();
2358 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2359
2360 let status = worker_artifact(
2361 State(state.clone()),
2362 bearer_headers(&handle),
2363 Query(ArtifactQuery {
2364 name: "summary".to_string(),
2365 }),
2366 axum::body::Bytes::from_static(b"hello artifact\n"),
2367 )
2368 .await
2369 .expect("worker_artifact");
2370 assert_eq!(status, StatusCode::NO_CONTENT);
2371
2372 let tail = state.engine.output_tail(&task_id, 1).await;
2373 assert_eq!(tail.len(), 1, "tail: {tail:?}");
2374 match &tail[0] {
2375 OutputEvent::Artifact { name, content } => {
2376 assert_eq!(name, "summary");
2377 match content {
2378 ContentRef::Inline { value } => {
2379 assert_eq!(value, &json!("hello artifact"));
2380 }
2381 other => panic!("expected Inline content, got {other:?}"),
2382 }
2383 }
2384 other => panic!("expected Artifact event, got {other:?}"),
2385 }
2386 }
2387
2388 #[tokio::test]
2395 async fn worker_artifact_rejects_blank_name() {
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 let result = worker_artifact(
2403 State(state.clone()),
2404 bearer_headers(&handle),
2405 Query(ArtifactQuery {
2406 name: " ".to_string(),
2407 }),
2408 axum::body::Bytes::from_static(b"x"),
2409 )
2410 .await;
2411 let err = match result {
2412 Ok(_) => panic!("expected 400 ApiError for blank name, got Ok"),
2413 Err(e) => e,
2414 };
2415 assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
2416
2417 assert!(state.engine.output_tail(&task_id, 1).await.is_empty());
2419 }
2420
2421 #[tokio::test]
2427 async fn worker_artifact_staging_same_name_twice_appends_both_events_in_order() {
2428 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2429 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2430 let state = test_state(data_store, run_store);
2431 let task_id = StepId::new();
2432 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2433
2434 for body in [b"first".as_slice(), b"second".as_slice()] {
2435 worker_artifact(
2436 State(state.clone()),
2437 bearer_headers(&handle),
2438 Query(ArtifactQuery {
2439 name: "a".to_string(),
2440 }),
2441 axum::body::Bytes::copy_from_slice(body),
2442 )
2443 .await
2444 .expect("worker_artifact");
2445 }
2446
2447 let tail = state.engine.output_tail(&task_id, 1).await;
2448 assert_eq!(tail.len(), 2, "tail: {tail:?}");
2449 let values: Vec<&str> = tail
2450 .iter()
2451 .map(|ev| match ev {
2452 OutputEvent::Artifact {
2453 content: ContentRef::Inline { value },
2454 ..
2455 } => value.as_str().expect("string value"),
2456 other => panic!("expected Artifact/Inline event, got {other:?}"),
2457 })
2458 .collect();
2459 assert_eq!(values, vec!["first", "second"]);
2460 }
2461
2462 async fn link_task_to_run(state: &AppState, task_id: &StepId, attempt: u32, run_id: &RunId) {
2470 let tid = task_id.clone();
2471 let rid_str = run_id.to_string();
2472 state
2473 .engine
2474 .with_state("test.link_task_to_run", move |s| {
2475 let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2476 entry.view.run_id = Some(rid_str);
2477 s.agent_ctx.insert((tid, attempt), entry);
2478 })
2479 .await
2480 .expect("link_task_to_run");
2481 }
2482
2483 #[tokio::test]
2488 async fn submit_and_artifact_against_terminal_run_return_410() {
2489 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2490 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2491 let state = test_state(data_store, run_store.clone());
2492 let task_id = StepId::new();
2493 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2494
2495 let owner_task = TaskId::new();
2496 let run_id = RunId::new();
2497 let mut rec = run_record(&owner_task, &run_id, vec![]);
2498 rec.status = RunStatus::Failed;
2499 run_store.create(rec).await.expect("run create");
2500 link_task_to_run(&state, &task_id, 1, &run_id).await;
2501
2502 let err = worker_submit(
2503 State(state.clone()),
2504 bearer_headers(&handle),
2505 Query(SubmitQuery {
2506 ok: None,
2507 verdict: None,
2508 }),
2509 axum::body::Bytes::from_static(b"LATE OUTPUT"),
2510 )
2511 .await
2512 .expect_err("a submit against a Failed run must be rejected");
2513 assert_eq!(err.status, StatusCode::GONE);
2514 assert!(
2515 err.message.contains(&run_id.to_string()),
2516 "the 410 must name the terminal run: {}",
2517 err.message
2518 );
2519
2520 let err = worker_artifact(
2521 State(state.clone()),
2522 bearer_headers(&handle),
2523 Query(ArtifactQuery {
2524 name: "part.md".to_string(),
2525 }),
2526 axum::body::Bytes::from_static(b"LATE PART"),
2527 )
2528 .await
2529 .expect_err("an artifact staged against a Failed run must be rejected");
2530 assert_eq!(err.status, StatusCode::GONE);
2531
2532 let tail = state.engine.output_tail(&task_id, 1).await;
2534 assert!(tail.is_empty(), "rejected submits must not land: {tail:?}");
2535 }
2536
2537 #[tokio::test]
2541 async fn terminal_run_guard_is_fail_open() {
2542 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2543 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2544 let state = test_state(data_store, run_store.clone());
2545 let task_id = StepId::new();
2546 seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2547
2548 reject_if_run_terminal(&state, &task_id, 1)
2550 .await
2551 .expect("no linkage must fail open");
2552
2553 let unknown_run = RunId::new();
2555 link_task_to_run(&state, &task_id, 1, &unknown_run).await;
2556 reject_if_run_terminal(&state, &task_id, 1)
2557 .await
2558 .expect("unknown run must fail open");
2559
2560 let owner_task = TaskId::new();
2562 let live_run = RunId::new();
2563 run_store
2564 .create(run_record(&owner_task, &live_run, vec![]))
2565 .await
2566 .expect("run create");
2567 link_task_to_run(&state, &task_id, 1, &live_run).await;
2568 reject_if_run_terminal(&state, &task_id, 1)
2569 .await
2570 .expect("a Running run must pass the guard");
2571 }
2572
2573 fn degradation_body(tool: &str, note: Option<&str>) -> DegradationBody {
2578 DegradationBody {
2579 tool: tool.to_string(),
2580 error: "boom".to_string(),
2581 fallback: "used cached value".to_string(),
2582 note: note.map(str::to_string),
2583 }
2584 }
2585
2586 async fn link_task_to_run_with_agent(
2592 state: &AppState,
2593 task_id: &StepId,
2594 attempt: u32,
2595 run_id: &RunId,
2596 agent: &str,
2597 ) {
2598 let tid = task_id.clone();
2599 let rid_str = run_id.to_string();
2600 let agent = agent.to_string();
2601 state
2602 .engine
2603 .with_state("test.link_task_to_run_with_agent", move |s| {
2604 let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2605 entry.view.run_id = Some(rid_str);
2606 entry.view.agent = agent;
2607 s.agent_ctx.insert((tid, attempt), entry);
2608 })
2609 .await
2610 .expect("link_task_to_run_with_agent");
2611 }
2612
2613 #[tokio::test]
2618 async fn worker_degradation_persists_entry_when_run_tracked() {
2619 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2620 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2621 let state = test_state(data_store, run_store.clone());
2622 let task_id = StepId::new();
2623 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2624
2625 let owner_task = TaskId::new();
2626 let run_id = RunId::new();
2627 run_store
2628 .create(run_record(&owner_task, &run_id, vec![]))
2629 .await
2630 .expect("run create");
2631 link_task_to_run_with_agent(&state, &task_id, 1, &run_id, "planner").await;
2632
2633 let status = worker_degradation(
2634 State(state.clone()),
2635 bearer_headers(&handle),
2636 Json(degradation_body("web_search", Some("rate limited"))),
2637 )
2638 .await
2639 .expect("worker_degradation");
2640 assert_eq!(status, StatusCode::NO_CONTENT);
2641
2642 let rec = run_store.get(&run_id).await.expect("run get");
2643 assert_eq!(
2644 rec.degradations.len(),
2645 1,
2646 "degradations: {:?}",
2647 rec.degradations
2648 );
2649 let entry = &rec.degradations[0];
2650 assert_eq!(entry.tool, "web_search");
2651 assert_eq!(entry.error, "boom");
2652 assert_eq!(entry.fallback, "used cached value");
2653 assert_eq!(entry.note.as_deref(), Some("rate limited"));
2654 assert_eq!(entry.step_ref.as_deref(), Some("planner"));
2655 assert_eq!(entry.attempt, Some(1));
2656 assert!(entry.at > 0, "at must be a real timestamp: {}", entry.at);
2657 }
2658
2659 #[tokio::test]
2661 async fn worker_degradation_appends_in_order() {
2662 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2663 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2664 let state = test_state(data_store, run_store.clone());
2665 let task_id = StepId::new();
2666 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2667
2668 let owner_task = TaskId::new();
2669 let run_id = RunId::new();
2670 run_store
2671 .create(run_record(&owner_task, &run_id, vec![]))
2672 .await
2673 .expect("run create");
2674 link_task_to_run(&state, &task_id, 1, &run_id).await;
2675
2676 for tool in ["first_tool", "second_tool"] {
2677 worker_degradation(
2678 State(state.clone()),
2679 bearer_headers(&handle),
2680 Json(degradation_body(tool, None)),
2681 )
2682 .await
2683 .expect("worker_degradation");
2684 }
2685
2686 let rec = run_store.get(&run_id).await.expect("run get");
2687 let tools: Vec<&str> = rec.degradations.iter().map(|e| e.tool.as_str()).collect();
2688 assert_eq!(tools, vec!["first_tool", "second_tool"]);
2689 }
2690
2691 #[tokio::test]
2695 async fn worker_degradation_silent_ok_when_no_run_tracked() {
2696 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2697 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2698 let state = test_state(data_store, run_store);
2699 let task_id = StepId::new();
2700 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2701
2702 let status = worker_degradation(
2703 State(state.clone()),
2704 bearer_headers(&handle),
2705 Json(degradation_body("some_tool", None)),
2706 )
2707 .await
2708 .expect("worker_degradation must not error on missing run linkage");
2709 assert_eq!(status, StatusCode::NO_CONTENT);
2710 }
2711
2712 #[tokio::test]
2715 async fn worker_degradation_rejects_terminal_run() {
2716 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2717 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2718 let state = test_state(data_store, run_store.clone());
2719 let task_id = StepId::new();
2720 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2721
2722 let owner_task = TaskId::new();
2723 let run_id = RunId::new();
2724 let mut rec = run_record(&owner_task, &run_id, vec![]);
2725 rec.status = RunStatus::Done;
2726 run_store.create(rec).await.expect("run create");
2727 link_task_to_run(&state, &task_id, 1, &run_id).await;
2728
2729 let err = worker_degradation(
2730 State(state.clone()),
2731 bearer_headers(&handle),
2732 Json(degradation_body("some_tool", None)),
2733 )
2734 .await
2735 .expect_err("a degradation against a Done run must be rejected");
2736 assert_eq!(err.status, StatusCode::GONE);
2737
2738 let rec = run_store.get(&run_id).await.expect("run get");
2739 assert!(
2740 rec.degradations.is_empty(),
2741 "rejected degradation must not land: {:?}",
2742 rec.degradations
2743 );
2744 }
2745
2746 async fn seed_work_dir(
2760 state: &AppState,
2761 task_id: &StepId,
2762 attempt: u32,
2763 work_dir: &str,
2764 allow_file_submit: Option<Value>,
2765 ) {
2766 let tid = task_id.clone();
2767 let work_dir = work_dir.to_string();
2768 state
2769 .engine
2770 .with_state("test.seed_work_dir", move |s| {
2771 let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2772 entry.view.work_dir = Some(work_dir);
2773 if let Some(v) = allow_file_submit {
2774 entry
2775 .view
2776 .extra
2777 .insert(FILE_SENTINEL_ALLOW_KEY.to_string(), v);
2778 }
2779 s.agent_ctx.insert((tid, attempt), entry);
2780 })
2781 .await
2782 .expect("seed_work_dir");
2783 }
2784
2785 #[tokio::test]
2789 async fn worker_submit_resolves_file_sentinel_under_work_dir() {
2790 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2791 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2792 let state = test_state(data_store.clone(), run_store);
2793 let task_id = StepId::new();
2794 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2795
2796 let tmp = tempfile::tempdir().expect("tempdir");
2797 let work_dir = tmp.path().to_path_buf();
2798 seed_work_dir(
2799 &state,
2800 &task_id,
2801 1,
2802 work_dir.to_str().expect("work_dir utf-8"),
2803 Some(Value::Bool(true)),
2804 )
2805 .await;
2806
2807 let payload_path = work_dir.join("scout.md");
2808 let payload = "## Context Package (broad)\n\nlarge body content\n";
2809 tokio::fs::write(&payload_path, payload)
2810 .await
2811 .expect("write payload");
2812 let body = format!(
2813 "@file:{}",
2814 payload_path.to_str().expect("payload path utf-8")
2815 );
2816
2817 let status = worker_submit(
2818 State(state.clone()),
2819 bearer_headers(&handle),
2820 Query(SubmitQuery {
2821 ok: None,
2822 verdict: None,
2823 }),
2824 axum::body::Bytes::from(body),
2825 )
2826 .await
2827 .expect("worker_submit sentinel");
2828 assert_eq!(status, StatusCode::NO_CONTENT);
2829
2830 let tid = task_id.clone();
2834 let value = state
2835 .engine
2836 .with_state("test.inspect_output_store", move |s| {
2837 s.output_store.get(&(tid.clone(), 1)).and_then(|evs| {
2838 evs.iter().find_map(|ev| match ev {
2839 OutputEvent::Final {
2840 content: ContentRef::Inline { value },
2841 ..
2842 } => Some(value.clone()),
2843 _ => None,
2844 })
2845 })
2846 })
2847 .await
2848 .expect("with_state")
2849 .expect("Final event present");
2850 assert_eq!(value, Value::String(payload.trim_end().to_string()));
2851 }
2852
2853 #[tokio::test]
2856 async fn worker_submit_passes_non_sentinel_body_unchanged() {
2857 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2858 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2859 let state = test_state(data_store.clone(), run_store);
2860 let task_id = StepId::new();
2861 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2862 let status = worker_submit(
2866 State(state.clone()),
2867 bearer_headers(&handle),
2868 Query(SubmitQuery {
2869 ok: None,
2870 verdict: None,
2871 }),
2872 axum::body::Bytes::from_static(b"DONE yes=1 maybe=0 no=0"),
2873 )
2874 .await
2875 .expect("worker_submit inline");
2876 assert_eq!(status, StatusCode::NO_CONTENT);
2877
2878 let tid = task_id.clone();
2879 let value = state
2880 .engine
2881 .with_state("test.inspect_output_store", move |s| {
2882 s.output_store.get(&(tid.clone(), 1)).and_then(|evs| {
2883 evs.iter().find_map(|ev| match ev {
2884 OutputEvent::Final {
2885 content: ContentRef::Inline { value },
2886 ..
2887 } => Some(value.clone()),
2888 _ => None,
2889 })
2890 })
2891 })
2892 .await
2893 .expect("with_state")
2894 .expect("Final event present");
2895 assert_eq!(value, Value::String("DONE yes=1 maybe=0 no=0".to_string()));
2896 }
2897
2898 #[tokio::test]
2903 async fn worker_submit_rejects_sentinel_path_outside_work_dir() {
2904 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2905 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2906 let state = test_state(data_store, run_store);
2907 let task_id = StepId::new();
2908 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2909
2910 let allowed = tempfile::tempdir().expect("allowed tempdir");
2911 let outside = tempfile::tempdir().expect("outside tempdir");
2912 seed_work_dir(
2913 &state,
2914 &task_id,
2915 1,
2916 allowed.path().to_str().expect("utf-8"),
2917 Some(Value::Bool(true)),
2918 )
2919 .await;
2920
2921 let outside_file = outside.path().join("leak.md");
2922 tokio::fs::write(&outside_file, b"outside content")
2923 .await
2924 .expect("write outside");
2925 let body = format!(
2926 "@file:{}",
2927 outside_file.to_str().expect("outside path utf-8")
2928 );
2929
2930 let err = worker_submit(
2931 State(state.clone()),
2932 bearer_headers(&handle),
2933 Query(SubmitQuery {
2934 ok: None,
2935 verdict: None,
2936 }),
2937 axum::body::Bytes::from(body),
2938 )
2939 .await
2940 .expect_err("outside-work_dir sentinel must be rejected");
2941 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2942 }
2943
2944 #[tokio::test]
2946 async fn worker_submit_rejects_sentinel_missing_file() {
2947 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2948 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2949 let state = test_state(data_store, run_store);
2950 let task_id = StepId::new();
2951 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2952
2953 let tmp = tempfile::tempdir().expect("tempdir");
2954 seed_work_dir(
2955 &state,
2956 &task_id,
2957 1,
2958 tmp.path().to_str().expect("utf-8"),
2959 Some(Value::Bool(true)),
2960 )
2961 .await;
2962 let missing = tmp.path().join("does-not-exist.md");
2963 let body = format!("@file:{}", missing.to_str().expect("utf-8"));
2964
2965 let err = worker_submit(
2966 State(state.clone()),
2967 bearer_headers(&handle),
2968 Query(SubmitQuery {
2969 ok: None,
2970 verdict: None,
2971 }),
2972 axum::body::Bytes::from(body),
2973 )
2974 .await
2975 .expect_err("missing-file sentinel must be rejected");
2976 assert_eq!(err.status, StatusCode::NOT_FOUND);
2977 }
2978
2979 #[tokio::test]
2981 async fn worker_submit_rejects_sentinel_relative_path() {
2982 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2983 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2984 let state = test_state(data_store, run_store);
2985 let task_id = StepId::new();
2986 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2987
2988 let err = worker_submit(
2989 State(state.clone()),
2990 bearer_headers(&handle),
2991 Query(SubmitQuery {
2992 ok: None,
2993 verdict: None,
2994 }),
2995 axum::body::Bytes::from_static(b"@file:relative/path.md"),
2996 )
2997 .await
2998 .expect_err("relative-path sentinel must be rejected");
2999 assert_eq!(err.status, StatusCode::BAD_REQUEST);
3000 }
3001
3002 #[tokio::test]
3006 async fn worker_submit_rejects_sentinel_without_agent_context_view() {
3007 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3008 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3009 let state = test_state(data_store, run_store);
3010 let task_id = StepId::new();
3011 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3012 let err = worker_submit(
3015 State(state.clone()),
3016 bearer_headers(&handle),
3017 Query(SubmitQuery {
3018 ok: None,
3019 verdict: None,
3020 }),
3021 axum::body::Bytes::from_static(b"@file:/tmp/anywhere.md"),
3022 )
3023 .await
3024 .expect_err("missing AgentContextView must reject sentinel");
3025 assert_eq!(err.status, StatusCode::BAD_REQUEST);
3026 }
3027
3028 #[tokio::test]
3032 async fn worker_artifact_resolves_file_sentinel_under_work_dir() {
3033 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3034 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3035 let state = test_state(data_store, run_store);
3036 let task_id = StepId::new();
3037 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3038
3039 let tmp = tempfile::tempdir().expect("tempdir");
3040 seed_work_dir(
3041 &state,
3042 &task_id,
3043 1,
3044 tmp.path().to_str().expect("utf-8"),
3045 Some(Value::Bool(true)),
3046 )
3047 .await;
3048
3049 let payload_path = tmp.path().join("part.md");
3050 let payload = "artifact part body\n";
3051 tokio::fs::write(&payload_path, payload)
3052 .await
3053 .expect("write payload");
3054 let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
3055
3056 let status = worker_artifact(
3057 State(state.clone()),
3058 bearer_headers(&handle),
3059 Query(ArtifactQuery {
3060 name: "scout".to_string(),
3061 }),
3062 axum::body::Bytes::from(body),
3063 )
3064 .await
3065 .expect("worker_artifact sentinel");
3066 assert_eq!(status, StatusCode::NO_CONTENT);
3067 }
3068
3069 #[tokio::test]
3074 async fn worker_submit_rejects_sentinel_without_allow_flag() {
3075 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3076 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3077 let state = test_state(data_store, run_store);
3078 let task_id = StepId::new();
3079 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3080
3081 let tmp = tempfile::tempdir().expect("tempdir");
3082 seed_work_dir(
3083 &state,
3084 &task_id,
3085 1,
3086 tmp.path().to_str().expect("utf-8"),
3087 None,
3088 )
3089 .await;
3090
3091 let payload_path = tmp.path().join("out.md");
3092 tokio::fs::write(&payload_path, b"resolvable body")
3093 .await
3094 .expect("write payload");
3095 let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
3096
3097 let err = worker_submit(
3098 State(state.clone()),
3099 bearer_headers(&handle),
3100 Query(SubmitQuery {
3101 ok: None,
3102 verdict: None,
3103 }),
3104 axum::body::Bytes::from(body),
3105 )
3106 .await
3107 .expect_err("missing opt-in must reject sentinel");
3108 assert_eq!(err.status, StatusCode::BAD_REQUEST);
3109 assert!(
3110 err.message.contains("not allowed"),
3111 "rejection must name the opt-in guard, got: {}",
3112 err.message
3113 );
3114 }
3115
3116 #[tokio::test]
3119 async fn worker_submit_rejects_sentinel_with_non_true_allow_values() {
3120 for allow in [Value::Bool(false), Value::String("true".to_string())] {
3121 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3122 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3123 let state = test_state(data_store, run_store);
3124 let task_id = StepId::new();
3125 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3126
3127 let tmp = tempfile::tempdir().expect("tempdir");
3128 seed_work_dir(
3129 &state,
3130 &task_id,
3131 1,
3132 tmp.path().to_str().expect("utf-8"),
3133 Some(allow.clone()),
3134 )
3135 .await;
3136
3137 let payload_path = tmp.path().join("out.md");
3138 tokio::fs::write(&payload_path, b"resolvable body")
3139 .await
3140 .expect("write payload");
3141 let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
3142
3143 let err = worker_submit(
3144 State(state.clone()),
3145 bearer_headers(&handle),
3146 Query(SubmitQuery {
3147 ok: None,
3148 verdict: None,
3149 }),
3150 axum::body::Bytes::from(body),
3151 )
3152 .await
3153 .expect_err("non-true opt-in value must reject sentinel");
3154 assert_eq!(err.status, StatusCode::BAD_REQUEST, "value: {allow:?}");
3155 }
3156 }
3157
3158 async fn seed_submit_format(
3174 state: &AppState,
3175 task_id: &StepId,
3176 attempt: u32,
3177 agent: &str,
3178 submit_format: Option<Value>,
3179 work_dir: Option<&str>,
3180 ) {
3181 let tid = task_id.clone();
3182 let agent = agent.to_string();
3183 let work_dir = work_dir.map(|w| w.to_string());
3184 state
3185 .engine
3186 .with_state("test.seed_submit_format", move |s| {
3187 let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
3188 entry.view.agent = agent;
3189 if let Some(w) = work_dir {
3190 entry.view.work_dir = Some(w);
3191 entry
3192 .view
3193 .extra
3194 .insert(FILE_SENTINEL_ALLOW_KEY.to_string(), Value::Bool(true));
3195 }
3196 if let Some(v) = submit_format {
3197 entry.view.extra.insert(SUBMIT_FORMAT_KEY.to_string(), v);
3198 }
3199 s.agent_ctx.insert((tid, attempt), entry);
3200 })
3201 .await
3202 .expect("seed_submit_format");
3203 }
3204
3205 async fn final_value(state: &AppState, task_id: &StepId, attempt: u32) -> Option<Value> {
3208 let tid = task_id.clone();
3209 state
3210 .engine
3211 .with_state("test.inspect_output_store", move |s| {
3212 s.output_store.get(&(tid.clone(), attempt)).and_then(|evs| {
3213 evs.iter().find_map(|ev| match ev {
3214 OutputEvent::Final {
3215 content: ContentRef::Inline { value },
3216 ..
3217 } => Some(value.clone()),
3218 _ => None,
3219 })
3220 })
3221 })
3222 .await
3223 .expect("with_state")
3224 }
3225
3226 #[tokio::test]
3235 async fn worker_submit_without_submit_format_stages_json_looking_body_as_string() {
3236 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3237 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3238 let state = test_state(data_store, run_store);
3239 let task_id = StepId::new();
3240 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3241 seed_submit_format(&state, &task_id, 1, "planner", None, None).await;
3242
3243 let body = r#"{"lanes":["a","b"]}"#;
3244 let status = worker_submit(
3245 State(state.clone()),
3246 bearer_headers(&handle),
3247 Query(SubmitQuery {
3248 ok: None,
3249 verdict: None,
3250 }),
3251 axum::body::Bytes::from(body),
3252 )
3253 .await
3254 .expect("undeclared submit must succeed");
3255 assert_eq!(status, StatusCode::NO_CONTENT);
3256
3257 assert_eq!(
3258 final_value(&state, &task_id, 1).await,
3259 Some(Value::String(body.to_string())),
3260 "an undeclared step must keep the raw-string fold",
3261 );
3262 }
3263
3264 #[tokio::test]
3268 async fn worker_submit_declared_json_folds_structured_value() {
3269 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3270 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3271 let state = test_state(data_store, run_store);
3272 let task_id = StepId::new();
3273 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3274 seed_submit_format(
3275 &state,
3276 &task_id,
3277 1,
3278 "planner",
3279 Some(Value::String(SUBMIT_FORMAT_JSON.to_string())),
3280 None,
3281 )
3282 .await;
3283
3284 let status = worker_submit(
3285 State(state.clone()),
3286 bearer_headers(&handle),
3287 Query(SubmitQuery {
3288 ok: None,
3289 verdict: None,
3290 }),
3291 axum::body::Bytes::from(r#"{"lanes":["auth","billing"],"verdict":"PASS"}"#),
3292 )
3293 .await
3294 .expect("declared JSON submit must succeed");
3295 assert_eq!(status, StatusCode::NO_CONTENT);
3296
3297 let value = final_value(&state, &task_id, 1)
3298 .await
3299 .expect("Final event present");
3300 assert_eq!(
3301 value,
3302 json!({"lanes": ["auth", "billing"], "verdict": "PASS"})
3303 );
3304 assert_eq!(value["lanes"], json!(["auth", "billing"]));
3306 assert_eq!(value["verdict"], json!("PASS"));
3307 }
3308
3309 #[tokio::test]
3313 async fn worker_submit_declared_json_rejects_unparseable_body_with_422() {
3314 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3315 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3316 let state = test_state(data_store, run_store);
3317 let task_id = StepId::new();
3318 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3319 seed_submit_format(
3320 &state,
3321 &task_id,
3322 1,
3323 "planner",
3324 Some(Value::String(SUBMIT_FORMAT_JSON.to_string())),
3325 None,
3326 )
3327 .await;
3328
3329 let err = worker_submit(
3330 State(state.clone()),
3331 bearer_headers(&handle),
3332 Query(SubmitQuery {
3333 ok: None,
3334 verdict: None,
3335 }),
3336 axum::body::Bytes::from("DONE — 3 lanes planned, see the report above"),
3337 )
3338 .await
3339 .expect_err("a declared step must not fold an unparseable body");
3340 assert_eq!(err.status, StatusCode::UNPROCESSABLE_ENTITY);
3341 assert!(
3342 err.message.contains("planner") && err.message.contains("DONE"),
3343 "the rejection must name the agent and echo the body head, got: {}",
3344 err.message
3345 );
3346 assert_eq!(
3347 final_value(&state, &task_id, 1).await,
3348 None,
3349 "a rejected submit must not reach the output tail",
3350 );
3351 }
3352
3353 #[tokio::test]
3357 async fn worker_submit_declared_json_parses_file_sentinel_contents() {
3358 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3359 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3360 let state = test_state(data_store, run_store);
3361 let task_id = StepId::new();
3362 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3363
3364 let tmp = tempfile::tempdir().expect("tempdir");
3365 let work_dir = tmp.path().to_path_buf();
3366 seed_submit_format(
3367 &state,
3368 &task_id,
3369 1,
3370 "planner",
3371 Some(Value::String(SUBMIT_FORMAT_JSON.to_string())),
3372 Some(work_dir.to_str().expect("work_dir utf-8")),
3373 )
3374 .await;
3375
3376 let payload_path = work_dir.join("plan.json");
3377 tokio::fs::write(&payload_path, "{\"lanes\": [\"auth\", \"billing\"]}\n")
3378 .await
3379 .expect("write payload");
3380 let body = format!(
3381 "@file:{}",
3382 payload_path.to_str().expect("payload path utf-8")
3383 );
3384
3385 let status = worker_submit(
3386 State(state.clone()),
3387 bearer_headers(&handle),
3388 Query(SubmitQuery {
3389 ok: None,
3390 verdict: None,
3391 }),
3392 axum::body::Bytes::from(body),
3393 )
3394 .await
3395 .expect("sentinel + declared JSON submit must succeed");
3396 assert_eq!(status, StatusCode::NO_CONTENT);
3397
3398 assert_eq!(
3399 final_value(&state, &task_id, 1).await,
3400 Some(json!({"lanes": ["auth", "billing"]})),
3401 );
3402 }
3403
3404 #[tokio::test]
3409 async fn worker_submit_unknown_submit_format_falls_back_to_string() {
3410 for declared in [
3411 Value::String("yaml".to_string()),
3412 Value::Bool(true),
3413 Value::Number(1.into()),
3414 ] {
3415 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3416 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3417 let state = test_state(data_store, run_store);
3418 let task_id = StepId::new();
3419 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3420 seed_submit_format(&state, &task_id, 1, "planner", Some(declared.clone()), None).await;
3421
3422 let status = worker_submit(
3423 State(state.clone()),
3424 bearer_headers(&handle),
3425 Query(SubmitQuery {
3426 ok: None,
3427 verdict: None,
3428 }),
3429 axum::body::Bytes::from(r#"{"lanes":["a"]}"#),
3430 )
3431 .await
3432 .unwrap_or_else(|e| panic!("unknown value must not reject ({declared}): {e:?}"));
3433 assert_eq!(status, StatusCode::NO_CONTENT);
3434
3435 assert_eq!(
3436 final_value(&state, &task_id, 1).await,
3437 Some(Value::String(r#"{"lanes":["a"]}"#.to_string())),
3438 "unknown value {declared} must keep the string fold",
3439 );
3440 }
3441 }
3442
3443 #[tokio::test]
3449 async fn worker_submit_declared_text_stages_string() {
3450 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3451 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3452 let state = test_state(data_store, run_store);
3453 let task_id = StepId::new();
3454 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3455 seed_submit_format(
3456 &state,
3457 &task_id,
3458 1,
3459 "planner",
3460 Some(Value::String("text".to_string())),
3461 None,
3462 )
3463 .await;
3464
3465 let body = r#"{"lanes":["a","b"]}"#;
3466 let status = worker_submit(
3467 State(state.clone()),
3468 bearer_headers(&handle),
3469 Query(SubmitQuery {
3470 ok: None,
3471 verdict: None,
3472 }),
3473 axum::body::Bytes::from(body),
3474 )
3475 .await
3476 .expect("text-declared submit must succeed");
3477 assert_eq!(status, StatusCode::NO_CONTENT);
3478
3479 assert_eq!(
3480 final_value(&state, &task_id, 1).await,
3481 Some(Value::String(body.to_string())),
3482 "a text-declared step must stage the raw string",
3483 );
3484 }
3485
3486 #[tokio::test]
3491 async fn worker_submit_verdict_contract_unchanged_without_submit_format() {
3492 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3493 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3494 let state = test_state(data_store, run_store);
3495 state.engine.register_verdict_contracts(HashMap::from([(
3496 "gate".to_string(),
3497 body_verdict_contract(&["PASS", "BLOCKED"]),
3498 )]));
3499
3500 let accepted = StepId::new();
3502 let handle = seed_task_with_handle(&state, &accepted, "gate", 1, None).await;
3503 seed_submit_format(&state, &accepted, 1, "gate", None, None).await;
3504 let status = worker_submit(
3505 State(state.clone()),
3506 bearer_headers(&handle),
3507 Query(SubmitQuery {
3508 ok: None,
3509 verdict: None,
3510 }),
3511 axum::body::Bytes::from("BLOCKED"),
3512 )
3513 .await
3514 .expect("a declared verdict value must still pass");
3515 assert_eq!(status, StatusCode::NO_CONTENT);
3516 assert_eq!(
3517 final_value(&state, &accepted, 1).await,
3518 Some(Value::String("BLOCKED".to_string())),
3519 );
3520
3521 let rejected = StepId::new();
3524 let handle = seed_task_with_handle(&state, &rejected, "gate", 1, None).await;
3525 seed_submit_format(&state, &rejected, 1, "gate", None, None).await;
3526 let err = worker_submit(
3527 State(state.clone()),
3528 bearer_headers(&handle),
3529 Query(SubmitQuery {
3530 ok: None,
3531 verdict: None,
3532 }),
3533 axum::body::Bytes::from("UNKNOWN"),
3534 )
3535 .await
3536 .expect_err("a non-member verdict value must still be rejected");
3537 assert_eq!(err.status, StatusCode::UNPROCESSABLE_ENTITY);
3538 assert!(
3539 err.message.contains("verdict contract violation"),
3540 "the verdict contract must own this rejection, got: {}",
3541 err.message
3542 );
3543 }
3544
3545 #[tokio::test]
3551 async fn declared_json_submit_feeds_a_fanout_items_path() {
3552 use mlua_flow_ir::{EvalError, Expr, JoinMode, Node as FlowNode};
3553
3554 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3555 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3556 let state = test_state(data_store, run_store);
3557 let task_id = StepId::new();
3558 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
3559 seed_submit_format(
3560 &state,
3561 &task_id,
3562 1,
3563 "planner",
3564 Some(Value::String(SUBMIT_FORMAT_JSON.to_string())),
3565 None,
3566 )
3567 .await;
3568
3569 worker_submit(
3570 State(state.clone()),
3571 bearer_headers(&handle),
3572 Query(SubmitQuery {
3573 ok: None,
3574 verdict: None,
3575 }),
3576 axum::body::Bytes::from(r#"{"lanes":["auth","billing","search"]}"#),
3577 )
3578 .await
3579 .expect("declared JSON submit must succeed");
3580 let planner_out = final_value(&state, &task_id, 1)
3581 .await
3582 .expect("Final event present");
3583
3584 let path = |s: &str| Expr::Path {
3586 at: s.parse().expect("literal test path"),
3587 };
3588 let flow = FlowNode::Fanout {
3589 items: path("$.planner.lanes"),
3590 bind: path("$.item"),
3591 body: Box::new(FlowNode::Step {
3592 ref_: "check".to_string(),
3593 in_: path("$.item"),
3594 out: path("$.branch_out"),
3595 }),
3596 join: JoinMode::All,
3597 out: path("$.results"),
3598 };
3599 let dispatcher = |_ref: &str, input: Value| -> Result<Value, EvalError> { Ok(input) };
3600 let final_ctx = mlua_flow_ir::eval(&flow, json!({ "planner": planner_out }), &dispatcher)
3601 .expect("fanout over the parsed submit must evaluate");
3602
3603 let lanes: Vec<&Value> = final_ctx["results"]
3604 .as_array()
3605 .expect("results is an array")
3606 .iter()
3607 .map(|lane_ctx| &lane_ctx["branch_out"])
3608 .collect();
3609 assert_eq!(
3610 lanes,
3611 vec![&json!("auth"), &json!("billing"), &json!("search")]
3612 );
3613 }
3614
3615 fn body_verdict_contract(values: &[&str]) -> mlua_swarm_schema::VerdictContract {
3626 mlua_swarm_schema::VerdictContract {
3627 channel: VerdictChannel::Body,
3628 values: values.iter().map(|v| v.to_string()).collect(),
3629 }
3630 }
3631
3632 fn part_verdict_contract(values: &[&str]) -> mlua_swarm_schema::VerdictContract {
3633 mlua_swarm_schema::VerdictContract {
3634 channel: VerdictChannel::Part,
3635 values: values.iter().map(|v| v.to_string()).collect(),
3636 }
3637 }
3638
3639 #[tokio::test]
3642 async fn worker_submit_rejects_body_outside_contract_values_with_422() {
3643 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3644 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3645 let state = test_state(data_store, run_store);
3646 let task_id = StepId::new();
3647 let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
3648 state.engine.register_verdict_contracts(HashMap::from([(
3649 "gate".to_string(),
3650 body_verdict_contract(&["PASS", "BLOCKED"]),
3651 )]));
3652
3653 let err = worker_submit(
3654 State(state.clone()),
3655 bearer_headers(&handle),
3656 Query(SubmitQuery {
3657 ok: None,
3658 verdict: None,
3659 }),
3660 axum::body::Bytes::from("UNKNOWN"),
3661 )
3662 .await
3663 .expect_err("value outside declared values must reject");
3664 assert_eq!(err.status, StatusCode::UNPROCESSABLE_ENTITY);
3665 assert!(
3666 err.message.contains("PASS") && err.message.contains("BLOCKED"),
3667 "rejection must echo the declared values, got: {}",
3668 err.message
3669 );
3670 }
3671
3672 #[tokio::test]
3675 async fn worker_submit_accepts_body_inside_contract_values() {
3676 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3677 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3678 let state = test_state(data_store, run_store);
3679 let task_id = StepId::new();
3680 let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
3681 state.engine.register_verdict_contracts(HashMap::from([(
3682 "gate".to_string(),
3683 body_verdict_contract(&["PASS", "BLOCKED"]),
3684 )]));
3685
3686 let status = worker_submit(
3687 State(state.clone()),
3688 bearer_headers(&handle),
3689 Query(SubmitQuery {
3690 ok: None,
3691 verdict: None,
3692 }),
3693 axum::body::Bytes::from("PASS"),
3694 )
3695 .await
3696 .expect("value inside declared values must succeed");
3697 assert_eq!(status, StatusCode::NO_CONTENT);
3698 }
3699
3700 #[tokio::test]
3704 async fn worker_submit_without_a_declared_contract_is_unaffected() {
3705 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3706 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3707 let state = test_state(data_store, run_store);
3708 let task_id = StepId::new();
3709 let handle = seed_task_with_handle(&state, &task_id, "undeclared-agent", 1, None).await;
3711
3712 let status = worker_submit(
3713 State(state.clone()),
3714 bearer_headers(&handle),
3715 Query(SubmitQuery {
3716 ok: None,
3717 verdict: None,
3718 }),
3719 axum::body::Bytes::from("anything at all, no contract to violate"),
3720 )
3721 .await
3722 .expect("no contract declared must never reject");
3723 assert_eq!(status, StatusCode::NO_CONTENT);
3724 }
3725
3726 #[tokio::test]
3729 async fn worker_artifact_verdict_part_rejects_value_outside_contract_with_422() {
3730 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3731 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3732 let state = test_state(data_store, run_store);
3733 let task_id = StepId::new();
3734 let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
3735 state.engine.register_verdict_contracts(HashMap::from([(
3736 "gate".to_string(),
3737 part_verdict_contract(&["PASS", "BLOCKED"]),
3738 )]));
3739
3740 let err = worker_artifact(
3741 State(state.clone()),
3742 bearer_headers(&handle),
3743 Query(ArtifactQuery {
3744 name: "verdict".to_string(),
3745 }),
3746 axum::body::Bytes::from("UNKNOWN"),
3747 )
3748 .await
3749 .expect_err("value outside declared values must reject");
3750 assert_eq!(err.status, StatusCode::UNPROCESSABLE_ENTITY);
3751 }
3752
3753 #[tokio::test]
3757 async fn worker_artifact_non_verdict_part_skips_the_gate() {
3758 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3759 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3760 let state = test_state(data_store, run_store);
3761 let task_id = StepId::new();
3762 let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
3763 state.engine.register_verdict_contracts(HashMap::from([(
3764 "gate".to_string(),
3765 part_verdict_contract(&["PASS", "BLOCKED"]),
3766 )]));
3767
3768 let status = worker_artifact(
3769 State(state.clone()),
3770 bearer_headers(&handle),
3771 Query(ArtifactQuery {
3772 name: "notes".to_string(),
3773 }),
3774 axum::body::Bytes::from("anything at all"),
3775 )
3776 .await
3777 .expect("non-verdict part name must never be gated");
3778 assert_eq!(status, StatusCode::NO_CONTENT);
3779 }
3780}