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