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::step_naming::StepNaming;
77use mlua_swarm::store::run::{DegradationEntry, RunStatus, RunStoreError};
78use mlua_swarm::{CapToken, ContentRef, EngineError, OutputEvent, RunId, StepId, WorkerPayload};
79use mlua_swarm_schema::{ContextPolicy, VerdictChannel};
80use serde::Deserialize;
81use serde_json::Value;
82
83use crate::projection::McpQueryAdapter;
84use crate::{ApiError, AppState};
85
86#[derive(Debug, Deserialize)]
88pub struct PromptQuery {
89 pub task_id: StepId,
93}
94
95pub async fn worker_prompt(
101 State(state): State<AppState>,
102 headers: HeaderMap,
103 Query(q): Query<PromptQuery>,
104) -> Result<Json<WorkerPayload>, ApiError> {
105 let task_id = q.task_id;
106 let bearer = extract_bearer_raw(&headers)?;
107 let mut payload = if let Some(handle) = parse_worker_handle(&bearer) {
108 let resolved = state
110 .engine
111 .task_id_from_handle(handle)
112 .await
113 .map_err(|e| ApiError::engine(format!("task_id_from_handle: {e}")))?;
114 if resolved != task_id {
115 return Err(ApiError::bad_request(format!(
116 "handle {handle} is bound to task {resolved}, not {task_id}"
117 )));
118 }
119 state
120 .engine
121 .fetch_worker_payload_trusted(&task_id)
122 .await
123 .map_err(|e| ApiError::engine(format!("fetch_worker_payload_trusted: {e}")))?
124 } else {
125 let token = CapToken::decode(bearer.trim())
127 .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
128 state
129 .engine
130 .fetch_worker_payload(&token, &task_id)
131 .await
132 .map_err(|e| ApiError::engine(format!("fetch_worker_payload: {e}")))?
133 };
134 assemble_step_pointers(&state, &mut payload).await;
135 Ok(Json(payload))
136}
137
138async fn assemble_step_pointers(state: &AppState, payload: &mut WorkerPayload) {
166 let Some(context) = payload.context.as_mut() else {
167 return;
168 };
169 let Some(run_id_str) = context.run_id.clone() else {
170 return;
171 };
172 let Ok(run_id) = RunId::parse(run_id_str) else {
173 return;
174 };
175
176 let adapter = McpQueryAdapter::new(
177 state.data_store.clone(),
178 state.run_store.clone(),
179 state.engine.clone(),
180 );
181 let Ok((run, resolved_steps)) = adapter.list_steps_by_run_id(&run_id).await else {
182 return;
183 };
184
185 let naming = state.engine.step_naming_for(&payload.task_id).await;
186 let policy = state
187 .engine
188 .context_policy_for(&payload.task_id, payload.attempt)
189 .await;
190 let self_canonical = naming
191 .as_deref()
192 .and_then(|n| n.canonical_of_producer(&payload.agent))
193 .map(str::to_string)
194 .unwrap_or_else(|| payload.agent.clone());
195
196 let mut pointers = Vec::new();
197 for step in &resolved_steps {
198 if step.name == self_canonical
199 || !allows_step_canonical(&policy, naming.as_deref(), &step.name)
200 {
201 continue;
202 }
203 if let Some((size_bytes, file_path, content_url, sha256)) =
204 crate::projection::resolve_step_pointer_fields(state, &run, step).await
205 {
206 pointers.push(StepPointer {
207 name: step.name.clone(),
208 size_bytes,
209 file_path,
210 content_url,
211 sha256,
212 });
213 }
214 }
215 context.steps = pointers;
216}
217
218fn allows_step_canonical(
233 policy: &ContextPolicy,
234 naming: Option<&StepNaming>,
235 canonical_name: &str,
236) -> bool {
237 let resolves_to = |raw: &str| -> bool {
238 match naming {
239 Some(n) => n
240 .resolve(raw)
241 .map(|c| c == canonical_name)
242 .unwrap_or(raw == canonical_name),
243 None => raw == canonical_name,
244 }
245 };
246 if policy
247 .steps_exclude
248 .iter()
249 .any(|excluded| resolves_to(excluded))
250 {
251 return false;
252 }
253 match &policy.steps {
254 None => true,
255 Some(list) => list.iter().any(|included| resolves_to(included)),
256 }
257}
258
259#[derive(Debug, Deserialize)]
261pub struct WorkerResultReq {
262 pub task_id: StepId,
265 pub value: Value,
267 #[serde(default = "default_ok_true")]
271 pub ok: bool,
272 #[serde(default)]
275 pub attempt: Option<u32>,
276}
277
278fn default_ok_true() -> bool {
279 true
280}
281
282pub async fn worker_result(
285 State(state): State<AppState>,
286 headers: HeaderMap,
287 Json(req): Json<WorkerResultReq>,
288) -> Result<StatusCode, ApiError> {
289 let token = decode_worker_bearer(&headers)?;
290 let task_id = req.task_id.clone();
291
292 let attempt = match req.attempt {
294 Some(n) => n,
295 None => state
296 .engine
297 .task_attempt(&task_id)
298 .await
299 .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?,
300 };
301
302 let event = OutputEvent::Final {
303 content: ContentRef::Inline {
304 value: req.value.clone(),
305 },
306 ok: req.ok,
307 };
308 map_completion_result(
313 state
314 .engine
315 .submit_output(&token, &task_id, attempt, event)
316 .await,
317 "submit_output",
318 )?;
319 state
320 .engine
321 .post_result(&token, &task_id, req.value)
322 .await
323 .map_err(|e| ApiError::engine(format!("post_result: {e}")))?;
324 Ok(StatusCode::NO_CONTENT)
325}
326
327const FILE_SENTINEL_PREFIX: &str = "@file:";
335
336const FILE_SENTINEL_MAX_BYTES: u64 = 2 * 1024 * 1024;
342
343const FILE_SENTINEL_ALLOW_KEY: &str = "allow_file_submit";
355
356async fn resolve_file_sentinel(
389 state: &AppState,
390 task_id: &StepId,
391 attempt: u32,
392 body_str: String,
393) -> Result<String, ApiError> {
394 let Some(rest) = body_str.strip_prefix(FILE_SENTINEL_PREFIX) else {
395 return Ok(body_str);
396 };
397 let path_str = rest.trim();
398 if path_str.is_empty() {
399 return Err(ApiError::bad_request(
400 "@file: sentinel: empty path".to_string(),
401 ));
402 }
403 if path_str.contains('\n') || path_str.contains('\r') {
404 return Err(ApiError::bad_request(
405 "@file: sentinel: path must be a single line".to_string(),
406 ));
407 }
408 let path = std::path::Path::new(path_str);
409 if !path.is_absolute() {
410 return Err(ApiError::bad_request(format!(
411 "@file: sentinel: path must be absolute (got {path_str:?})"
412 )));
413 }
414 let view = state
415 .engine
416 .agent_context_for(task_id, attempt)
417 .await
418 .ok_or_else(|| {
419 ApiError::bad_request(
420 "@file: sentinel: no AgentContextView for this task/attempt \
421 (spawn must run through AgentContextMiddleware to enable \
422 sentinel resolution)"
423 .to_string(),
424 )
425 })?;
426 if view.extra.get(FILE_SENTINEL_ALLOW_KEY) != Some(&Value::Bool(true)) {
430 return Err(ApiError::bad_request(format!(
431 "@file: sentinel: file submission is not allowed for this step \
432 (declare `{FILE_SENTINEL_ALLOW_KEY}: true` via `$step_meta` / \
433 `AgentMeta.ctx` / `Blueprint.metas`; strict boolean `true` \
434 required)"
435 )));
436 }
437 let work_dir = view.work_dir.ok_or_else(|| {
438 ApiError::bad_request("@file: sentinel: task has no resolved work_dir".to_string())
439 })?;
440 let work_dir_canon = tokio::fs::canonicalize(&work_dir).await.map_err(|e| {
441 ApiError::engine(format!(
442 "@file: sentinel: canonicalize work_dir {work_dir:?}: {e}"
443 ))
444 })?;
445 let path_canon = match tokio::fs::canonicalize(path).await {
446 Ok(p) => p,
447 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
448 return Err(ApiError::not_found(format!(
449 "@file: sentinel: file not found: {path_str}"
450 )));
451 }
452 Err(e) => {
453 return Err(ApiError::engine(format!(
454 "@file: sentinel: canonicalize {path_str:?}: {e}"
455 )));
456 }
457 };
458 if !path_canon.starts_with(&work_dir_canon) {
459 return Err(ApiError::bad_request(format!(
460 "@file: sentinel: path {} is not under work_dir {} (canonicalized: {} vs {})",
461 path_str,
462 work_dir,
463 path_canon.display(),
464 work_dir_canon.display(),
465 )));
466 }
467 let meta = tokio::fs::metadata(&path_canon)
468 .await
469 .map_err(|e| ApiError::engine(format!("@file: sentinel: metadata {path_str:?}: {e}")))?;
470 if meta.len() > FILE_SENTINEL_MAX_BYTES {
471 return Err(ApiError::payload_too_large(format!(
472 "@file: sentinel: file size {} exceeds limit {}",
473 meta.len(),
474 FILE_SENTINEL_MAX_BYTES
475 )));
476 }
477 let bytes = tokio::fs::read(&path_canon)
478 .await
479 .map_err(|e| ApiError::engine(format!("@file: sentinel: read {path_str:?}: {e}")))?;
480 Ok(String::from_utf8_lossy(&bytes).trim_end().to_string())
482}
483
484async fn check_verdict_contract(
505 state: &AppState,
506 task_id: &StepId,
507 channel: VerdictChannel,
508 value: &str,
509) -> Result<(), ApiError> {
510 let Some(contract) = state.engine.verdict_contract_for_task(task_id).await else {
511 return Ok(());
512 };
513 if contract.channel != channel {
514 return Ok(());
515 }
516 if contract.values.iter().any(|v| v == value) {
517 return Ok(());
518 }
519 Err(ApiError::unprocessable(format!(
520 "verdict contract violation: {value:?} is not a member of the declared values {:?}",
521 contract.values
522 )))
523}
524
525fn map_completion_result<T>(result: Result<T, EngineError>, context: &str) -> Result<T, ApiError> {
542 result.map_err(|e| match e {
543 EngineError::VerdictValueRejected { value, allowed } => ApiError::unprocessable(format!(
544 "verdict contract violation: {value:?} is not a member of the declared values {allowed:?}"
545 )),
546 EngineError::VerdictPartMissing { allowed } => ApiError::unprocessable(format!(
547 "verdict contract violation: no staged \"verdict\" part found for this attempt; declared values {allowed:?}"
548 )),
549 other => ApiError::engine(format!("{context}: {other}")),
550 })
551}
552
553#[derive(Debug, Deserialize, Default)]
575pub struct SubmitQuery {
576 #[serde(default)]
580 pub ok: Option<bool>,
581}
582
583pub async fn worker_submit(
589 State(state): State<AppState>,
590 headers: HeaderMap,
591 Query(q): Query<SubmitQuery>,
592 body: axum::body::Bytes,
593) -> Result<StatusCode, ApiError> {
594 let bearer = extract_bearer_raw(&headers)?;
597 let task_id = if let Some(handle) = parse_worker_handle(&bearer) {
598 state
599 .engine
600 .task_id_from_handle(handle)
601 .await
602 .map_err(|e| ApiError::engine(format!("task_id_from_handle: {e}")))?
603 } else {
604 let token = CapToken::decode(bearer.trim())
605 .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
606 state
607 .engine
608 .task_id_from_token(&token)
609 .await
610 .map_err(|e| ApiError::engine(format!("task_id_from_token: {e}")))?
611 };
612 let attempt = state
613 .engine
614 .task_attempt(&task_id)
615 .await
616 .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?;
617 reject_if_run_terminal(&state, &task_id, attempt).await?;
620 let body_str = String::from_utf8_lossy(&body).trim_end().to_string();
625 let body_str = resolve_file_sentinel(&state, &task_id, attempt, body_str).await?;
628 let value = Value::String(body_str);
637
638 let ok = q.ok.unwrap_or(true);
644 map_completion_result(
645 state
646 .engine
647 .submit_worker_result_trusted(&task_id, attempt, value, ok)
648 .await,
649 "submit_worker_result_trusted",
650 )?;
651 Ok(StatusCode::NO_CONTENT)
652}
653
654#[derive(Debug, Deserialize)]
656pub struct ArtifactQuery {
657 pub name: String,
664}
665
666pub async fn worker_artifact(
688 State(state): State<AppState>,
689 headers: HeaderMap,
690 Query(q): Query<ArtifactQuery>,
691 body: axum::body::Bytes,
692) -> Result<StatusCode, ApiError> {
693 let name = q.name.trim();
694 if name.is_empty() {
695 return Err(ApiError::bad_request("name must not be empty".into()));
696 }
697 let name = name.to_string();
698
699 let bearer = extract_bearer_raw(&headers)?;
700 let task_id = if let Some(handle) = parse_worker_handle(&bearer) {
701 state
702 .engine
703 .task_id_from_handle(handle)
704 .await
705 .map_err(|e| ApiError::engine(format!("task_id_from_handle: {e}")))?
706 } else {
707 let token = CapToken::decode(bearer.trim())
708 .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
709 state
710 .engine
711 .task_id_from_token(&token)
712 .await
713 .map_err(|e| ApiError::engine(format!("task_id_from_token: {e}")))?
714 };
715 let attempt = state
716 .engine
717 .task_attempt(&task_id)
718 .await
719 .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?;
720 reject_if_run_terminal(&state, &task_id, attempt).await?;
723 let body_str = String::from_utf8_lossy(&body).trim_end().to_string();
724 let body_str = resolve_file_sentinel(&state, &task_id, attempt, body_str).await?;
726 if name == "verdict" {
732 check_verdict_contract(&state, &task_id, VerdictChannel::Part, &body_str).await?;
733 }
734 let value = Value::String(body_str);
735
736 state
737 .engine
738 .stage_worker_artifact_trusted(&task_id, attempt, name, value)
739 .await
740 .map_err(|e| ApiError::engine(format!("stage_worker_artifact_trusted: {e}")))?;
741 Ok(StatusCode::NO_CONTENT)
742}
743
744#[derive(Debug, Deserialize)]
746pub struct DegradationBody {
747 pub tool: String,
749 pub error: String,
751 pub fallback: String,
753 #[serde(default)]
755 pub note: Option<String>,
756}
757
758pub async fn worker_degradation(
786 State(state): State<AppState>,
787 headers: HeaderMap,
788 Json(body): Json<DegradationBody>,
789) -> Result<StatusCode, ApiError> {
790 let bearer = extract_bearer_raw(&headers)?;
791 let task_id = if let Some(handle) = parse_worker_handle(&bearer) {
792 state
793 .engine
794 .task_id_from_handle(handle)
795 .await
796 .map_err(|e| ApiError::engine(format!("task_id_from_handle: {e}")))?
797 } else {
798 let token = CapToken::decode(bearer.trim())
799 .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
800 state
801 .engine
802 .task_id_from_token(&token)
803 .await
804 .map_err(|e| ApiError::engine(format!("task_id_from_token: {e}")))?
805 };
806 let attempt = state
807 .engine
808 .task_attempt(&task_id)
809 .await
810 .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?;
811 reject_if_run_terminal(&state, &task_id, attempt).await?;
814
815 let tid = task_id.clone();
820 let (run_id_str, agent) = match state
821 .engine
822 .with_state("worker_degradation_run_lookup", move |s| {
823 s.agent_ctx.get(&(tid, attempt)).and_then(|e| {
824 e.view
825 .run_id
826 .clone()
827 .map(|run_id| (run_id, e.view.agent.clone()))
828 })
829 })
830 .await
831 {
832 Ok(Some(pair)) => pair,
833 _ => {
834 tracing::warn!(%task_id, "worker_degradation: no run linkage for this task; entry dropped");
835 return Ok(StatusCode::NO_CONTENT);
836 }
837 };
838 let Ok(run_id) = RunId::parse(run_id_str) else {
839 tracing::warn!(%task_id, "worker_degradation: run_id failed to parse; entry dropped");
840 return Ok(StatusCode::NO_CONTENT);
841 };
842
843 let entry = DegradationEntry {
844 tool: body.tool,
845 error: body.error,
846 fallback: body.fallback,
847 note: body.note,
848 step_ref: Some(agent),
849 attempt: Some(attempt),
850 at: crate::tasks::now_secs(),
851 };
852 match state.run_store.append_degradation(&run_id, entry).await {
853 Ok(()) => Ok(StatusCode::NO_CONTENT),
854 Err(RunStoreError::NotFound(_)) => {
855 tracing::warn!(%task_id, %run_id, "worker_degradation: run not found in run_store; entry dropped");
856 Ok(StatusCode::NO_CONTENT)
857 }
858 Err(e) => Err(ApiError::engine(format!("append_degradation: {e}"))),
859 }
860}
861
862async fn reject_if_run_terminal(
879 state: &AppState,
880 task_id: &StepId,
881 attempt: u32,
882) -> Result<(), ApiError> {
883 let tid = task_id.clone();
884 let run_id_str = match state
885 .engine
886 .with_state("worker_terminal_run_guard", move |s| {
887 s.agent_ctx
888 .get(&(tid, attempt))
889 .and_then(|e| e.view.run_id.clone())
890 })
891 .await
892 {
893 Ok(Some(rid)) => rid,
894 _ => return Ok(()),
895 };
896 let Ok(run_id) = RunId::parse(run_id_str) else {
897 return Ok(());
898 };
899 let Ok(rec) = state.run_store.get(&run_id).await else {
900 return Ok(());
901 };
902 match rec.status {
903 RunStatus::Done | RunStatus::Failed | RunStatus::Interrupted => {
904 Err(ApiError::gone(format!(
905 "run {run_id} is already terminal ({:?}): this attempt's output cannot be \
906 delivered to a flow context; re-kick the task (POST /v1/tasks/:id/runs) and \
907 fetch a fresh prompt",
908 rec.status
909 )))
910 }
911 RunStatus::Pending | RunStatus::Running => Ok(()),
912 }
913}
914
915#[derive(Debug, Deserialize)]
920pub struct PromptSystemQuery {
921 pub task_id: StepId,
924 pub attempt: u32,
926}
927
928pub async fn worker_prompt_system(
938 State(state): State<AppState>,
939 headers: HeaderMap,
940 Query(q): Query<PromptSystemQuery>,
941) -> Result<impl axum::response::IntoResponse, ApiError> {
942 let task_id = q.task_id;
943 let attempt = q.attempt;
944 let bearer = extract_bearer_raw(&headers)?;
945 if let Some(handle) = parse_worker_handle(&bearer) {
946 let resolved = state
947 .engine
948 .task_id_from_handle(handle)
949 .await
950 .map_err(|e| ApiError::engine(format!("task_id_from_handle: {e}")))?;
951 if resolved != task_id {
952 return Err(ApiError::bad_request(format!(
953 "handle {handle} is bound to task {resolved}, not {task_id}"
954 )));
955 }
956 } else {
957 let token = CapToken::decode(bearer.trim())
958 .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
959 state
960 .engine
961 .verify_token_for_task(&token, mlua_swarm::Verb::FetchPrompt, &task_id)
962 .await
963 .map_err(|e| ApiError::engine(format!("verify_token_for_task: {e}")))?;
964 }
965 let system = state
966 .engine
967 .raw_system_prompt(&task_id, attempt)
968 .await
969 .map_err(|e| ApiError::engine(format!("raw_system_prompt: {e}")))?
970 .ok_or_else(|| {
971 ApiError::not_found(format!(
972 "no baked system prompt for task {task_id} attempt {attempt}"
973 ))
974 })?;
975 Ok((
976 [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
977 system,
978 ))
979}
980
981#[derive(Debug, serde::Serialize)]
983pub struct AgentRenderSizeResponse {
984 pub agent: String,
986 pub last_rendered_bytes: Option<usize>,
990}
991
992pub async fn agent_render_size(
1002 State(state): State<AppState>,
1003 axum::extract::Path(name): axum::extract::Path<String>,
1004) -> Json<AgentRenderSizeResponse> {
1005 let last_rendered_bytes = state.engine.agent_last_rendered_size(&name).await;
1006 Json(AgentRenderSizeResponse {
1007 agent: name,
1008 last_rendered_bytes,
1009 })
1010}
1011
1012fn extract_bearer_raw(headers: &HeaderMap) -> Result<String, ApiError> {
1016 let v = headers
1017 .get(AUTHORIZATION)
1018 .ok_or_else(|| ApiError::bad_request("missing Authorization header".into()))?
1019 .to_str()
1020 .map_err(|_| ApiError::bad_request("invalid Authorization header encoding".into()))?;
1021 let s = v
1022 .strip_prefix("Bearer ")
1023 .ok_or_else(|| ApiError::bad_request("Authorization must be 'Bearer <token>'".into()))?
1024 .trim();
1025 if s.is_empty() {
1026 return Err(ApiError::bad_request("Bearer is empty".into()));
1027 }
1028 Ok(s.to_string())
1029}
1030
1031fn parse_worker_handle(s: &str) -> Option<&str> {
1035 let s = s.trim();
1036 if s.starts_with("wh-")
1037 && s.len() >= 5
1038 && s.len() <= 64
1039 && s[3..].chars().all(|c| c.is_ascii_alphanumeric())
1040 {
1041 Some(s)
1042 } else {
1043 None
1044 }
1045}
1046
1047fn decode_worker_bearer(headers: &HeaderMap) -> Result<CapToken, ApiError> {
1051 let v = headers
1052 .get(AUTHORIZATION)
1053 .ok_or_else(|| ApiError::bad_request("missing Authorization header".into()))?
1054 .to_str()
1055 .map_err(|_| ApiError::bad_request("invalid Authorization header encoding".into()))?;
1056 let encoded = v
1057 .strip_prefix("Bearer ")
1058 .ok_or_else(|| ApiError::bad_request("Authorization must be 'Bearer <token>'".into()))?
1059 .trim();
1060 if encoded.is_empty() {
1061 return Err(ApiError::bad_request("Bearer token is empty".into()));
1062 }
1063 CapToken::decode(encoded).map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))
1064}
1065
1066#[cfg(test)]
1071mod tests {
1072 use super::*;
1073 use axum::response::IntoResponse;
1074 use mlua_swarm::core::agent_context::AgentContextView;
1075 use mlua_swarm::core::config::EngineCfg;
1076 use mlua_swarm::core::engine::Engine;
1077 use mlua_swarm::store::output::{InMemoryOutputStore, OutputStore};
1078 use mlua_swarm::store::run::{InMemoryRunStore, RunRecord, RunStatus, RunStore, StepEntry};
1079 use mlua_swarm::store::task::InMemoryTaskStore;
1080 use mlua_swarm::{RunId, StepId, TaskId};
1081 use serde_json::json;
1082 use std::collections::HashMap;
1083 use std::sync::Arc;
1084 use tokio::sync::Mutex;
1085
1086 fn test_state(data_store: Arc<dyn OutputStore>, run_store: Arc<dyn RunStore>) -> AppState {
1092 let engine = Engine::new(EngineCfg::default());
1093 let compiler = mlua_swarm::Compiler::new(crate::default_registry());
1094 let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
1095 AppState {
1096 engine,
1097 sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
1098 task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
1099 ws_operator_factory: None,
1100 data_store,
1101 operator_sessions: Arc::new(Mutex::new(HashMap::new())),
1102 roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
1103 task_store: Arc::new(InMemoryTaskStore::new()),
1104 run_store,
1105 base_url: None,
1106 sync_timeout_secs: 300,
1107 }
1108 }
1109
1110 async fn append_final(
1111 data_store: &Arc<dyn OutputStore>,
1112 task_id: &str,
1113 producer: &str,
1114 value: Value,
1115 ) {
1116 data_store
1117 .append(
1118 task_id,
1119 1,
1120 producer,
1121 OutputEvent::Final {
1122 content: ContentRef::Inline { value },
1123 ok: true,
1124 },
1125 vec![],
1126 )
1127 .await
1128 .expect("append final");
1129 }
1130
1131 fn step_entry(step_id: &StepId, step_ref: &str) -> StepEntry {
1132 StepEntry {
1133 step_id: step_id.clone(),
1134 step_ref: Some(step_ref.to_string()),
1135 status: Some("passed".to_string()),
1136 at: 0,
1137 }
1138 }
1139
1140 fn run_record(task_id: &TaskId, run_id: &RunId, step_entries: Vec<StepEntry>) -> RunRecord {
1141 RunRecord {
1142 id: run_id.clone(),
1143 task_id: task_id.clone(),
1144 status: RunStatus::Running,
1145 step_entries,
1146 degradations: Vec::new(),
1147 operator_sid: None,
1148 result_ref: None,
1149 created_at: 0,
1150 updated_at: 0,
1151 }
1152 }
1153
1154 fn consumer_payload(consumer_step_id: &StepId, run_id: &RunId) -> WorkerPayload {
1155 WorkerPayload {
1156 task_id: consumer_step_id.clone(),
1157 attempt: 1,
1158 agent: "consumer".to_string(),
1159 system: None,
1160 prompt: String::new(),
1161 context: Some(AgentContextView {
1162 task_id: consumer_step_id.to_string(),
1163 agent: "consumer".to_string(),
1164 attempt: 1,
1165 run_id: Some(run_id.to_string()),
1166 ..Default::default()
1167 }),
1168 system_ref: None,
1169 }
1170 }
1171
1172 #[tokio::test]
1177 async fn context_policy_unspecified_yields_every_submitted_step() {
1178 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1179 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1180 let task_id = TaskId::new();
1181 let run_id = RunId::new();
1182 let planner_id = StepId::new();
1183 let coder_id = StepId::new();
1184
1185 append_final(
1186 &data_store,
1187 planner_id.as_str(),
1188 "planner",
1189 json!({"plan": "x"}),
1190 )
1191 .await;
1192 append_final(
1193 &data_store,
1194 coder_id.as_str(),
1195 "coder",
1196 json!({"code": "y"}),
1197 )
1198 .await;
1199 run_store
1200 .create(run_record(
1201 &task_id,
1202 &run_id,
1203 vec![
1204 step_entry(&planner_id, "planner"),
1205 step_entry(&coder_id, "coder"),
1206 ],
1207 ))
1208 .await
1209 .expect("create run");
1210
1211 let state = test_state(data_store, run_store);
1212 let consumer_id = StepId::new();
1213 let mut payload = consumer_payload(&consumer_id, &run_id);
1214 assemble_step_pointers(&state, &mut payload).await;
1215
1216 let names: Vec<&str> = payload
1217 .context
1218 .as_ref()
1219 .expect("context")
1220 .steps
1221 .iter()
1222 .map(|p| p.name.as_str())
1223 .collect();
1224 assert!(names.contains(&"planner"), "names: {names:?}");
1225 assert!(names.contains(&"coder"), "names: {names:?}");
1226 }
1227
1228 #[tokio::test]
1230 async fn context_policy_steps_include_list_filters_to_named_steps() {
1231 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1232 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1233 let task_id = TaskId::new();
1234 let run_id = RunId::new();
1235 let planner_id = StepId::new();
1236 let coder_id = StepId::new();
1237 append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1238 append_final(&data_store, coder_id.as_str(), "coder", json!("y")).await;
1239 run_store
1240 .create(run_record(
1241 &task_id,
1242 &run_id,
1243 vec![
1244 step_entry(&planner_id, "planner"),
1245 step_entry(&coder_id, "coder"),
1246 ],
1247 ))
1248 .await
1249 .expect("create run");
1250
1251 let state = test_state(data_store, run_store);
1252 let consumer_id = StepId::new();
1253 state
1254 .engine
1255 .with_state("test.seed_policy", {
1256 let consumer_id = consumer_id.clone();
1257 move |s| {
1258 s.agent_ctx.insert(
1259 (consumer_id, 1),
1260 mlua_swarm::core::state::AgentCtxEntry {
1261 policy: mlua_swarm_schema::ContextPolicy {
1262 steps: Some(vec!["planner".to_string()]),
1263 ..Default::default()
1264 },
1265 ..Default::default()
1266 },
1267 );
1268 }
1269 })
1270 .await
1271 .expect("seed policy");
1272
1273 let mut payload = consumer_payload(&consumer_id, &run_id);
1274 assemble_step_pointers(&state, &mut payload).await;
1275
1276 let names: Vec<&str> = payload
1277 .context
1278 .as_ref()
1279 .expect("context")
1280 .steps
1281 .iter()
1282 .map(|p| p.name.as_str())
1283 .collect();
1284 assert_eq!(names, vec!["planner"], "names: {names:?}");
1285 }
1286
1287 #[tokio::test]
1289 async fn context_policy_steps_empty_list_yields_no_pointers() {
1290 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1291 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1292 let task_id = TaskId::new();
1293 let run_id = RunId::new();
1294 let planner_id = StepId::new();
1295 append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1296 run_store
1297 .create(run_record(
1298 &task_id,
1299 &run_id,
1300 vec![step_entry(&planner_id, "planner")],
1301 ))
1302 .await
1303 .expect("create run");
1304
1305 let state = test_state(data_store, run_store);
1306 let consumer_id = StepId::new();
1307 state
1308 .engine
1309 .with_state("test.seed_policy", {
1310 let consumer_id = consumer_id.clone();
1311 move |s| {
1312 s.agent_ctx.insert(
1313 (consumer_id, 1),
1314 mlua_swarm::core::state::AgentCtxEntry {
1315 policy: mlua_swarm_schema::ContextPolicy {
1316 steps: Some(vec![]),
1317 ..Default::default()
1318 },
1319 ..Default::default()
1320 },
1321 );
1322 }
1323 })
1324 .await
1325 .expect("seed policy");
1326
1327 let mut payload = consumer_payload(&consumer_id, &run_id);
1328 assemble_step_pointers(&state, &mut payload).await;
1329
1330 assert!(payload.context.expect("context").steps.is_empty());
1331 }
1332
1333 #[tokio::test]
1335 async fn context_policy_steps_exclude_wins_over_steps() {
1336 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1337 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1338 let task_id = TaskId::new();
1339 let run_id = RunId::new();
1340 let planner_id = StepId::new();
1341 let coder_id = StepId::new();
1342 append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1343 append_final(&data_store, coder_id.as_str(), "coder", json!("y")).await;
1344 run_store
1345 .create(run_record(
1346 &task_id,
1347 &run_id,
1348 vec![
1349 step_entry(&planner_id, "planner"),
1350 step_entry(&coder_id, "coder"),
1351 ],
1352 ))
1353 .await
1354 .expect("create run");
1355
1356 let state = test_state(data_store, run_store);
1357 let consumer_id = StepId::new();
1358 state
1359 .engine
1360 .with_state("test.seed_policy", {
1361 let consumer_id = consumer_id.clone();
1362 move |s| {
1363 s.agent_ctx.insert(
1364 (consumer_id, 1),
1365 mlua_swarm::core::state::AgentCtxEntry {
1366 policy: mlua_swarm_schema::ContextPolicy {
1367 steps: Some(vec!["planner".to_string(), "coder".to_string()]),
1368 steps_exclude: vec!["planner".to_string()],
1369 ..Default::default()
1370 },
1371 ..Default::default()
1372 },
1373 );
1374 }
1375 })
1376 .await
1377 .expect("seed policy");
1378
1379 let mut payload = consumer_payload(&consumer_id, &run_id);
1380 assemble_step_pointers(&state, &mut payload).await;
1381
1382 let names: Vec<&str> = payload
1383 .context
1384 .as_ref()
1385 .expect("context")
1386 .steps
1387 .iter()
1388 .map(|p| p.name.as_str())
1389 .collect();
1390 assert_eq!(names, vec!["coder"], "names: {names:?}");
1391 }
1392
1393 #[tokio::test]
1402 async fn in_flight_step_output_is_visible_before_run_finalizes() {
1403 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1404 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1405 let task_id = TaskId::new();
1406 let run_id = RunId::new();
1407 let step1_id = StepId::new();
1408 append_final(
1409 &data_store,
1410 step1_id.as_str(),
1411 "step1",
1412 json!({"step1_out": "hi"}),
1413 )
1414 .await;
1415 let mut run = run_record(&task_id, &run_id, vec![step_entry(&step1_id, "step1")]);
1416 run.status = RunStatus::Running;
1417 run.result_ref = None; run_store.create(run).await.expect("create run");
1419
1420 let state = test_state(data_store, run_store);
1421 let consumer_id = StepId::new();
1422 let mut payload = consumer_payload(&consumer_id, &run_id);
1423 assemble_step_pointers(&state, &mut payload).await;
1424
1425 let steps = &payload.context.expect("context").steps;
1426 assert_eq!(steps.len(), 1);
1427 assert_eq!(steps[0].name, "step1");
1428 }
1429
1430 #[tokio::test]
1434 async fn self_agent_name_is_always_excluded() {
1435 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1436 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1437 let task_id = TaskId::new();
1438 let run_id = RunId::new();
1439 let planner_id = StepId::new();
1440 let consumer_prior_id = StepId::new();
1441 append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1442 append_final(
1443 &data_store,
1444 consumer_prior_id.as_str(),
1445 "consumer",
1446 json!("self"),
1447 )
1448 .await;
1449 run_store
1450 .create(run_record(
1451 &task_id,
1452 &run_id,
1453 vec![
1454 step_entry(&planner_id, "planner"),
1455 step_entry(&consumer_prior_id, "consumer"),
1456 ],
1457 ))
1458 .await
1459 .expect("create run");
1460
1461 let state = test_state(data_store, run_store);
1462 let consumer_id = StepId::new();
1463 let mut payload = consumer_payload(&consumer_id, &run_id);
1464 assemble_step_pointers(&state, &mut payload).await;
1465
1466 let names: Vec<&str> = payload
1467 .context
1468 .as_ref()
1469 .expect("context")
1470 .steps
1471 .iter()
1472 .map(|p| p.name.as_str())
1473 .collect();
1474 assert!(!names.contains(&"consumer"), "names: {names:?}");
1475 assert!(names.contains(&"planner"), "names: {names:?}");
1476 }
1477
1478 #[tokio::test]
1482 async fn step_pointer_serializes_with_no_preview_or_content_bytes() {
1483 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1484 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1485 let task_id = TaskId::new();
1486 let run_id = RunId::new();
1487 let planner_id = StepId::new();
1488 append_final(
1489 &data_store,
1490 planner_id.as_str(),
1491 "planner",
1492 json!({"plan": "do the thing, at length".repeat(50)}),
1493 )
1494 .await;
1495 run_store
1496 .create(run_record(
1497 &task_id,
1498 &run_id,
1499 vec![step_entry(&planner_id, "planner")],
1500 ))
1501 .await
1502 .expect("create run");
1503
1504 let state = test_state(data_store, run_store);
1505 let consumer_id = StepId::new();
1506 let mut payload = consumer_payload(&consumer_id, &run_id);
1507 assemble_step_pointers(&state, &mut payload).await;
1508
1509 let steps = &payload.context.expect("context").steps;
1510 assert_eq!(steps.len(), 1);
1511 let json_value = serde_json::to_value(&steps[0]).expect("serialize StepPointer");
1512 let obj = json_value.as_object().expect("object");
1513 for forbidden in ["preview", "content", "value", "bytes"] {
1514 assert!(
1515 !obj.contains_key(forbidden),
1516 "StepPointer must not carry a {forbidden:?} field: {obj:?}"
1517 );
1518 }
1519 assert!(obj.contains_key("name"));
1520 assert!(obj.contains_key("size_bytes"));
1521 assert!(obj.contains_key("content_url"));
1522 assert!(obj.contains_key("sha256"));
1523 }
1524
1525 fn declared_name_bp() -> mlua_swarm::blueprint::Blueprint {
1533 use mlua_flow_ir::{Expr, Node};
1534 use mlua_swarm::blueprint::{
1535 current_schema_version, AgentDef, AgentKind, AgentMeta, Blueprint, BlueprintMetadata,
1536 CompilerHints, CompilerStrategy,
1537 };
1538 Blueprint {
1539 schema_version: current_schema_version(),
1540 id: "worker-test-declared-name-bp".into(),
1541 flow: Node::Step {
1542 ref_: "planner".to_string(),
1543 in_: Expr::Path {
1544 at: "$.in".parse().expect("literal test path: $.in"),
1545 },
1546 out: Expr::Path {
1547 at: "$.plan".parse().expect("literal test path: $.plan"),
1548 },
1549 },
1550 agents: vec![AgentDef {
1551 name: "planner".to_string(),
1552 kind: AgentKind::RustFn,
1553 spec: json!({"fn_id": "planner"}),
1554 profile: None,
1555 meta: Some(AgentMeta {
1556 projection_name: Some("plan-out".to_string()),
1557 ..Default::default()
1558 }),
1559 runner: None,
1560 runner_ref: None,
1561 verdict: None,
1562 }],
1563 operators: vec![],
1564 metas: vec![],
1565 hints: CompilerHints::default(),
1566 strategy: CompilerStrategy::default(),
1567 metadata: BlueprintMetadata::default(),
1568 spawner_hints: Default::default(),
1569 default_agent_kind: AgentKind::Operator,
1570 default_operator_kind: None,
1571 default_init_ctx: None,
1572 default_agent_ctx: None,
1573 default_context_policy: None,
1574 projection_placement: None,
1575 audits: vec![],
1576 degradation_policy: None,
1577 runners: vec![],
1578 default_runner: None,
1579 check_policy: None,
1580 }
1581 }
1582
1583 #[tokio::test]
1589 async fn declared_projection_name_pointer_name_is_canonical_and_policy_matches_it() {
1590 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1591 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1592 let task_id = TaskId::new();
1593 let run_id = RunId::new();
1594 let planner_id = StepId::new();
1595
1596 append_final(
1599 &data_store,
1600 planner_id.as_str(),
1601 "plan-out",
1602 json!({"plan": "x"}),
1603 )
1604 .await;
1605 run_store
1606 .create(run_record(
1607 &task_id,
1608 &run_id,
1609 vec![step_entry(&planner_id, "planner")],
1610 ))
1611 .await
1612 .expect("create run");
1613
1614 let state = test_state(data_store, run_store);
1615
1616 let (naming, _warnings) =
1622 mlua_swarm::core::step_naming::StepNaming::from_blueprint(&declared_name_bp())
1623 .expect("no collision");
1624 let naming = Arc::new(naming);
1625 let consumer_id = StepId::new();
1626 state
1627 .engine
1628 .with_state("test.seed_step_naming", {
1629 let naming = naming.clone();
1630 let planner_id = planner_id.clone();
1631 let consumer_id = consumer_id.clone();
1632 move |s| {
1633 s.step_namings.insert(planner_id, naming.clone());
1634 s.step_namings.insert(consumer_id, naming);
1635 }
1636 })
1637 .await
1638 .expect("seed step naming");
1639 state
1640 .engine
1641 .with_state("test.seed_policy", {
1642 let consumer_id = consumer_id.clone();
1643 move |s| {
1644 s.agent_ctx.insert(
1645 (consumer_id, 1),
1646 mlua_swarm::core::state::AgentCtxEntry {
1647 policy: mlua_swarm_schema::ContextPolicy {
1648 steps: Some(vec!["plan-out".to_string()]),
1649 ..Default::default()
1650 },
1651 ..Default::default()
1652 },
1653 );
1654 }
1655 })
1656 .await
1657 .expect("seed policy");
1658
1659 let mut payload = consumer_payload(&consumer_id, &run_id);
1660 assemble_step_pointers(&state, &mut payload).await;
1661
1662 let steps = &payload.context.expect("context").steps;
1663 assert_eq!(steps.len(), 1, "steps: {steps:?}");
1664 assert_eq!(
1665 steps[0].name, "plan-out",
1666 "StepPointer.name must be the canonical name"
1667 );
1668 }
1669
1670 async fn seed_task_with_handle(
1680 state: &AppState,
1681 task_id: &StepId,
1682 agent: &str,
1683 attempt: u32,
1684 system: Option<String>,
1685 ) -> String {
1686 let handle = format!("wh-{}", mlua_swarm::types::secure_hex(4));
1687 let task_id = task_id.clone();
1688 let agent = agent.to_string();
1689 let handle_clone = handle.clone();
1690 state
1691 .engine
1692 .with_state("test.seed_task_with_handle", move |s| {
1693 let mut task = mlua_swarm::core::state::TaskState::new(
1694 task_id.clone(),
1695 mlua_swarm::core::state::TaskSpec {
1696 agent: agent.clone(),
1697 initial_directive: json!("x"),
1698 step_ctx: None,
1699 check_policy: None,
1700 },
1701 );
1702 task.attempt = attempt;
1703 s.tasks.insert(task_id.clone(), task);
1704 s.systems.insert((task_id.clone(), attempt), system);
1705 let token = CapToken {
1706 agent_id: agent,
1707 role: mlua_swarm::Role::Worker,
1708 scopes: vec!["*".to_string()],
1709 issued_at: 0,
1710 expire_at: u64::MAX,
1711 max_uses: None,
1712 nonce: format!("test-nonce-{task_id}"),
1713 sig_hex: String::new(),
1714 };
1715 let fp = token.fingerprint();
1716 s.tokens.insert(
1717 fp.clone(),
1718 mlua_swarm::core::state::CapTokenRecord {
1719 token,
1720 uses_left: None,
1721 revoked: false,
1722 task_id: Some(task_id),
1723 },
1724 );
1725 s.worker_handles.insert(handle_clone, fp);
1726 })
1727 .await
1728 .expect("seed_task_with_handle");
1729 handle
1730 }
1731
1732 fn bearer_headers(handle: &str) -> HeaderMap {
1733 let mut headers = HeaderMap::new();
1734 headers.insert(
1735 AUTHORIZATION,
1736 format!("Bearer {handle}").parse().expect("header value"),
1737 );
1738 headers
1739 }
1740
1741 #[tokio::test]
1745 async fn worker_prompt_system_returns_raw_bytes_for_baked_system() {
1746 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1747 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1748 let state = test_state(data_store, run_store);
1749 let task_id = StepId::new();
1750 let rendered = "# Hello\n\nThis is the baked system prompt.".to_string();
1751 let handle =
1752 seed_task_with_handle(&state, &task_id, "planner", 1, Some(rendered.clone())).await;
1753
1754 let resp = worker_prompt_system(
1755 State(state.clone()),
1756 bearer_headers(&handle),
1757 Query(PromptSystemQuery {
1758 task_id: task_id.clone(),
1759 attempt: 1,
1760 }),
1761 )
1762 .await
1763 .expect("worker_prompt_system")
1764 .into_response();
1765
1766 assert_eq!(resp.status(), StatusCode::OK);
1767 let content_type = resp
1768 .headers()
1769 .get(header::CONTENT_TYPE)
1770 .expect("content-type header")
1771 .to_str()
1772 .expect("ascii");
1773 assert_eq!(content_type, "text/plain; charset=utf-8");
1774 let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1775 .await
1776 .expect("body bytes");
1777 assert_eq!(body_bytes.as_ref(), rendered.as_bytes());
1778 }
1779
1780 #[tokio::test]
1783 async fn worker_prompt_system_404s_when_no_baked_system() {
1784 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1785 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1786 let state = test_state(data_store, run_store);
1787 let task_id = StepId::new();
1788 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
1789
1790 let result = worker_prompt_system(
1791 State(state.clone()),
1792 bearer_headers(&handle),
1793 Query(PromptSystemQuery {
1794 task_id: task_id.clone(),
1795 attempt: 1,
1796 }),
1797 )
1798 .await;
1799 let err = match result {
1800 Ok(_) => panic!("expected 404 ApiError, got Ok"),
1801 Err(e) => e,
1802 };
1803 assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND);
1804 }
1805
1806 #[tokio::test]
1809 async fn worker_prompt_system_rejects_handle_task_mismatch() {
1810 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1811 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1812 let state = test_state(data_store, run_store);
1813 let task_id = StepId::new();
1814 let other_task_id = StepId::new();
1815 let handle =
1816 seed_task_with_handle(&state, &task_id, "planner", 1, Some("x".to_string())).await;
1817
1818 let result = worker_prompt_system(
1819 State(state.clone()),
1820 bearer_headers(&handle),
1821 Query(PromptSystemQuery {
1822 task_id: other_task_id,
1823 attempt: 1,
1824 }),
1825 )
1826 .await;
1827 let err = match result {
1828 Ok(_) => panic!("expected 400 ApiError for task mismatch, got Ok"),
1829 Err(e) => e,
1830 };
1831 assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
1832 }
1833
1834 #[tokio::test]
1838 async fn agent_render_size_returns_null_for_unknown_agent() {
1839 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1840 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1841 let state = test_state(data_store, run_store);
1842
1843 let Json(body) = agent_render_size(
1844 State(state.clone()),
1845 axum::extract::Path("never-dispatched".to_string()),
1846 )
1847 .await;
1848 assert_eq!(body.agent, "never-dispatched");
1849 assert_eq!(body.last_rendered_bytes, None);
1850 }
1851
1852 #[tokio::test]
1855 async fn agent_render_size_reports_last_rendered_bytes() {
1856 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1857 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1858 let state = test_state(data_store, run_store);
1859 let task_id = StepId::new();
1860 state
1861 .engine
1862 .with_state("test.seed_agent_ctx_for_bake", {
1863 let task_id = task_id.clone();
1864 move |s| {
1865 s.tasks.insert(
1866 task_id.clone(),
1867 mlua_swarm::core::state::TaskState::new(
1868 task_id,
1869 mlua_swarm::core::state::TaskSpec {
1870 agent: "coder".to_string(),
1871 initial_directive: json!("x"),
1872 step_ctx: None,
1873 check_policy: None,
1874 },
1875 ),
1876 );
1877 }
1878 })
1879 .await
1880 .expect("seed task");
1881 state
1882 .engine
1883 .bake_worker_system_prompt(&task_id, 1, Some("z".repeat(42)))
1884 .await
1885 .expect("bake_worker_system_prompt");
1886
1887 let Json(body) = agent_render_size(
1888 State(state.clone()),
1889 axum::extract::Path("coder".to_string()),
1890 )
1891 .await;
1892 assert_eq!(body.agent, "coder");
1893 assert_eq!(body.last_rendered_bytes, Some(42));
1894 }
1895
1896 #[tokio::test]
1904 async fn worker_artifact_stages_and_204s_for_valid_request() {
1905 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1906 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1907 let state = test_state(data_store, run_store);
1908 let task_id = StepId::new();
1909 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
1910
1911 let status = worker_artifact(
1912 State(state.clone()),
1913 bearer_headers(&handle),
1914 Query(ArtifactQuery {
1915 name: "summary".to_string(),
1916 }),
1917 axum::body::Bytes::from_static(b"hello artifact\n"),
1918 )
1919 .await
1920 .expect("worker_artifact");
1921 assert_eq!(status, StatusCode::NO_CONTENT);
1922
1923 let tail = state.engine.output_tail(&task_id, 1).await;
1924 assert_eq!(tail.len(), 1, "tail: {tail:?}");
1925 match &tail[0] {
1926 OutputEvent::Artifact { name, content } => {
1927 assert_eq!(name, "summary");
1928 match content {
1929 ContentRef::Inline { value } => {
1930 assert_eq!(value, &json!("hello artifact"));
1931 }
1932 other => panic!("expected Inline content, got {other:?}"),
1933 }
1934 }
1935 other => panic!("expected Artifact event, got {other:?}"),
1936 }
1937 }
1938
1939 #[tokio::test]
1946 async fn worker_artifact_rejects_blank_name() {
1947 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1948 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1949 let state = test_state(data_store, run_store);
1950 let task_id = StepId::new();
1951 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
1952
1953 let result = worker_artifact(
1954 State(state.clone()),
1955 bearer_headers(&handle),
1956 Query(ArtifactQuery {
1957 name: " ".to_string(),
1958 }),
1959 axum::body::Bytes::from_static(b"x"),
1960 )
1961 .await;
1962 let err = match result {
1963 Ok(_) => panic!("expected 400 ApiError for blank name, got Ok"),
1964 Err(e) => e,
1965 };
1966 assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
1967
1968 assert!(state.engine.output_tail(&task_id, 1).await.is_empty());
1970 }
1971
1972 #[tokio::test]
1978 async fn worker_artifact_staging_same_name_twice_appends_both_events_in_order() {
1979 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1980 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1981 let state = test_state(data_store, run_store);
1982 let task_id = StepId::new();
1983 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
1984
1985 for body in [b"first".as_slice(), b"second".as_slice()] {
1986 worker_artifact(
1987 State(state.clone()),
1988 bearer_headers(&handle),
1989 Query(ArtifactQuery {
1990 name: "a".to_string(),
1991 }),
1992 axum::body::Bytes::copy_from_slice(body),
1993 )
1994 .await
1995 .expect("worker_artifact");
1996 }
1997
1998 let tail = state.engine.output_tail(&task_id, 1).await;
1999 assert_eq!(tail.len(), 2, "tail: {tail:?}");
2000 let values: Vec<&str> = tail
2001 .iter()
2002 .map(|ev| match ev {
2003 OutputEvent::Artifact {
2004 content: ContentRef::Inline { value },
2005 ..
2006 } => value.as_str().expect("string value"),
2007 other => panic!("expected Artifact/Inline event, got {other:?}"),
2008 })
2009 .collect();
2010 assert_eq!(values, vec!["first", "second"]);
2011 }
2012
2013 async fn link_task_to_run(state: &AppState, task_id: &StepId, attempt: u32, run_id: &RunId) {
2021 let tid = task_id.clone();
2022 let rid_str = run_id.to_string();
2023 state
2024 .engine
2025 .with_state("test.link_task_to_run", move |s| {
2026 let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2027 entry.view.run_id = Some(rid_str);
2028 s.agent_ctx.insert((tid, attempt), entry);
2029 })
2030 .await
2031 .expect("link_task_to_run");
2032 }
2033
2034 #[tokio::test]
2039 async fn submit_and_artifact_against_terminal_run_return_410() {
2040 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2041 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2042 let state = test_state(data_store, run_store.clone());
2043 let task_id = StepId::new();
2044 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2045
2046 let owner_task = TaskId::new();
2047 let run_id = RunId::new();
2048 let mut rec = run_record(&owner_task, &run_id, vec![]);
2049 rec.status = RunStatus::Failed;
2050 run_store.create(rec).await.expect("run create");
2051 link_task_to_run(&state, &task_id, 1, &run_id).await;
2052
2053 let err = worker_submit(
2054 State(state.clone()),
2055 bearer_headers(&handle),
2056 Query(SubmitQuery { ok: None }),
2057 axum::body::Bytes::from_static(b"LATE OUTPUT"),
2058 )
2059 .await
2060 .expect_err("a submit against a Failed run must be rejected");
2061 assert_eq!(err.status, StatusCode::GONE);
2062 assert!(
2063 err.message.contains(&run_id.to_string()),
2064 "the 410 must name the terminal run: {}",
2065 err.message
2066 );
2067
2068 let err = worker_artifact(
2069 State(state.clone()),
2070 bearer_headers(&handle),
2071 Query(ArtifactQuery {
2072 name: "part.md".to_string(),
2073 }),
2074 axum::body::Bytes::from_static(b"LATE PART"),
2075 )
2076 .await
2077 .expect_err("an artifact staged against a Failed run must be rejected");
2078 assert_eq!(err.status, StatusCode::GONE);
2079
2080 let tail = state.engine.output_tail(&task_id, 1).await;
2082 assert!(tail.is_empty(), "rejected submits must not land: {tail:?}");
2083 }
2084
2085 #[tokio::test]
2089 async fn terminal_run_guard_is_fail_open() {
2090 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2091 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2092 let state = test_state(data_store, run_store.clone());
2093 let task_id = StepId::new();
2094 seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2095
2096 reject_if_run_terminal(&state, &task_id, 1)
2098 .await
2099 .expect("no linkage must fail open");
2100
2101 let unknown_run = RunId::new();
2103 link_task_to_run(&state, &task_id, 1, &unknown_run).await;
2104 reject_if_run_terminal(&state, &task_id, 1)
2105 .await
2106 .expect("unknown run must fail open");
2107
2108 let owner_task = TaskId::new();
2110 let live_run = RunId::new();
2111 run_store
2112 .create(run_record(&owner_task, &live_run, vec![]))
2113 .await
2114 .expect("run create");
2115 link_task_to_run(&state, &task_id, 1, &live_run).await;
2116 reject_if_run_terminal(&state, &task_id, 1)
2117 .await
2118 .expect("a Running run must pass the guard");
2119 }
2120
2121 fn degradation_body(tool: &str, note: Option<&str>) -> DegradationBody {
2126 DegradationBody {
2127 tool: tool.to_string(),
2128 error: "boom".to_string(),
2129 fallback: "used cached value".to_string(),
2130 note: note.map(str::to_string),
2131 }
2132 }
2133
2134 async fn link_task_to_run_with_agent(
2140 state: &AppState,
2141 task_id: &StepId,
2142 attempt: u32,
2143 run_id: &RunId,
2144 agent: &str,
2145 ) {
2146 let tid = task_id.clone();
2147 let rid_str = run_id.to_string();
2148 let agent = agent.to_string();
2149 state
2150 .engine
2151 .with_state("test.link_task_to_run_with_agent", move |s| {
2152 let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2153 entry.view.run_id = Some(rid_str);
2154 entry.view.agent = agent;
2155 s.agent_ctx.insert((tid, attempt), entry);
2156 })
2157 .await
2158 .expect("link_task_to_run_with_agent");
2159 }
2160
2161 #[tokio::test]
2166 async fn worker_degradation_persists_entry_when_run_tracked() {
2167 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2168 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2169 let state = test_state(data_store, run_store.clone());
2170 let task_id = StepId::new();
2171 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2172
2173 let owner_task = TaskId::new();
2174 let run_id = RunId::new();
2175 run_store
2176 .create(run_record(&owner_task, &run_id, vec![]))
2177 .await
2178 .expect("run create");
2179 link_task_to_run_with_agent(&state, &task_id, 1, &run_id, "planner").await;
2180
2181 let status = worker_degradation(
2182 State(state.clone()),
2183 bearer_headers(&handle),
2184 Json(degradation_body("web_search", Some("rate limited"))),
2185 )
2186 .await
2187 .expect("worker_degradation");
2188 assert_eq!(status, StatusCode::NO_CONTENT);
2189
2190 let rec = run_store.get(&run_id).await.expect("run get");
2191 assert_eq!(
2192 rec.degradations.len(),
2193 1,
2194 "degradations: {:?}",
2195 rec.degradations
2196 );
2197 let entry = &rec.degradations[0];
2198 assert_eq!(entry.tool, "web_search");
2199 assert_eq!(entry.error, "boom");
2200 assert_eq!(entry.fallback, "used cached value");
2201 assert_eq!(entry.note.as_deref(), Some("rate limited"));
2202 assert_eq!(entry.step_ref.as_deref(), Some("planner"));
2203 assert_eq!(entry.attempt, Some(1));
2204 assert!(entry.at > 0, "at must be a real timestamp: {}", entry.at);
2205 }
2206
2207 #[tokio::test]
2209 async fn worker_degradation_appends_in_order() {
2210 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2211 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2212 let state = test_state(data_store, run_store.clone());
2213 let task_id = StepId::new();
2214 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2215
2216 let owner_task = TaskId::new();
2217 let run_id = RunId::new();
2218 run_store
2219 .create(run_record(&owner_task, &run_id, vec![]))
2220 .await
2221 .expect("run create");
2222 link_task_to_run(&state, &task_id, 1, &run_id).await;
2223
2224 for tool in ["first_tool", "second_tool"] {
2225 worker_degradation(
2226 State(state.clone()),
2227 bearer_headers(&handle),
2228 Json(degradation_body(tool, None)),
2229 )
2230 .await
2231 .expect("worker_degradation");
2232 }
2233
2234 let rec = run_store.get(&run_id).await.expect("run get");
2235 let tools: Vec<&str> = rec.degradations.iter().map(|e| e.tool.as_str()).collect();
2236 assert_eq!(tools, vec!["first_tool", "second_tool"]);
2237 }
2238
2239 #[tokio::test]
2243 async fn worker_degradation_silent_ok_when_no_run_tracked() {
2244 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2245 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2246 let state = test_state(data_store, run_store);
2247 let task_id = StepId::new();
2248 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2249
2250 let status = worker_degradation(
2251 State(state.clone()),
2252 bearer_headers(&handle),
2253 Json(degradation_body("some_tool", None)),
2254 )
2255 .await
2256 .expect("worker_degradation must not error on missing run linkage");
2257 assert_eq!(status, StatusCode::NO_CONTENT);
2258 }
2259
2260 #[tokio::test]
2263 async fn worker_degradation_rejects_terminal_run() {
2264 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2265 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2266 let state = test_state(data_store, run_store.clone());
2267 let task_id = StepId::new();
2268 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2269
2270 let owner_task = TaskId::new();
2271 let run_id = RunId::new();
2272 let mut rec = run_record(&owner_task, &run_id, vec![]);
2273 rec.status = RunStatus::Done;
2274 run_store.create(rec).await.expect("run create");
2275 link_task_to_run(&state, &task_id, 1, &run_id).await;
2276
2277 let err = worker_degradation(
2278 State(state.clone()),
2279 bearer_headers(&handle),
2280 Json(degradation_body("some_tool", None)),
2281 )
2282 .await
2283 .expect_err("a degradation against a Done run must be rejected");
2284 assert_eq!(err.status, StatusCode::GONE);
2285
2286 let rec = run_store.get(&run_id).await.expect("run get");
2287 assert!(
2288 rec.degradations.is_empty(),
2289 "rejected degradation must not land: {:?}",
2290 rec.degradations
2291 );
2292 }
2293
2294 async fn seed_work_dir(
2308 state: &AppState,
2309 task_id: &StepId,
2310 attempt: u32,
2311 work_dir: &str,
2312 allow_file_submit: Option<Value>,
2313 ) {
2314 let tid = task_id.clone();
2315 let work_dir = work_dir.to_string();
2316 state
2317 .engine
2318 .with_state("test.seed_work_dir", move |s| {
2319 let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2320 entry.view.work_dir = Some(work_dir);
2321 if let Some(v) = allow_file_submit {
2322 entry
2323 .view
2324 .extra
2325 .insert(FILE_SENTINEL_ALLOW_KEY.to_string(), v);
2326 }
2327 s.agent_ctx.insert((tid, attempt), entry);
2328 })
2329 .await
2330 .expect("seed_work_dir");
2331 }
2332
2333 #[tokio::test]
2337 async fn worker_submit_resolves_file_sentinel_under_work_dir() {
2338 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2339 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2340 let state = test_state(data_store.clone(), run_store);
2341 let task_id = StepId::new();
2342 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2343
2344 let tmp = tempfile::tempdir().expect("tempdir");
2345 let work_dir = tmp.path().to_path_buf();
2346 seed_work_dir(
2347 &state,
2348 &task_id,
2349 1,
2350 work_dir.to_str().expect("work_dir utf-8"),
2351 Some(Value::Bool(true)),
2352 )
2353 .await;
2354
2355 let payload_path = work_dir.join("scout.md");
2356 let payload = "## Context Package (broad)\n\nlarge body content\n";
2357 tokio::fs::write(&payload_path, payload)
2358 .await
2359 .expect("write payload");
2360 let body = format!(
2361 "@file:{}",
2362 payload_path.to_str().expect("payload path utf-8")
2363 );
2364
2365 let status = worker_submit(
2366 State(state.clone()),
2367 bearer_headers(&handle),
2368 Query(SubmitQuery { ok: None }),
2369 axum::body::Bytes::from(body),
2370 )
2371 .await
2372 .expect("worker_submit sentinel");
2373 assert_eq!(status, StatusCode::NO_CONTENT);
2374
2375 let tid = task_id.clone();
2379 let value = state
2380 .engine
2381 .with_state("test.inspect_output_store", move |s| {
2382 s.output_store.get(&(tid.clone(), 1)).and_then(|evs| {
2383 evs.iter().find_map(|ev| match ev {
2384 OutputEvent::Final {
2385 content: ContentRef::Inline { value },
2386 ..
2387 } => Some(value.clone()),
2388 _ => None,
2389 })
2390 })
2391 })
2392 .await
2393 .expect("with_state")
2394 .expect("Final event present");
2395 assert_eq!(value, Value::String(payload.trim_end().to_string()));
2396 }
2397
2398 #[tokio::test]
2401 async fn worker_submit_passes_non_sentinel_body_unchanged() {
2402 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2403 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2404 let state = test_state(data_store.clone(), run_store);
2405 let task_id = StepId::new();
2406 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2407 let status = worker_submit(
2411 State(state.clone()),
2412 bearer_headers(&handle),
2413 Query(SubmitQuery { ok: None }),
2414 axum::body::Bytes::from_static(b"DONE yes=1 maybe=0 no=0"),
2415 )
2416 .await
2417 .expect("worker_submit inline");
2418 assert_eq!(status, StatusCode::NO_CONTENT);
2419
2420 let tid = task_id.clone();
2421 let value = state
2422 .engine
2423 .with_state("test.inspect_output_store", move |s| {
2424 s.output_store.get(&(tid.clone(), 1)).and_then(|evs| {
2425 evs.iter().find_map(|ev| match ev {
2426 OutputEvent::Final {
2427 content: ContentRef::Inline { value },
2428 ..
2429 } => Some(value.clone()),
2430 _ => None,
2431 })
2432 })
2433 })
2434 .await
2435 .expect("with_state")
2436 .expect("Final event present");
2437 assert_eq!(value, Value::String("DONE yes=1 maybe=0 no=0".to_string()));
2438 }
2439
2440 #[tokio::test]
2445 async fn worker_submit_rejects_sentinel_path_outside_work_dir() {
2446 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2447 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2448 let state = test_state(data_store, run_store);
2449 let task_id = StepId::new();
2450 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2451
2452 let allowed = tempfile::tempdir().expect("allowed tempdir");
2453 let outside = tempfile::tempdir().expect("outside tempdir");
2454 seed_work_dir(
2455 &state,
2456 &task_id,
2457 1,
2458 allowed.path().to_str().expect("utf-8"),
2459 Some(Value::Bool(true)),
2460 )
2461 .await;
2462
2463 let outside_file = outside.path().join("leak.md");
2464 tokio::fs::write(&outside_file, b"outside content")
2465 .await
2466 .expect("write outside");
2467 let body = format!(
2468 "@file:{}",
2469 outside_file.to_str().expect("outside path utf-8")
2470 );
2471
2472 let err = worker_submit(
2473 State(state.clone()),
2474 bearer_headers(&handle),
2475 Query(SubmitQuery { ok: None }),
2476 axum::body::Bytes::from(body),
2477 )
2478 .await
2479 .expect_err("outside-work_dir sentinel must be rejected");
2480 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2481 }
2482
2483 #[tokio::test]
2485 async fn worker_submit_rejects_sentinel_missing_file() {
2486 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2487 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2488 let state = test_state(data_store, run_store);
2489 let task_id = StepId::new();
2490 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2491
2492 let tmp = tempfile::tempdir().expect("tempdir");
2493 seed_work_dir(
2494 &state,
2495 &task_id,
2496 1,
2497 tmp.path().to_str().expect("utf-8"),
2498 Some(Value::Bool(true)),
2499 )
2500 .await;
2501 let missing = tmp.path().join("does-not-exist.md");
2502 let body = format!("@file:{}", missing.to_str().expect("utf-8"));
2503
2504 let err = worker_submit(
2505 State(state.clone()),
2506 bearer_headers(&handle),
2507 Query(SubmitQuery { ok: None }),
2508 axum::body::Bytes::from(body),
2509 )
2510 .await
2511 .expect_err("missing-file sentinel must be rejected");
2512 assert_eq!(err.status, StatusCode::NOT_FOUND);
2513 }
2514
2515 #[tokio::test]
2517 async fn worker_submit_rejects_sentinel_relative_path() {
2518 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2519 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2520 let state = test_state(data_store, run_store);
2521 let task_id = StepId::new();
2522 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2523
2524 let err = worker_submit(
2525 State(state.clone()),
2526 bearer_headers(&handle),
2527 Query(SubmitQuery { ok: None }),
2528 axum::body::Bytes::from_static(b"@file:relative/path.md"),
2529 )
2530 .await
2531 .expect_err("relative-path sentinel must be rejected");
2532 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2533 }
2534
2535 #[tokio::test]
2539 async fn worker_submit_rejects_sentinel_without_agent_context_view() {
2540 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2541 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2542 let state = test_state(data_store, run_store);
2543 let task_id = StepId::new();
2544 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2545 let err = worker_submit(
2548 State(state.clone()),
2549 bearer_headers(&handle),
2550 Query(SubmitQuery { ok: None }),
2551 axum::body::Bytes::from_static(b"@file:/tmp/anywhere.md"),
2552 )
2553 .await
2554 .expect_err("missing AgentContextView must reject sentinel");
2555 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2556 }
2557
2558 #[tokio::test]
2562 async fn worker_artifact_resolves_file_sentinel_under_work_dir() {
2563 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2564 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2565 let state = test_state(data_store, run_store);
2566 let task_id = StepId::new();
2567 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2568
2569 let tmp = tempfile::tempdir().expect("tempdir");
2570 seed_work_dir(
2571 &state,
2572 &task_id,
2573 1,
2574 tmp.path().to_str().expect("utf-8"),
2575 Some(Value::Bool(true)),
2576 )
2577 .await;
2578
2579 let payload_path = tmp.path().join("part.md");
2580 let payload = "artifact part body\n";
2581 tokio::fs::write(&payload_path, payload)
2582 .await
2583 .expect("write payload");
2584 let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
2585
2586 let status = worker_artifact(
2587 State(state.clone()),
2588 bearer_headers(&handle),
2589 Query(ArtifactQuery {
2590 name: "scout".to_string(),
2591 }),
2592 axum::body::Bytes::from(body),
2593 )
2594 .await
2595 .expect("worker_artifact sentinel");
2596 assert_eq!(status, StatusCode::NO_CONTENT);
2597 }
2598
2599 #[tokio::test]
2604 async fn worker_submit_rejects_sentinel_without_allow_flag() {
2605 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2606 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2607 let state = test_state(data_store, run_store);
2608 let task_id = StepId::new();
2609 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2610
2611 let tmp = tempfile::tempdir().expect("tempdir");
2612 seed_work_dir(
2613 &state,
2614 &task_id,
2615 1,
2616 tmp.path().to_str().expect("utf-8"),
2617 None,
2618 )
2619 .await;
2620
2621 let payload_path = tmp.path().join("out.md");
2622 tokio::fs::write(&payload_path, b"resolvable body")
2623 .await
2624 .expect("write payload");
2625 let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
2626
2627 let err = worker_submit(
2628 State(state.clone()),
2629 bearer_headers(&handle),
2630 Query(SubmitQuery { ok: None }),
2631 axum::body::Bytes::from(body),
2632 )
2633 .await
2634 .expect_err("missing opt-in must reject sentinel");
2635 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2636 assert!(
2637 err.message.contains("not allowed"),
2638 "rejection must name the opt-in guard, got: {}",
2639 err.message
2640 );
2641 }
2642
2643 #[tokio::test]
2646 async fn worker_submit_rejects_sentinel_with_non_true_allow_values() {
2647 for allow in [Value::Bool(false), Value::String("true".to_string())] {
2648 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2649 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2650 let state = test_state(data_store, run_store);
2651 let task_id = StepId::new();
2652 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2653
2654 let tmp = tempfile::tempdir().expect("tempdir");
2655 seed_work_dir(
2656 &state,
2657 &task_id,
2658 1,
2659 tmp.path().to_str().expect("utf-8"),
2660 Some(allow.clone()),
2661 )
2662 .await;
2663
2664 let payload_path = tmp.path().join("out.md");
2665 tokio::fs::write(&payload_path, b"resolvable body")
2666 .await
2667 .expect("write payload");
2668 let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
2669
2670 let err = worker_submit(
2671 State(state.clone()),
2672 bearer_headers(&handle),
2673 Query(SubmitQuery { ok: None }),
2674 axum::body::Bytes::from(body),
2675 )
2676 .await
2677 .expect_err("non-true opt-in value must reject sentinel");
2678 assert_eq!(err.status, StatusCode::BAD_REQUEST, "value: {allow:?}");
2679 }
2680 }
2681
2682 fn body_verdict_contract(values: &[&str]) -> mlua_swarm_schema::VerdictContract {
2693 mlua_swarm_schema::VerdictContract {
2694 channel: VerdictChannel::Body,
2695 values: values.iter().map(|v| v.to_string()).collect(),
2696 }
2697 }
2698
2699 fn part_verdict_contract(values: &[&str]) -> mlua_swarm_schema::VerdictContract {
2700 mlua_swarm_schema::VerdictContract {
2701 channel: VerdictChannel::Part,
2702 values: values.iter().map(|v| v.to_string()).collect(),
2703 }
2704 }
2705
2706 #[tokio::test]
2709 async fn worker_submit_rejects_body_outside_contract_values_with_422() {
2710 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2711 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2712 let state = test_state(data_store, run_store);
2713 let task_id = StepId::new();
2714 let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
2715 state.engine.register_verdict_contracts(HashMap::from([(
2716 "gate".to_string(),
2717 body_verdict_contract(&["PASS", "BLOCKED"]),
2718 )]));
2719
2720 let err = worker_submit(
2721 State(state.clone()),
2722 bearer_headers(&handle),
2723 Query(SubmitQuery { ok: None }),
2724 axum::body::Bytes::from("UNKNOWN"),
2725 )
2726 .await
2727 .expect_err("value outside declared values must reject");
2728 assert_eq!(err.status, StatusCode::UNPROCESSABLE_ENTITY);
2729 assert!(
2730 err.message.contains("PASS") && err.message.contains("BLOCKED"),
2731 "rejection must echo the declared values, got: {}",
2732 err.message
2733 );
2734 }
2735
2736 #[tokio::test]
2739 async fn worker_submit_accepts_body_inside_contract_values() {
2740 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2741 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2742 let state = test_state(data_store, run_store);
2743 let task_id = StepId::new();
2744 let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
2745 state.engine.register_verdict_contracts(HashMap::from([(
2746 "gate".to_string(),
2747 body_verdict_contract(&["PASS", "BLOCKED"]),
2748 )]));
2749
2750 let status = worker_submit(
2751 State(state.clone()),
2752 bearer_headers(&handle),
2753 Query(SubmitQuery { ok: None }),
2754 axum::body::Bytes::from("PASS"),
2755 )
2756 .await
2757 .expect("value inside declared values must succeed");
2758 assert_eq!(status, StatusCode::NO_CONTENT);
2759 }
2760
2761 #[tokio::test]
2765 async fn worker_submit_without_a_declared_contract_is_unaffected() {
2766 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2767 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2768 let state = test_state(data_store, run_store);
2769 let task_id = StepId::new();
2770 let handle = seed_task_with_handle(&state, &task_id, "undeclared-agent", 1, None).await;
2772
2773 let status = worker_submit(
2774 State(state.clone()),
2775 bearer_headers(&handle),
2776 Query(SubmitQuery { ok: None }),
2777 axum::body::Bytes::from("anything at all, no contract to violate"),
2778 )
2779 .await
2780 .expect("no contract declared must never reject");
2781 assert_eq!(status, StatusCode::NO_CONTENT);
2782 }
2783
2784 #[tokio::test]
2787 async fn worker_artifact_verdict_part_rejects_value_outside_contract_with_422() {
2788 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2789 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2790 let state = test_state(data_store, run_store);
2791 let task_id = StepId::new();
2792 let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
2793 state.engine.register_verdict_contracts(HashMap::from([(
2794 "gate".to_string(),
2795 part_verdict_contract(&["PASS", "BLOCKED"]),
2796 )]));
2797
2798 let err = worker_artifact(
2799 State(state.clone()),
2800 bearer_headers(&handle),
2801 Query(ArtifactQuery {
2802 name: "verdict".to_string(),
2803 }),
2804 axum::body::Bytes::from("UNKNOWN"),
2805 )
2806 .await
2807 .expect_err("value outside declared values must reject");
2808 assert_eq!(err.status, StatusCode::UNPROCESSABLE_ENTITY);
2809 }
2810
2811 #[tokio::test]
2815 async fn worker_artifact_non_verdict_part_skips_the_gate() {
2816 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2817 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2818 let state = test_state(data_store, run_store);
2819 let task_id = StepId::new();
2820 let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
2821 state.engine.register_verdict_contracts(HashMap::from([(
2822 "gate".to_string(),
2823 part_verdict_contract(&["PASS", "BLOCKED"]),
2824 )]));
2825
2826 let status = worker_artifact(
2827 State(state.clone()),
2828 bearer_headers(&handle),
2829 Query(ArtifactQuery {
2830 name: "notes".to_string(),
2831 }),
2832 axum::body::Bytes::from("anything at all"),
2833 )
2834 .await
2835 .expect("non-verdict part name must never be gated");
2836 assert_eq!(status, StatusCode::NO_CONTENT);
2837 }
2838}