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(map_handle_lookup_err)?;
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(map_handle_lookup_err)?
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(map_handle_lookup_err)?
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(map_handle_lookup_err)?
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(map_handle_lookup_err)?;
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 map_handle_lookup_err(e: EngineError) -> ApiError {
1044 match e {
1045 EngineError::TokenNotFound(_) => ApiError::gone(
1046 "worker handle is no longer valid (the engine's in-flight state was reset, \
1047 e.g. by a server restart): re-kick the task (POST /v1/tasks/:id/runs) and \
1048 fetch a fresh prompt/handle"
1049 .to_string(),
1050 ),
1051 other => ApiError::engine(format!("task_id_from_handle: {other}")),
1052 }
1053}
1054
1055fn parse_worker_handle(s: &str) -> Option<&str> {
1059 let s = s.trim();
1060 if s.starts_with("wh-")
1061 && s.len() >= 5
1062 && s.len() <= 64
1063 && s[3..].chars().all(|c| c.is_ascii_alphanumeric())
1064 {
1065 Some(s)
1066 } else {
1067 None
1068 }
1069}
1070
1071fn decode_worker_bearer(headers: &HeaderMap) -> Result<CapToken, ApiError> {
1075 let v = headers
1076 .get(AUTHORIZATION)
1077 .ok_or_else(|| ApiError::bad_request("missing Authorization header".into()))?
1078 .to_str()
1079 .map_err(|_| ApiError::bad_request("invalid Authorization header encoding".into()))?;
1080 let encoded = v
1081 .strip_prefix("Bearer ")
1082 .ok_or_else(|| ApiError::bad_request("Authorization must be 'Bearer <token>'".into()))?
1083 .trim();
1084 if encoded.is_empty() {
1085 return Err(ApiError::bad_request("Bearer token is empty".into()));
1086 }
1087 CapToken::decode(encoded).map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))
1088}
1089
1090#[cfg(test)]
1095mod tests {
1096 use super::*;
1097 use axum::response::IntoResponse;
1098 use mlua_swarm::core::agent_context::AgentContextView;
1099 use mlua_swarm::core::config::EngineCfg;
1100 use mlua_swarm::core::engine::Engine;
1101 use mlua_swarm::store::output::{InMemoryOutputStore, OutputStore};
1102 use mlua_swarm::store::run::{InMemoryRunStore, RunRecord, RunStatus, RunStore, StepEntry};
1103 use mlua_swarm::store::task::InMemoryTaskStore;
1104 use mlua_swarm::{RunId, StepId, TaskId};
1105 use serde_json::json;
1106 use std::collections::HashMap;
1107 use std::sync::Arc;
1108 use tokio::sync::Mutex;
1109
1110 fn test_state(data_store: Arc<dyn OutputStore>, run_store: Arc<dyn RunStore>) -> AppState {
1116 let engine = Engine::new(EngineCfg::default());
1117 let compiler = mlua_swarm::Compiler::new(crate::default_registry());
1118 let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
1119 AppState {
1120 engine,
1121 sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
1122 task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
1123 ws_operator_factory: None,
1124 data_store,
1125 operator_sessions: Arc::new(Mutex::new(HashMap::new())),
1126 roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
1127 task_store: Arc::new(InMemoryTaskStore::new()),
1128 run_store,
1129 replay_store: Arc::new(mlua_swarm::store::replay::InMemoryReplayStore::new()),
1130 base_url: None,
1131 sync_timeout_secs: 300,
1132 }
1133 }
1134
1135 async fn append_final(
1136 data_store: &Arc<dyn OutputStore>,
1137 task_id: &str,
1138 producer: &str,
1139 value: Value,
1140 ) {
1141 data_store
1142 .append(
1143 task_id,
1144 1,
1145 producer,
1146 OutputEvent::Final {
1147 content: ContentRef::Inline { value },
1148 ok: true,
1149 },
1150 vec![],
1151 )
1152 .await
1153 .expect("append final");
1154 }
1155
1156 fn step_entry(step_id: &StepId, step_ref: &str) -> StepEntry {
1157 StepEntry {
1158 step_id: step_id.clone(),
1159 step_ref: Some(step_ref.to_string()),
1160 status: Some("passed".to_string()),
1161 binding_digest: None,
1162 at: 0,
1163 }
1164 }
1165
1166 fn run_record(task_id: &TaskId, run_id: &RunId, step_entries: Vec<StepEntry>) -> RunRecord {
1167 RunRecord {
1168 id: run_id.clone(),
1169 task_id: task_id.clone(),
1170 status: RunStatus::Running,
1171 step_entries,
1172 degradations: Vec::new(),
1173 operator_sid: None,
1174 result_ref: None,
1175 input_json: None,
1176 created_at: 0,
1177 updated_at: 0,
1178 }
1179 }
1180
1181 fn consumer_payload(consumer_step_id: &StepId, run_id: &RunId) -> WorkerPayload {
1182 WorkerPayload {
1183 task_id: consumer_step_id.clone(),
1184 attempt: 1,
1185 agent: "consumer".to_string(),
1186 system: None,
1187 prompt: String::new(),
1188 context: Some(AgentContextView {
1189 task_id: consumer_step_id.to_string(),
1190 agent: "consumer".to_string(),
1191 attempt: 1,
1192 run_id: Some(run_id.to_string()),
1193 ..Default::default()
1194 }),
1195 system_ref: None,
1196 }
1197 }
1198
1199 #[tokio::test]
1204 async fn context_policy_unspecified_yields_every_submitted_step() {
1205 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1206 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1207 let task_id = TaskId::new();
1208 let run_id = RunId::new();
1209 let planner_id = StepId::new();
1210 let coder_id = StepId::new();
1211
1212 append_final(
1213 &data_store,
1214 planner_id.as_str(),
1215 "planner",
1216 json!({"plan": "x"}),
1217 )
1218 .await;
1219 append_final(
1220 &data_store,
1221 coder_id.as_str(),
1222 "coder",
1223 json!({"code": "y"}),
1224 )
1225 .await;
1226 run_store
1227 .create(run_record(
1228 &task_id,
1229 &run_id,
1230 vec![
1231 step_entry(&planner_id, "planner"),
1232 step_entry(&coder_id, "coder"),
1233 ],
1234 ))
1235 .await
1236 .expect("create run");
1237
1238 let state = test_state(data_store, run_store);
1239 let consumer_id = StepId::new();
1240 let mut payload = consumer_payload(&consumer_id, &run_id);
1241 assemble_step_pointers(&state, &mut payload).await;
1242
1243 let names: Vec<&str> = payload
1244 .context
1245 .as_ref()
1246 .expect("context")
1247 .steps
1248 .iter()
1249 .map(|p| p.name.as_str())
1250 .collect();
1251 assert!(names.contains(&"planner"), "names: {names:?}");
1252 assert!(names.contains(&"coder"), "names: {names:?}");
1253 }
1254
1255 #[tokio::test]
1257 async fn context_policy_steps_include_list_filters_to_named_steps() {
1258 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1259 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1260 let task_id = TaskId::new();
1261 let run_id = RunId::new();
1262 let planner_id = StepId::new();
1263 let coder_id = StepId::new();
1264 append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1265 append_final(&data_store, coder_id.as_str(), "coder", json!("y")).await;
1266 run_store
1267 .create(run_record(
1268 &task_id,
1269 &run_id,
1270 vec![
1271 step_entry(&planner_id, "planner"),
1272 step_entry(&coder_id, "coder"),
1273 ],
1274 ))
1275 .await
1276 .expect("create run");
1277
1278 let state = test_state(data_store, run_store);
1279 let consumer_id = StepId::new();
1280 state
1281 .engine
1282 .with_state("test.seed_policy", {
1283 let consumer_id = consumer_id.clone();
1284 move |s| {
1285 s.agent_ctx.insert(
1286 (consumer_id, 1),
1287 mlua_swarm::core::state::AgentCtxEntry {
1288 policy: mlua_swarm_schema::ContextPolicy {
1289 steps: Some(vec!["planner".to_string()]),
1290 ..Default::default()
1291 },
1292 ..Default::default()
1293 },
1294 );
1295 }
1296 })
1297 .await
1298 .expect("seed policy");
1299
1300 let mut payload = consumer_payload(&consumer_id, &run_id);
1301 assemble_step_pointers(&state, &mut payload).await;
1302
1303 let names: Vec<&str> = payload
1304 .context
1305 .as_ref()
1306 .expect("context")
1307 .steps
1308 .iter()
1309 .map(|p| p.name.as_str())
1310 .collect();
1311 assert_eq!(names, vec!["planner"], "names: {names:?}");
1312 }
1313
1314 #[tokio::test]
1316 async fn context_policy_steps_empty_list_yields_no_pointers() {
1317 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1318 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1319 let task_id = TaskId::new();
1320 let run_id = RunId::new();
1321 let planner_id = StepId::new();
1322 append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1323 run_store
1324 .create(run_record(
1325 &task_id,
1326 &run_id,
1327 vec![step_entry(&planner_id, "planner")],
1328 ))
1329 .await
1330 .expect("create run");
1331
1332 let state = test_state(data_store, run_store);
1333 let consumer_id = StepId::new();
1334 state
1335 .engine
1336 .with_state("test.seed_policy", {
1337 let consumer_id = consumer_id.clone();
1338 move |s| {
1339 s.agent_ctx.insert(
1340 (consumer_id, 1),
1341 mlua_swarm::core::state::AgentCtxEntry {
1342 policy: mlua_swarm_schema::ContextPolicy {
1343 steps: Some(vec![]),
1344 ..Default::default()
1345 },
1346 ..Default::default()
1347 },
1348 );
1349 }
1350 })
1351 .await
1352 .expect("seed policy");
1353
1354 let mut payload = consumer_payload(&consumer_id, &run_id);
1355 assemble_step_pointers(&state, &mut payload).await;
1356
1357 assert!(payload.context.expect("context").steps.is_empty());
1358 }
1359
1360 #[tokio::test]
1362 async fn context_policy_steps_exclude_wins_over_steps() {
1363 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1364 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1365 let task_id = TaskId::new();
1366 let run_id = RunId::new();
1367 let planner_id = StepId::new();
1368 let coder_id = StepId::new();
1369 append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1370 append_final(&data_store, coder_id.as_str(), "coder", json!("y")).await;
1371 run_store
1372 .create(run_record(
1373 &task_id,
1374 &run_id,
1375 vec![
1376 step_entry(&planner_id, "planner"),
1377 step_entry(&coder_id, "coder"),
1378 ],
1379 ))
1380 .await
1381 .expect("create run");
1382
1383 let state = test_state(data_store, run_store);
1384 let consumer_id = StepId::new();
1385 state
1386 .engine
1387 .with_state("test.seed_policy", {
1388 let consumer_id = consumer_id.clone();
1389 move |s| {
1390 s.agent_ctx.insert(
1391 (consumer_id, 1),
1392 mlua_swarm::core::state::AgentCtxEntry {
1393 policy: mlua_swarm_schema::ContextPolicy {
1394 steps: Some(vec!["planner".to_string(), "coder".to_string()]),
1395 steps_exclude: vec!["planner".to_string()],
1396 ..Default::default()
1397 },
1398 ..Default::default()
1399 },
1400 );
1401 }
1402 })
1403 .await
1404 .expect("seed policy");
1405
1406 let mut payload = consumer_payload(&consumer_id, &run_id);
1407 assemble_step_pointers(&state, &mut payload).await;
1408
1409 let names: Vec<&str> = payload
1410 .context
1411 .as_ref()
1412 .expect("context")
1413 .steps
1414 .iter()
1415 .map(|p| p.name.as_str())
1416 .collect();
1417 assert_eq!(names, vec!["coder"], "names: {names:?}");
1418 }
1419
1420 #[tokio::test]
1429 async fn in_flight_step_output_is_visible_before_run_finalizes() {
1430 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1431 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1432 let task_id = TaskId::new();
1433 let run_id = RunId::new();
1434 let step1_id = StepId::new();
1435 append_final(
1436 &data_store,
1437 step1_id.as_str(),
1438 "step1",
1439 json!({"step1_out": "hi"}),
1440 )
1441 .await;
1442 let mut run = run_record(&task_id, &run_id, vec![step_entry(&step1_id, "step1")]);
1443 run.status = RunStatus::Running;
1444 run.result_ref = None; run_store.create(run).await.expect("create run");
1446
1447 let state = test_state(data_store, run_store);
1448 let consumer_id = StepId::new();
1449 let mut payload = consumer_payload(&consumer_id, &run_id);
1450 assemble_step_pointers(&state, &mut payload).await;
1451
1452 let steps = &payload.context.expect("context").steps;
1453 assert_eq!(steps.len(), 1);
1454 assert_eq!(steps[0].name, "step1");
1455 }
1456
1457 #[tokio::test]
1461 async fn self_agent_name_is_always_excluded() {
1462 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1463 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1464 let task_id = TaskId::new();
1465 let run_id = RunId::new();
1466 let planner_id = StepId::new();
1467 let consumer_prior_id = StepId::new();
1468 append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1469 append_final(
1470 &data_store,
1471 consumer_prior_id.as_str(),
1472 "consumer",
1473 json!("self"),
1474 )
1475 .await;
1476 run_store
1477 .create(run_record(
1478 &task_id,
1479 &run_id,
1480 vec![
1481 step_entry(&planner_id, "planner"),
1482 step_entry(&consumer_prior_id, "consumer"),
1483 ],
1484 ))
1485 .await
1486 .expect("create run");
1487
1488 let state = test_state(data_store, run_store);
1489 let consumer_id = StepId::new();
1490 let mut payload = consumer_payload(&consumer_id, &run_id);
1491 assemble_step_pointers(&state, &mut payload).await;
1492
1493 let names: Vec<&str> = payload
1494 .context
1495 .as_ref()
1496 .expect("context")
1497 .steps
1498 .iter()
1499 .map(|p| p.name.as_str())
1500 .collect();
1501 assert!(!names.contains(&"consumer"), "names: {names:?}");
1502 assert!(names.contains(&"planner"), "names: {names:?}");
1503 }
1504
1505 #[tokio::test]
1509 async fn step_pointer_serializes_with_no_preview_or_content_bytes() {
1510 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1511 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1512 let task_id = TaskId::new();
1513 let run_id = RunId::new();
1514 let planner_id = StepId::new();
1515 append_final(
1516 &data_store,
1517 planner_id.as_str(),
1518 "planner",
1519 json!({"plan": "do the thing, at length".repeat(50)}),
1520 )
1521 .await;
1522 run_store
1523 .create(run_record(
1524 &task_id,
1525 &run_id,
1526 vec![step_entry(&planner_id, "planner")],
1527 ))
1528 .await
1529 .expect("create run");
1530
1531 let state = test_state(data_store, run_store);
1532 let consumer_id = StepId::new();
1533 let mut payload = consumer_payload(&consumer_id, &run_id);
1534 assemble_step_pointers(&state, &mut payload).await;
1535
1536 let steps = &payload.context.expect("context").steps;
1537 assert_eq!(steps.len(), 1);
1538 let json_value = serde_json::to_value(&steps[0]).expect("serialize StepPointer");
1539 let obj = json_value.as_object().expect("object");
1540 for forbidden in ["preview", "content", "value", "bytes"] {
1541 assert!(
1542 !obj.contains_key(forbidden),
1543 "StepPointer must not carry a {forbidden:?} field: {obj:?}"
1544 );
1545 }
1546 assert!(obj.contains_key("name"));
1547 assert!(obj.contains_key("size_bytes"));
1548 assert!(obj.contains_key("content_url"));
1549 assert!(obj.contains_key("sha256"));
1550 }
1551
1552 fn declared_name_bp() -> mlua_swarm::blueprint::Blueprint {
1560 use mlua_flow_ir::{Expr, Node};
1561 use mlua_swarm::blueprint::{
1562 current_schema_version, AgentDef, AgentKind, AgentMeta, Blueprint, BlueprintMetadata,
1563 CompilerHints, CompilerStrategy,
1564 };
1565 Blueprint {
1566 schema_version: current_schema_version(),
1567 id: "worker-test-declared-name-bp".into(),
1568 flow: Node::Step {
1569 ref_: "planner".to_string(),
1570 in_: Expr::Path {
1571 at: "$.in".parse().expect("literal test path: $.in"),
1572 },
1573 out: Expr::Path {
1574 at: "$.plan".parse().expect("literal test path: $.plan"),
1575 },
1576 },
1577 agents: vec![AgentDef {
1578 name: "planner".to_string(),
1579 kind: AgentKind::RustFn,
1580 spec: json!({"fn_id": "planner"}),
1581 profile: None,
1582 meta: Some(AgentMeta {
1583 projection_name: Some("plan-out".to_string()),
1584 ..Default::default()
1585 }),
1586 runner: None,
1587 runner_ref: None,
1588 verdict: None,
1589 }],
1590 operators: vec![],
1591 metas: vec![],
1592 hints: CompilerHints::default(),
1593 strategy: CompilerStrategy::default(),
1594 metadata: BlueprintMetadata::default(),
1595 spawner_hints: Default::default(),
1596 default_agent_kind: AgentKind::Operator,
1597 default_operator_kind: None,
1598 default_init_ctx: None,
1599 default_agent_ctx: None,
1600 default_context_policy: None,
1601 projection_placement: None,
1602 audits: vec![],
1603 degradation_policy: None,
1604 runners: vec![],
1605 default_runner: None,
1606 check_policy: None,
1607 blueprint_ref_includes: Vec::new(),
1608 }
1609 }
1610
1611 #[tokio::test]
1617 async fn declared_projection_name_pointer_name_is_canonical_and_policy_matches_it() {
1618 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1619 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1620 let task_id = TaskId::new();
1621 let run_id = RunId::new();
1622 let planner_id = StepId::new();
1623
1624 append_final(
1627 &data_store,
1628 planner_id.as_str(),
1629 "plan-out",
1630 json!({"plan": "x"}),
1631 )
1632 .await;
1633 run_store
1634 .create(run_record(
1635 &task_id,
1636 &run_id,
1637 vec![step_entry(&planner_id, "planner")],
1638 ))
1639 .await
1640 .expect("create run");
1641
1642 let state = test_state(data_store, run_store);
1643
1644 let (naming, _warnings) =
1650 mlua_swarm::core::step_naming::StepNaming::from_blueprint(&declared_name_bp())
1651 .expect("no collision");
1652 let naming = Arc::new(naming);
1653 let consumer_id = StepId::new();
1654 state
1655 .engine
1656 .with_state("test.seed_step_naming", {
1657 let naming = naming.clone();
1658 let planner_id = planner_id.clone();
1659 let consumer_id = consumer_id.clone();
1660 move |s| {
1661 s.step_namings.insert(planner_id, naming.clone());
1662 s.step_namings.insert(consumer_id, naming);
1663 }
1664 })
1665 .await
1666 .expect("seed step naming");
1667 state
1668 .engine
1669 .with_state("test.seed_policy", {
1670 let consumer_id = consumer_id.clone();
1671 move |s| {
1672 s.agent_ctx.insert(
1673 (consumer_id, 1),
1674 mlua_swarm::core::state::AgentCtxEntry {
1675 policy: mlua_swarm_schema::ContextPolicy {
1676 steps: Some(vec!["plan-out".to_string()]),
1677 ..Default::default()
1678 },
1679 ..Default::default()
1680 },
1681 );
1682 }
1683 })
1684 .await
1685 .expect("seed policy");
1686
1687 let mut payload = consumer_payload(&consumer_id, &run_id);
1688 assemble_step_pointers(&state, &mut payload).await;
1689
1690 let steps = &payload.context.expect("context").steps;
1691 assert_eq!(steps.len(), 1, "steps: {steps:?}");
1692 assert_eq!(
1693 steps[0].name, "plan-out",
1694 "StepPointer.name must be the canonical name"
1695 );
1696 }
1697
1698 async fn seed_task_with_handle(
1708 state: &AppState,
1709 task_id: &StepId,
1710 agent: &str,
1711 attempt: u32,
1712 system: Option<String>,
1713 ) -> String {
1714 let handle = format!("wh-{}", mlua_swarm::types::secure_hex(4));
1715 let task_id = task_id.clone();
1716 let agent = agent.to_string();
1717 let handle_clone = handle.clone();
1718 state
1719 .engine
1720 .with_state("test.seed_task_with_handle", move |s| {
1721 let mut task = mlua_swarm::core::state::TaskState::new(
1722 task_id.clone(),
1723 mlua_swarm::core::state::TaskSpec {
1724 agent: agent.clone(),
1725 initial_directive: json!("x"),
1726 step_ctx: None,
1727 check_policy: None,
1728 },
1729 );
1730 task.attempt = attempt;
1731 s.tasks.insert(task_id.clone(), task);
1732 s.systems.insert((task_id.clone(), attempt), system);
1733 let token = CapToken {
1734 agent_id: agent,
1735 role: mlua_swarm::Role::Worker,
1736 scopes: vec!["*".to_string()],
1737 issued_at: 0,
1738 expire_at: u64::MAX,
1739 max_uses: None,
1740 nonce: format!("test-nonce-{task_id}"),
1741 sig_hex: String::new(),
1742 };
1743 let fp = token.fingerprint();
1744 s.tokens.insert(
1745 fp.clone(),
1746 mlua_swarm::core::state::CapTokenRecord {
1747 token,
1748 uses_left: None,
1749 revoked: false,
1750 task_id: Some(task_id),
1751 },
1752 );
1753 s.worker_handles.insert(handle_clone, fp);
1754 })
1755 .await
1756 .expect("seed_task_with_handle");
1757 handle
1758 }
1759
1760 fn bearer_headers(handle: &str) -> HeaderMap {
1761 let mut headers = HeaderMap::new();
1762 headers.insert(
1763 AUTHORIZATION,
1764 format!("Bearer {handle}").parse().expect("header value"),
1765 );
1766 headers
1767 }
1768
1769 #[tokio::test]
1773 async fn worker_prompt_system_returns_raw_bytes_for_baked_system() {
1774 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1775 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1776 let state = test_state(data_store, run_store);
1777 let task_id = StepId::new();
1778 let rendered = "# Hello\n\nThis is the baked system prompt.".to_string();
1779 let handle =
1780 seed_task_with_handle(&state, &task_id, "planner", 1, Some(rendered.clone())).await;
1781
1782 let resp = worker_prompt_system(
1783 State(state.clone()),
1784 bearer_headers(&handle),
1785 Query(PromptSystemQuery {
1786 task_id: task_id.clone(),
1787 attempt: 1,
1788 }),
1789 )
1790 .await
1791 .expect("worker_prompt_system")
1792 .into_response();
1793
1794 assert_eq!(resp.status(), StatusCode::OK);
1795 let content_type = resp
1796 .headers()
1797 .get(header::CONTENT_TYPE)
1798 .expect("content-type header")
1799 .to_str()
1800 .expect("ascii");
1801 assert_eq!(content_type, "text/plain; charset=utf-8");
1802 let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1803 .await
1804 .expect("body bytes");
1805 assert_eq!(body_bytes.as_ref(), rendered.as_bytes());
1806 }
1807
1808 #[tokio::test]
1811 async fn worker_prompt_system_404s_when_no_baked_system() {
1812 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1813 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1814 let state = test_state(data_store, run_store);
1815 let task_id = StepId::new();
1816 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
1817
1818 let result = worker_prompt_system(
1819 State(state.clone()),
1820 bearer_headers(&handle),
1821 Query(PromptSystemQuery {
1822 task_id: task_id.clone(),
1823 attempt: 1,
1824 }),
1825 )
1826 .await;
1827 let err = match result {
1828 Ok(_) => panic!("expected 404 ApiError, got Ok"),
1829 Err(e) => e,
1830 };
1831 assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND);
1832 }
1833
1834 #[tokio::test]
1837 async fn worker_prompt_system_rejects_handle_task_mismatch() {
1838 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1839 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1840 let state = test_state(data_store, run_store);
1841 let task_id = StepId::new();
1842 let other_task_id = StepId::new();
1843 let handle =
1844 seed_task_with_handle(&state, &task_id, "planner", 1, Some("x".to_string())).await;
1845
1846 let result = worker_prompt_system(
1847 State(state.clone()),
1848 bearer_headers(&handle),
1849 Query(PromptSystemQuery {
1850 task_id: other_task_id,
1851 attempt: 1,
1852 }),
1853 )
1854 .await;
1855 let err = match result {
1856 Ok(_) => panic!("expected 400 ApiError for task mismatch, got Ok"),
1857 Err(e) => e,
1858 };
1859 assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
1860 }
1861
1862 #[tokio::test]
1866 async fn agent_render_size_returns_null_for_unknown_agent() {
1867 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1868 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1869 let state = test_state(data_store, run_store);
1870
1871 let Json(body) = agent_render_size(
1872 State(state.clone()),
1873 axum::extract::Path("never-dispatched".to_string()),
1874 )
1875 .await;
1876 assert_eq!(body.agent, "never-dispatched");
1877 assert_eq!(body.last_rendered_bytes, None);
1878 }
1879
1880 #[tokio::test]
1883 async fn agent_render_size_reports_last_rendered_bytes() {
1884 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1885 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1886 let state = test_state(data_store, run_store);
1887 let task_id = StepId::new();
1888 state
1889 .engine
1890 .with_state("test.seed_agent_ctx_for_bake", {
1891 let task_id = task_id.clone();
1892 move |s| {
1893 s.tasks.insert(
1894 task_id.clone(),
1895 mlua_swarm::core::state::TaskState::new(
1896 task_id,
1897 mlua_swarm::core::state::TaskSpec {
1898 agent: "coder".to_string(),
1899 initial_directive: json!("x"),
1900 step_ctx: None,
1901 check_policy: None,
1902 },
1903 ),
1904 );
1905 }
1906 })
1907 .await
1908 .expect("seed task");
1909 state
1910 .engine
1911 .bake_worker_system_prompt(&task_id, 1, Some("z".repeat(42)))
1912 .await
1913 .expect("bake_worker_system_prompt");
1914
1915 let Json(body) = agent_render_size(
1916 State(state.clone()),
1917 axum::extract::Path("coder".to_string()),
1918 )
1919 .await;
1920 assert_eq!(body.agent, "coder");
1921 assert_eq!(body.last_rendered_bytes, Some(42));
1922 }
1923
1924 #[tokio::test]
1932 async fn worker_artifact_stages_and_204s_for_valid_request() {
1933 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1934 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1935 let state = test_state(data_store, run_store);
1936 let task_id = StepId::new();
1937 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
1938
1939 let status = worker_artifact(
1940 State(state.clone()),
1941 bearer_headers(&handle),
1942 Query(ArtifactQuery {
1943 name: "summary".to_string(),
1944 }),
1945 axum::body::Bytes::from_static(b"hello artifact\n"),
1946 )
1947 .await
1948 .expect("worker_artifact");
1949 assert_eq!(status, StatusCode::NO_CONTENT);
1950
1951 let tail = state.engine.output_tail(&task_id, 1).await;
1952 assert_eq!(tail.len(), 1, "tail: {tail:?}");
1953 match &tail[0] {
1954 OutputEvent::Artifact { name, content } => {
1955 assert_eq!(name, "summary");
1956 match content {
1957 ContentRef::Inline { value } => {
1958 assert_eq!(value, &json!("hello artifact"));
1959 }
1960 other => panic!("expected Inline content, got {other:?}"),
1961 }
1962 }
1963 other => panic!("expected Artifact event, got {other:?}"),
1964 }
1965 }
1966
1967 #[tokio::test]
1974 async fn worker_artifact_rejects_blank_name() {
1975 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1976 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1977 let state = test_state(data_store, run_store);
1978 let task_id = StepId::new();
1979 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
1980
1981 let result = worker_artifact(
1982 State(state.clone()),
1983 bearer_headers(&handle),
1984 Query(ArtifactQuery {
1985 name: " ".to_string(),
1986 }),
1987 axum::body::Bytes::from_static(b"x"),
1988 )
1989 .await;
1990 let err = match result {
1991 Ok(_) => panic!("expected 400 ApiError for blank name, got Ok"),
1992 Err(e) => e,
1993 };
1994 assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
1995
1996 assert!(state.engine.output_tail(&task_id, 1).await.is_empty());
1998 }
1999
2000 #[tokio::test]
2006 async fn worker_artifact_staging_same_name_twice_appends_both_events_in_order() {
2007 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2008 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2009 let state = test_state(data_store, run_store);
2010 let task_id = StepId::new();
2011 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2012
2013 for body in [b"first".as_slice(), b"second".as_slice()] {
2014 worker_artifact(
2015 State(state.clone()),
2016 bearer_headers(&handle),
2017 Query(ArtifactQuery {
2018 name: "a".to_string(),
2019 }),
2020 axum::body::Bytes::copy_from_slice(body),
2021 )
2022 .await
2023 .expect("worker_artifact");
2024 }
2025
2026 let tail = state.engine.output_tail(&task_id, 1).await;
2027 assert_eq!(tail.len(), 2, "tail: {tail:?}");
2028 let values: Vec<&str> = tail
2029 .iter()
2030 .map(|ev| match ev {
2031 OutputEvent::Artifact {
2032 content: ContentRef::Inline { value },
2033 ..
2034 } => value.as_str().expect("string value"),
2035 other => panic!("expected Artifact/Inline event, got {other:?}"),
2036 })
2037 .collect();
2038 assert_eq!(values, vec!["first", "second"]);
2039 }
2040
2041 async fn link_task_to_run(state: &AppState, task_id: &StepId, attempt: u32, run_id: &RunId) {
2049 let tid = task_id.clone();
2050 let rid_str = run_id.to_string();
2051 state
2052 .engine
2053 .with_state("test.link_task_to_run", move |s| {
2054 let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2055 entry.view.run_id = Some(rid_str);
2056 s.agent_ctx.insert((tid, attempt), entry);
2057 })
2058 .await
2059 .expect("link_task_to_run");
2060 }
2061
2062 #[tokio::test]
2067 async fn submit_and_artifact_against_terminal_run_return_410() {
2068 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2069 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2070 let state = test_state(data_store, run_store.clone());
2071 let task_id = StepId::new();
2072 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2073
2074 let owner_task = TaskId::new();
2075 let run_id = RunId::new();
2076 let mut rec = run_record(&owner_task, &run_id, vec![]);
2077 rec.status = RunStatus::Failed;
2078 run_store.create(rec).await.expect("run create");
2079 link_task_to_run(&state, &task_id, 1, &run_id).await;
2080
2081 let err = worker_submit(
2082 State(state.clone()),
2083 bearer_headers(&handle),
2084 Query(SubmitQuery { ok: None }),
2085 axum::body::Bytes::from_static(b"LATE OUTPUT"),
2086 )
2087 .await
2088 .expect_err("a submit against a Failed run must be rejected");
2089 assert_eq!(err.status, StatusCode::GONE);
2090 assert!(
2091 err.message.contains(&run_id.to_string()),
2092 "the 410 must name the terminal run: {}",
2093 err.message
2094 );
2095
2096 let err = worker_artifact(
2097 State(state.clone()),
2098 bearer_headers(&handle),
2099 Query(ArtifactQuery {
2100 name: "part.md".to_string(),
2101 }),
2102 axum::body::Bytes::from_static(b"LATE PART"),
2103 )
2104 .await
2105 .expect_err("an artifact staged against a Failed run must be rejected");
2106 assert_eq!(err.status, StatusCode::GONE);
2107
2108 let tail = state.engine.output_tail(&task_id, 1).await;
2110 assert!(tail.is_empty(), "rejected submits must not land: {tail:?}");
2111 }
2112
2113 #[tokio::test]
2117 async fn terminal_run_guard_is_fail_open() {
2118 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2119 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2120 let state = test_state(data_store, run_store.clone());
2121 let task_id = StepId::new();
2122 seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2123
2124 reject_if_run_terminal(&state, &task_id, 1)
2126 .await
2127 .expect("no linkage must fail open");
2128
2129 let unknown_run = RunId::new();
2131 link_task_to_run(&state, &task_id, 1, &unknown_run).await;
2132 reject_if_run_terminal(&state, &task_id, 1)
2133 .await
2134 .expect("unknown run must fail open");
2135
2136 let owner_task = TaskId::new();
2138 let live_run = RunId::new();
2139 run_store
2140 .create(run_record(&owner_task, &live_run, vec![]))
2141 .await
2142 .expect("run create");
2143 link_task_to_run(&state, &task_id, 1, &live_run).await;
2144 reject_if_run_terminal(&state, &task_id, 1)
2145 .await
2146 .expect("a Running run must pass the guard");
2147 }
2148
2149 fn degradation_body(tool: &str, note: Option<&str>) -> DegradationBody {
2154 DegradationBody {
2155 tool: tool.to_string(),
2156 error: "boom".to_string(),
2157 fallback: "used cached value".to_string(),
2158 note: note.map(str::to_string),
2159 }
2160 }
2161
2162 async fn link_task_to_run_with_agent(
2168 state: &AppState,
2169 task_id: &StepId,
2170 attempt: u32,
2171 run_id: &RunId,
2172 agent: &str,
2173 ) {
2174 let tid = task_id.clone();
2175 let rid_str = run_id.to_string();
2176 let agent = agent.to_string();
2177 state
2178 .engine
2179 .with_state("test.link_task_to_run_with_agent", move |s| {
2180 let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2181 entry.view.run_id = Some(rid_str);
2182 entry.view.agent = agent;
2183 s.agent_ctx.insert((tid, attempt), entry);
2184 })
2185 .await
2186 .expect("link_task_to_run_with_agent");
2187 }
2188
2189 #[tokio::test]
2194 async fn worker_degradation_persists_entry_when_run_tracked() {
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.clone());
2198 let task_id = StepId::new();
2199 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2200
2201 let owner_task = TaskId::new();
2202 let run_id = RunId::new();
2203 run_store
2204 .create(run_record(&owner_task, &run_id, vec![]))
2205 .await
2206 .expect("run create");
2207 link_task_to_run_with_agent(&state, &task_id, 1, &run_id, "planner").await;
2208
2209 let status = worker_degradation(
2210 State(state.clone()),
2211 bearer_headers(&handle),
2212 Json(degradation_body("web_search", Some("rate limited"))),
2213 )
2214 .await
2215 .expect("worker_degradation");
2216 assert_eq!(status, StatusCode::NO_CONTENT);
2217
2218 let rec = run_store.get(&run_id).await.expect("run get");
2219 assert_eq!(
2220 rec.degradations.len(),
2221 1,
2222 "degradations: {:?}",
2223 rec.degradations
2224 );
2225 let entry = &rec.degradations[0];
2226 assert_eq!(entry.tool, "web_search");
2227 assert_eq!(entry.error, "boom");
2228 assert_eq!(entry.fallback, "used cached value");
2229 assert_eq!(entry.note.as_deref(), Some("rate limited"));
2230 assert_eq!(entry.step_ref.as_deref(), Some("planner"));
2231 assert_eq!(entry.attempt, Some(1));
2232 assert!(entry.at > 0, "at must be a real timestamp: {}", entry.at);
2233 }
2234
2235 #[tokio::test]
2237 async fn worker_degradation_appends_in_order() {
2238 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2239 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2240 let state = test_state(data_store, run_store.clone());
2241 let task_id = StepId::new();
2242 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2243
2244 let owner_task = TaskId::new();
2245 let run_id = RunId::new();
2246 run_store
2247 .create(run_record(&owner_task, &run_id, vec![]))
2248 .await
2249 .expect("run create");
2250 link_task_to_run(&state, &task_id, 1, &run_id).await;
2251
2252 for tool in ["first_tool", "second_tool"] {
2253 worker_degradation(
2254 State(state.clone()),
2255 bearer_headers(&handle),
2256 Json(degradation_body(tool, None)),
2257 )
2258 .await
2259 .expect("worker_degradation");
2260 }
2261
2262 let rec = run_store.get(&run_id).await.expect("run get");
2263 let tools: Vec<&str> = rec.degradations.iter().map(|e| e.tool.as_str()).collect();
2264 assert_eq!(tools, vec!["first_tool", "second_tool"]);
2265 }
2266
2267 #[tokio::test]
2271 async fn worker_degradation_silent_ok_when_no_run_tracked() {
2272 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2273 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2274 let state = test_state(data_store, run_store);
2275 let task_id = StepId::new();
2276 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2277
2278 let status = worker_degradation(
2279 State(state.clone()),
2280 bearer_headers(&handle),
2281 Json(degradation_body("some_tool", None)),
2282 )
2283 .await
2284 .expect("worker_degradation must not error on missing run linkage");
2285 assert_eq!(status, StatusCode::NO_CONTENT);
2286 }
2287
2288 #[tokio::test]
2291 async fn worker_degradation_rejects_terminal_run() {
2292 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2293 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2294 let state = test_state(data_store, run_store.clone());
2295 let task_id = StepId::new();
2296 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2297
2298 let owner_task = TaskId::new();
2299 let run_id = RunId::new();
2300 let mut rec = run_record(&owner_task, &run_id, vec![]);
2301 rec.status = RunStatus::Done;
2302 run_store.create(rec).await.expect("run create");
2303 link_task_to_run(&state, &task_id, 1, &run_id).await;
2304
2305 let err = worker_degradation(
2306 State(state.clone()),
2307 bearer_headers(&handle),
2308 Json(degradation_body("some_tool", None)),
2309 )
2310 .await
2311 .expect_err("a degradation against a Done run must be rejected");
2312 assert_eq!(err.status, StatusCode::GONE);
2313
2314 let rec = run_store.get(&run_id).await.expect("run get");
2315 assert!(
2316 rec.degradations.is_empty(),
2317 "rejected degradation must not land: {:?}",
2318 rec.degradations
2319 );
2320 }
2321
2322 async fn seed_work_dir(
2336 state: &AppState,
2337 task_id: &StepId,
2338 attempt: u32,
2339 work_dir: &str,
2340 allow_file_submit: Option<Value>,
2341 ) {
2342 let tid = task_id.clone();
2343 let work_dir = work_dir.to_string();
2344 state
2345 .engine
2346 .with_state("test.seed_work_dir", move |s| {
2347 let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2348 entry.view.work_dir = Some(work_dir);
2349 if let Some(v) = allow_file_submit {
2350 entry
2351 .view
2352 .extra
2353 .insert(FILE_SENTINEL_ALLOW_KEY.to_string(), v);
2354 }
2355 s.agent_ctx.insert((tid, attempt), entry);
2356 })
2357 .await
2358 .expect("seed_work_dir");
2359 }
2360
2361 #[tokio::test]
2365 async fn worker_submit_resolves_file_sentinel_under_work_dir() {
2366 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2367 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2368 let state = test_state(data_store.clone(), run_store);
2369 let task_id = StepId::new();
2370 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2371
2372 let tmp = tempfile::tempdir().expect("tempdir");
2373 let work_dir = tmp.path().to_path_buf();
2374 seed_work_dir(
2375 &state,
2376 &task_id,
2377 1,
2378 work_dir.to_str().expect("work_dir utf-8"),
2379 Some(Value::Bool(true)),
2380 )
2381 .await;
2382
2383 let payload_path = work_dir.join("scout.md");
2384 let payload = "## Context Package (broad)\n\nlarge body content\n";
2385 tokio::fs::write(&payload_path, payload)
2386 .await
2387 .expect("write payload");
2388 let body = format!(
2389 "@file:{}",
2390 payload_path.to_str().expect("payload path utf-8")
2391 );
2392
2393 let status = worker_submit(
2394 State(state.clone()),
2395 bearer_headers(&handle),
2396 Query(SubmitQuery { ok: None }),
2397 axum::body::Bytes::from(body),
2398 )
2399 .await
2400 .expect("worker_submit sentinel");
2401 assert_eq!(status, StatusCode::NO_CONTENT);
2402
2403 let tid = task_id.clone();
2407 let value = state
2408 .engine
2409 .with_state("test.inspect_output_store", move |s| {
2410 s.output_store.get(&(tid.clone(), 1)).and_then(|evs| {
2411 evs.iter().find_map(|ev| match ev {
2412 OutputEvent::Final {
2413 content: ContentRef::Inline { value },
2414 ..
2415 } => Some(value.clone()),
2416 _ => None,
2417 })
2418 })
2419 })
2420 .await
2421 .expect("with_state")
2422 .expect("Final event present");
2423 assert_eq!(value, Value::String(payload.trim_end().to_string()));
2424 }
2425
2426 #[tokio::test]
2429 async fn worker_submit_passes_non_sentinel_body_unchanged() {
2430 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2431 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2432 let state = test_state(data_store.clone(), run_store);
2433 let task_id = StepId::new();
2434 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2435 let status = worker_submit(
2439 State(state.clone()),
2440 bearer_headers(&handle),
2441 Query(SubmitQuery { ok: None }),
2442 axum::body::Bytes::from_static(b"DONE yes=1 maybe=0 no=0"),
2443 )
2444 .await
2445 .expect("worker_submit inline");
2446 assert_eq!(status, StatusCode::NO_CONTENT);
2447
2448 let tid = task_id.clone();
2449 let value = state
2450 .engine
2451 .with_state("test.inspect_output_store", move |s| {
2452 s.output_store.get(&(tid.clone(), 1)).and_then(|evs| {
2453 evs.iter().find_map(|ev| match ev {
2454 OutputEvent::Final {
2455 content: ContentRef::Inline { value },
2456 ..
2457 } => Some(value.clone()),
2458 _ => None,
2459 })
2460 })
2461 })
2462 .await
2463 .expect("with_state")
2464 .expect("Final event present");
2465 assert_eq!(value, Value::String("DONE yes=1 maybe=0 no=0".to_string()));
2466 }
2467
2468 #[tokio::test]
2473 async fn worker_submit_rejects_sentinel_path_outside_work_dir() {
2474 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2475 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2476 let state = test_state(data_store, run_store);
2477 let task_id = StepId::new();
2478 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2479
2480 let allowed = tempfile::tempdir().expect("allowed tempdir");
2481 let outside = tempfile::tempdir().expect("outside tempdir");
2482 seed_work_dir(
2483 &state,
2484 &task_id,
2485 1,
2486 allowed.path().to_str().expect("utf-8"),
2487 Some(Value::Bool(true)),
2488 )
2489 .await;
2490
2491 let outside_file = outside.path().join("leak.md");
2492 tokio::fs::write(&outside_file, b"outside content")
2493 .await
2494 .expect("write outside");
2495 let body = format!(
2496 "@file:{}",
2497 outside_file.to_str().expect("outside path utf-8")
2498 );
2499
2500 let err = worker_submit(
2501 State(state.clone()),
2502 bearer_headers(&handle),
2503 Query(SubmitQuery { ok: None }),
2504 axum::body::Bytes::from(body),
2505 )
2506 .await
2507 .expect_err("outside-work_dir sentinel must be rejected");
2508 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2509 }
2510
2511 #[tokio::test]
2513 async fn worker_submit_rejects_sentinel_missing_file() {
2514 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2515 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2516 let state = test_state(data_store, run_store);
2517 let task_id = StepId::new();
2518 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2519
2520 let tmp = tempfile::tempdir().expect("tempdir");
2521 seed_work_dir(
2522 &state,
2523 &task_id,
2524 1,
2525 tmp.path().to_str().expect("utf-8"),
2526 Some(Value::Bool(true)),
2527 )
2528 .await;
2529 let missing = tmp.path().join("does-not-exist.md");
2530 let body = format!("@file:{}", missing.to_str().expect("utf-8"));
2531
2532 let err = worker_submit(
2533 State(state.clone()),
2534 bearer_headers(&handle),
2535 Query(SubmitQuery { ok: None }),
2536 axum::body::Bytes::from(body),
2537 )
2538 .await
2539 .expect_err("missing-file sentinel must be rejected");
2540 assert_eq!(err.status, StatusCode::NOT_FOUND);
2541 }
2542
2543 #[tokio::test]
2545 async fn worker_submit_rejects_sentinel_relative_path() {
2546 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2547 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2548 let state = test_state(data_store, run_store);
2549 let task_id = StepId::new();
2550 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2551
2552 let err = worker_submit(
2553 State(state.clone()),
2554 bearer_headers(&handle),
2555 Query(SubmitQuery { ok: None }),
2556 axum::body::Bytes::from_static(b"@file:relative/path.md"),
2557 )
2558 .await
2559 .expect_err("relative-path sentinel must be rejected");
2560 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2561 }
2562
2563 #[tokio::test]
2567 async fn worker_submit_rejects_sentinel_without_agent_context_view() {
2568 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2569 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2570 let state = test_state(data_store, run_store);
2571 let task_id = StepId::new();
2572 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2573 let err = worker_submit(
2576 State(state.clone()),
2577 bearer_headers(&handle),
2578 Query(SubmitQuery { ok: None }),
2579 axum::body::Bytes::from_static(b"@file:/tmp/anywhere.md"),
2580 )
2581 .await
2582 .expect_err("missing AgentContextView must reject sentinel");
2583 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2584 }
2585
2586 #[tokio::test]
2590 async fn worker_artifact_resolves_file_sentinel_under_work_dir() {
2591 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2592 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2593 let state = test_state(data_store, run_store);
2594 let task_id = StepId::new();
2595 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2596
2597 let tmp = tempfile::tempdir().expect("tempdir");
2598 seed_work_dir(
2599 &state,
2600 &task_id,
2601 1,
2602 tmp.path().to_str().expect("utf-8"),
2603 Some(Value::Bool(true)),
2604 )
2605 .await;
2606
2607 let payload_path = tmp.path().join("part.md");
2608 let payload = "artifact part body\n";
2609 tokio::fs::write(&payload_path, payload)
2610 .await
2611 .expect("write payload");
2612 let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
2613
2614 let status = worker_artifact(
2615 State(state.clone()),
2616 bearer_headers(&handle),
2617 Query(ArtifactQuery {
2618 name: "scout".to_string(),
2619 }),
2620 axum::body::Bytes::from(body),
2621 )
2622 .await
2623 .expect("worker_artifact sentinel");
2624 assert_eq!(status, StatusCode::NO_CONTENT);
2625 }
2626
2627 #[tokio::test]
2632 async fn worker_submit_rejects_sentinel_without_allow_flag() {
2633 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2634 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2635 let state = test_state(data_store, run_store);
2636 let task_id = StepId::new();
2637 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2638
2639 let tmp = tempfile::tempdir().expect("tempdir");
2640 seed_work_dir(
2641 &state,
2642 &task_id,
2643 1,
2644 tmp.path().to_str().expect("utf-8"),
2645 None,
2646 )
2647 .await;
2648
2649 let payload_path = tmp.path().join("out.md");
2650 tokio::fs::write(&payload_path, b"resolvable body")
2651 .await
2652 .expect("write payload");
2653 let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
2654
2655 let err = worker_submit(
2656 State(state.clone()),
2657 bearer_headers(&handle),
2658 Query(SubmitQuery { ok: None }),
2659 axum::body::Bytes::from(body),
2660 )
2661 .await
2662 .expect_err("missing opt-in must reject sentinel");
2663 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2664 assert!(
2665 err.message.contains("not allowed"),
2666 "rejection must name the opt-in guard, got: {}",
2667 err.message
2668 );
2669 }
2670
2671 #[tokio::test]
2674 async fn worker_submit_rejects_sentinel_with_non_true_allow_values() {
2675 for allow in [Value::Bool(false), Value::String("true".to_string())] {
2676 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2677 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2678 let state = test_state(data_store, run_store);
2679 let task_id = StepId::new();
2680 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2681
2682 let tmp = tempfile::tempdir().expect("tempdir");
2683 seed_work_dir(
2684 &state,
2685 &task_id,
2686 1,
2687 tmp.path().to_str().expect("utf-8"),
2688 Some(allow.clone()),
2689 )
2690 .await;
2691
2692 let payload_path = tmp.path().join("out.md");
2693 tokio::fs::write(&payload_path, b"resolvable body")
2694 .await
2695 .expect("write payload");
2696 let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
2697
2698 let err = worker_submit(
2699 State(state.clone()),
2700 bearer_headers(&handle),
2701 Query(SubmitQuery { ok: None }),
2702 axum::body::Bytes::from(body),
2703 )
2704 .await
2705 .expect_err("non-true opt-in value must reject sentinel");
2706 assert_eq!(err.status, StatusCode::BAD_REQUEST, "value: {allow:?}");
2707 }
2708 }
2709
2710 fn body_verdict_contract(values: &[&str]) -> mlua_swarm_schema::VerdictContract {
2721 mlua_swarm_schema::VerdictContract {
2722 channel: VerdictChannel::Body,
2723 values: values.iter().map(|v| v.to_string()).collect(),
2724 }
2725 }
2726
2727 fn part_verdict_contract(values: &[&str]) -> mlua_swarm_schema::VerdictContract {
2728 mlua_swarm_schema::VerdictContract {
2729 channel: VerdictChannel::Part,
2730 values: values.iter().map(|v| v.to_string()).collect(),
2731 }
2732 }
2733
2734 #[tokio::test]
2737 async fn worker_submit_rejects_body_outside_contract_values_with_422() {
2738 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2739 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2740 let state = test_state(data_store, run_store);
2741 let task_id = StepId::new();
2742 let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
2743 state.engine.register_verdict_contracts(HashMap::from([(
2744 "gate".to_string(),
2745 body_verdict_contract(&["PASS", "BLOCKED"]),
2746 )]));
2747
2748 let err = worker_submit(
2749 State(state.clone()),
2750 bearer_headers(&handle),
2751 Query(SubmitQuery { ok: None }),
2752 axum::body::Bytes::from("UNKNOWN"),
2753 )
2754 .await
2755 .expect_err("value outside declared values must reject");
2756 assert_eq!(err.status, StatusCode::UNPROCESSABLE_ENTITY);
2757 assert!(
2758 err.message.contains("PASS") && err.message.contains("BLOCKED"),
2759 "rejection must echo the declared values, got: {}",
2760 err.message
2761 );
2762 }
2763
2764 #[tokio::test]
2767 async fn worker_submit_accepts_body_inside_contract_values() {
2768 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2769 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2770 let state = test_state(data_store, run_store);
2771 let task_id = StepId::new();
2772 let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
2773 state.engine.register_verdict_contracts(HashMap::from([(
2774 "gate".to_string(),
2775 body_verdict_contract(&["PASS", "BLOCKED"]),
2776 )]));
2777
2778 let status = worker_submit(
2779 State(state.clone()),
2780 bearer_headers(&handle),
2781 Query(SubmitQuery { ok: None }),
2782 axum::body::Bytes::from("PASS"),
2783 )
2784 .await
2785 .expect("value inside declared values must succeed");
2786 assert_eq!(status, StatusCode::NO_CONTENT);
2787 }
2788
2789 #[tokio::test]
2793 async fn worker_submit_without_a_declared_contract_is_unaffected() {
2794 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2795 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2796 let state = test_state(data_store, run_store);
2797 let task_id = StepId::new();
2798 let handle = seed_task_with_handle(&state, &task_id, "undeclared-agent", 1, None).await;
2800
2801 let status = worker_submit(
2802 State(state.clone()),
2803 bearer_headers(&handle),
2804 Query(SubmitQuery { ok: None }),
2805 axum::body::Bytes::from("anything at all, no contract to violate"),
2806 )
2807 .await
2808 .expect("no contract declared must never reject");
2809 assert_eq!(status, StatusCode::NO_CONTENT);
2810 }
2811
2812 #[tokio::test]
2815 async fn worker_artifact_verdict_part_rejects_value_outside_contract_with_422() {
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 err = worker_artifact(
2827 State(state.clone()),
2828 bearer_headers(&handle),
2829 Query(ArtifactQuery {
2830 name: "verdict".to_string(),
2831 }),
2832 axum::body::Bytes::from("UNKNOWN"),
2833 )
2834 .await
2835 .expect_err("value outside declared values must reject");
2836 assert_eq!(err.status, StatusCode::UNPROCESSABLE_ENTITY);
2837 }
2838
2839 #[tokio::test]
2843 async fn worker_artifact_non_verdict_part_skips_the_gate() {
2844 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2845 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2846 let state = test_state(data_store, run_store);
2847 let task_id = StepId::new();
2848 let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
2849 state.engine.register_verdict_contracts(HashMap::from([(
2850 "gate".to_string(),
2851 part_verdict_contract(&["PASS", "BLOCKED"]),
2852 )]));
2853
2854 let status = worker_artifact(
2855 State(state.clone()),
2856 bearer_headers(&handle),
2857 Query(ArtifactQuery {
2858 name: "notes".to_string(),
2859 }),
2860 axum::body::Bytes::from("anything at all"),
2861 )
2862 .await
2863 .expect("non-verdict part name must never be gated");
2864 assert_eq!(status, StatusCode::NO_CONTENT);
2865 }
2866}