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 }
1607 }
1608
1609 #[tokio::test]
1615 async fn declared_projection_name_pointer_name_is_canonical_and_policy_matches_it() {
1616 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1617 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1618 let task_id = TaskId::new();
1619 let run_id = RunId::new();
1620 let planner_id = StepId::new();
1621
1622 append_final(
1625 &data_store,
1626 planner_id.as_str(),
1627 "plan-out",
1628 json!({"plan": "x"}),
1629 )
1630 .await;
1631 run_store
1632 .create(run_record(
1633 &task_id,
1634 &run_id,
1635 vec![step_entry(&planner_id, "planner")],
1636 ))
1637 .await
1638 .expect("create run");
1639
1640 let state = test_state(data_store, run_store);
1641
1642 let (naming, _warnings) =
1648 mlua_swarm::core::step_naming::StepNaming::from_blueprint(&declared_name_bp())
1649 .expect("no collision");
1650 let naming = Arc::new(naming);
1651 let consumer_id = StepId::new();
1652 state
1653 .engine
1654 .with_state("test.seed_step_naming", {
1655 let naming = naming.clone();
1656 let planner_id = planner_id.clone();
1657 let consumer_id = consumer_id.clone();
1658 move |s| {
1659 s.step_namings.insert(planner_id, naming.clone());
1660 s.step_namings.insert(consumer_id, naming);
1661 }
1662 })
1663 .await
1664 .expect("seed step naming");
1665 state
1666 .engine
1667 .with_state("test.seed_policy", {
1668 let consumer_id = consumer_id.clone();
1669 move |s| {
1670 s.agent_ctx.insert(
1671 (consumer_id, 1),
1672 mlua_swarm::core::state::AgentCtxEntry {
1673 policy: mlua_swarm_schema::ContextPolicy {
1674 steps: Some(vec!["plan-out".to_string()]),
1675 ..Default::default()
1676 },
1677 ..Default::default()
1678 },
1679 );
1680 }
1681 })
1682 .await
1683 .expect("seed policy");
1684
1685 let mut payload = consumer_payload(&consumer_id, &run_id);
1686 assemble_step_pointers(&state, &mut payload).await;
1687
1688 let steps = &payload.context.expect("context").steps;
1689 assert_eq!(steps.len(), 1, "steps: {steps:?}");
1690 assert_eq!(
1691 steps[0].name, "plan-out",
1692 "StepPointer.name must be the canonical name"
1693 );
1694 }
1695
1696 async fn seed_task_with_handle(
1706 state: &AppState,
1707 task_id: &StepId,
1708 agent: &str,
1709 attempt: u32,
1710 system: Option<String>,
1711 ) -> String {
1712 let handle = format!("wh-{}", mlua_swarm::types::secure_hex(4));
1713 let task_id = task_id.clone();
1714 let agent = agent.to_string();
1715 let handle_clone = handle.clone();
1716 state
1717 .engine
1718 .with_state("test.seed_task_with_handle", move |s| {
1719 let mut task = mlua_swarm::core::state::TaskState::new(
1720 task_id.clone(),
1721 mlua_swarm::core::state::TaskSpec {
1722 agent: agent.clone(),
1723 initial_directive: json!("x"),
1724 step_ctx: None,
1725 check_policy: None,
1726 },
1727 );
1728 task.attempt = attempt;
1729 s.tasks.insert(task_id.clone(), task);
1730 s.systems.insert((task_id.clone(), attempt), system);
1731 let token = CapToken {
1732 agent_id: agent,
1733 role: mlua_swarm::Role::Worker,
1734 scopes: vec!["*".to_string()],
1735 issued_at: 0,
1736 expire_at: u64::MAX,
1737 max_uses: None,
1738 nonce: format!("test-nonce-{task_id}"),
1739 sig_hex: String::new(),
1740 };
1741 let fp = token.fingerprint();
1742 s.tokens.insert(
1743 fp.clone(),
1744 mlua_swarm::core::state::CapTokenRecord {
1745 token,
1746 uses_left: None,
1747 revoked: false,
1748 task_id: Some(task_id),
1749 },
1750 );
1751 s.worker_handles.insert(handle_clone, fp);
1752 })
1753 .await
1754 .expect("seed_task_with_handle");
1755 handle
1756 }
1757
1758 fn bearer_headers(handle: &str) -> HeaderMap {
1759 let mut headers = HeaderMap::new();
1760 headers.insert(
1761 AUTHORIZATION,
1762 format!("Bearer {handle}").parse().expect("header value"),
1763 );
1764 headers
1765 }
1766
1767 #[tokio::test]
1771 async fn worker_prompt_system_returns_raw_bytes_for_baked_system() {
1772 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1773 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1774 let state = test_state(data_store, run_store);
1775 let task_id = StepId::new();
1776 let rendered = "# Hello\n\nThis is the baked system prompt.".to_string();
1777 let handle =
1778 seed_task_with_handle(&state, &task_id, "planner", 1, Some(rendered.clone())).await;
1779
1780 let resp = worker_prompt_system(
1781 State(state.clone()),
1782 bearer_headers(&handle),
1783 Query(PromptSystemQuery {
1784 task_id: task_id.clone(),
1785 attempt: 1,
1786 }),
1787 )
1788 .await
1789 .expect("worker_prompt_system")
1790 .into_response();
1791
1792 assert_eq!(resp.status(), StatusCode::OK);
1793 let content_type = resp
1794 .headers()
1795 .get(header::CONTENT_TYPE)
1796 .expect("content-type header")
1797 .to_str()
1798 .expect("ascii");
1799 assert_eq!(content_type, "text/plain; charset=utf-8");
1800 let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1801 .await
1802 .expect("body bytes");
1803 assert_eq!(body_bytes.as_ref(), rendered.as_bytes());
1804 }
1805
1806 #[tokio::test]
1809 async fn worker_prompt_system_404s_when_no_baked_system() {
1810 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1811 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1812 let state = test_state(data_store, run_store);
1813 let task_id = StepId::new();
1814 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
1815
1816 let result = worker_prompt_system(
1817 State(state.clone()),
1818 bearer_headers(&handle),
1819 Query(PromptSystemQuery {
1820 task_id: task_id.clone(),
1821 attempt: 1,
1822 }),
1823 )
1824 .await;
1825 let err = match result {
1826 Ok(_) => panic!("expected 404 ApiError, got Ok"),
1827 Err(e) => e,
1828 };
1829 assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND);
1830 }
1831
1832 #[tokio::test]
1835 async fn worker_prompt_system_rejects_handle_task_mismatch() {
1836 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1837 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1838 let state = test_state(data_store, run_store);
1839 let task_id = StepId::new();
1840 let other_task_id = StepId::new();
1841 let handle =
1842 seed_task_with_handle(&state, &task_id, "planner", 1, Some("x".to_string())).await;
1843
1844 let result = worker_prompt_system(
1845 State(state.clone()),
1846 bearer_headers(&handle),
1847 Query(PromptSystemQuery {
1848 task_id: other_task_id,
1849 attempt: 1,
1850 }),
1851 )
1852 .await;
1853 let err = match result {
1854 Ok(_) => panic!("expected 400 ApiError for task mismatch, got Ok"),
1855 Err(e) => e,
1856 };
1857 assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
1858 }
1859
1860 #[tokio::test]
1864 async fn agent_render_size_returns_null_for_unknown_agent() {
1865 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1866 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1867 let state = test_state(data_store, run_store);
1868
1869 let Json(body) = agent_render_size(
1870 State(state.clone()),
1871 axum::extract::Path("never-dispatched".to_string()),
1872 )
1873 .await;
1874 assert_eq!(body.agent, "never-dispatched");
1875 assert_eq!(body.last_rendered_bytes, None);
1876 }
1877
1878 #[tokio::test]
1881 async fn agent_render_size_reports_last_rendered_bytes() {
1882 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1883 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1884 let state = test_state(data_store, run_store);
1885 let task_id = StepId::new();
1886 state
1887 .engine
1888 .with_state("test.seed_agent_ctx_for_bake", {
1889 let task_id = task_id.clone();
1890 move |s| {
1891 s.tasks.insert(
1892 task_id.clone(),
1893 mlua_swarm::core::state::TaskState::new(
1894 task_id,
1895 mlua_swarm::core::state::TaskSpec {
1896 agent: "coder".to_string(),
1897 initial_directive: json!("x"),
1898 step_ctx: None,
1899 check_policy: None,
1900 },
1901 ),
1902 );
1903 }
1904 })
1905 .await
1906 .expect("seed task");
1907 state
1908 .engine
1909 .bake_worker_system_prompt(&task_id, 1, Some("z".repeat(42)))
1910 .await
1911 .expect("bake_worker_system_prompt");
1912
1913 let Json(body) = agent_render_size(
1914 State(state.clone()),
1915 axum::extract::Path("coder".to_string()),
1916 )
1917 .await;
1918 assert_eq!(body.agent, "coder");
1919 assert_eq!(body.last_rendered_bytes, Some(42));
1920 }
1921
1922 #[tokio::test]
1930 async fn worker_artifact_stages_and_204s_for_valid_request() {
1931 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1932 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1933 let state = test_state(data_store, run_store);
1934 let task_id = StepId::new();
1935 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
1936
1937 let status = worker_artifact(
1938 State(state.clone()),
1939 bearer_headers(&handle),
1940 Query(ArtifactQuery {
1941 name: "summary".to_string(),
1942 }),
1943 axum::body::Bytes::from_static(b"hello artifact\n"),
1944 )
1945 .await
1946 .expect("worker_artifact");
1947 assert_eq!(status, StatusCode::NO_CONTENT);
1948
1949 let tail = state.engine.output_tail(&task_id, 1).await;
1950 assert_eq!(tail.len(), 1, "tail: {tail:?}");
1951 match &tail[0] {
1952 OutputEvent::Artifact { name, content } => {
1953 assert_eq!(name, "summary");
1954 match content {
1955 ContentRef::Inline { value } => {
1956 assert_eq!(value, &json!("hello artifact"));
1957 }
1958 other => panic!("expected Inline content, got {other:?}"),
1959 }
1960 }
1961 other => panic!("expected Artifact event, got {other:?}"),
1962 }
1963 }
1964
1965 #[tokio::test]
1972 async fn worker_artifact_rejects_blank_name() {
1973 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1974 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1975 let state = test_state(data_store, run_store);
1976 let task_id = StepId::new();
1977 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
1978
1979 let result = worker_artifact(
1980 State(state.clone()),
1981 bearer_headers(&handle),
1982 Query(ArtifactQuery {
1983 name: " ".to_string(),
1984 }),
1985 axum::body::Bytes::from_static(b"x"),
1986 )
1987 .await;
1988 let err = match result {
1989 Ok(_) => panic!("expected 400 ApiError for blank name, got Ok"),
1990 Err(e) => e,
1991 };
1992 assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
1993
1994 assert!(state.engine.output_tail(&task_id, 1).await.is_empty());
1996 }
1997
1998 #[tokio::test]
2004 async fn worker_artifact_staging_same_name_twice_appends_both_events_in_order() {
2005 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2006 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2007 let state = test_state(data_store, run_store);
2008 let task_id = StepId::new();
2009 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2010
2011 for body in [b"first".as_slice(), b"second".as_slice()] {
2012 worker_artifact(
2013 State(state.clone()),
2014 bearer_headers(&handle),
2015 Query(ArtifactQuery {
2016 name: "a".to_string(),
2017 }),
2018 axum::body::Bytes::copy_from_slice(body),
2019 )
2020 .await
2021 .expect("worker_artifact");
2022 }
2023
2024 let tail = state.engine.output_tail(&task_id, 1).await;
2025 assert_eq!(tail.len(), 2, "tail: {tail:?}");
2026 let values: Vec<&str> = tail
2027 .iter()
2028 .map(|ev| match ev {
2029 OutputEvent::Artifact {
2030 content: ContentRef::Inline { value },
2031 ..
2032 } => value.as_str().expect("string value"),
2033 other => panic!("expected Artifact/Inline event, got {other:?}"),
2034 })
2035 .collect();
2036 assert_eq!(values, vec!["first", "second"]);
2037 }
2038
2039 async fn link_task_to_run(state: &AppState, task_id: &StepId, attempt: u32, run_id: &RunId) {
2047 let tid = task_id.clone();
2048 let rid_str = run_id.to_string();
2049 state
2050 .engine
2051 .with_state("test.link_task_to_run", move |s| {
2052 let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2053 entry.view.run_id = Some(rid_str);
2054 s.agent_ctx.insert((tid, attempt), entry);
2055 })
2056 .await
2057 .expect("link_task_to_run");
2058 }
2059
2060 #[tokio::test]
2065 async fn submit_and_artifact_against_terminal_run_return_410() {
2066 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2067 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2068 let state = test_state(data_store, run_store.clone());
2069 let task_id = StepId::new();
2070 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2071
2072 let owner_task = TaskId::new();
2073 let run_id = RunId::new();
2074 let mut rec = run_record(&owner_task, &run_id, vec![]);
2075 rec.status = RunStatus::Failed;
2076 run_store.create(rec).await.expect("run create");
2077 link_task_to_run(&state, &task_id, 1, &run_id).await;
2078
2079 let err = worker_submit(
2080 State(state.clone()),
2081 bearer_headers(&handle),
2082 Query(SubmitQuery { ok: None }),
2083 axum::body::Bytes::from_static(b"LATE OUTPUT"),
2084 )
2085 .await
2086 .expect_err("a submit against a Failed run must be rejected");
2087 assert_eq!(err.status, StatusCode::GONE);
2088 assert!(
2089 err.message.contains(&run_id.to_string()),
2090 "the 410 must name the terminal run: {}",
2091 err.message
2092 );
2093
2094 let err = worker_artifact(
2095 State(state.clone()),
2096 bearer_headers(&handle),
2097 Query(ArtifactQuery {
2098 name: "part.md".to_string(),
2099 }),
2100 axum::body::Bytes::from_static(b"LATE PART"),
2101 )
2102 .await
2103 .expect_err("an artifact staged against a Failed run must be rejected");
2104 assert_eq!(err.status, StatusCode::GONE);
2105
2106 let tail = state.engine.output_tail(&task_id, 1).await;
2108 assert!(tail.is_empty(), "rejected submits must not land: {tail:?}");
2109 }
2110
2111 #[tokio::test]
2115 async fn terminal_run_guard_is_fail_open() {
2116 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2117 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2118 let state = test_state(data_store, run_store.clone());
2119 let task_id = StepId::new();
2120 seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2121
2122 reject_if_run_terminal(&state, &task_id, 1)
2124 .await
2125 .expect("no linkage must fail open");
2126
2127 let unknown_run = RunId::new();
2129 link_task_to_run(&state, &task_id, 1, &unknown_run).await;
2130 reject_if_run_terminal(&state, &task_id, 1)
2131 .await
2132 .expect("unknown run must fail open");
2133
2134 let owner_task = TaskId::new();
2136 let live_run = RunId::new();
2137 run_store
2138 .create(run_record(&owner_task, &live_run, vec![]))
2139 .await
2140 .expect("run create");
2141 link_task_to_run(&state, &task_id, 1, &live_run).await;
2142 reject_if_run_terminal(&state, &task_id, 1)
2143 .await
2144 .expect("a Running run must pass the guard");
2145 }
2146
2147 fn degradation_body(tool: &str, note: Option<&str>) -> DegradationBody {
2152 DegradationBody {
2153 tool: tool.to_string(),
2154 error: "boom".to_string(),
2155 fallback: "used cached value".to_string(),
2156 note: note.map(str::to_string),
2157 }
2158 }
2159
2160 async fn link_task_to_run_with_agent(
2166 state: &AppState,
2167 task_id: &StepId,
2168 attempt: u32,
2169 run_id: &RunId,
2170 agent: &str,
2171 ) {
2172 let tid = task_id.clone();
2173 let rid_str = run_id.to_string();
2174 let agent = agent.to_string();
2175 state
2176 .engine
2177 .with_state("test.link_task_to_run_with_agent", move |s| {
2178 let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2179 entry.view.run_id = Some(rid_str);
2180 entry.view.agent = agent;
2181 s.agent_ctx.insert((tid, attempt), entry);
2182 })
2183 .await
2184 .expect("link_task_to_run_with_agent");
2185 }
2186
2187 #[tokio::test]
2192 async fn worker_degradation_persists_entry_when_run_tracked() {
2193 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2194 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2195 let state = test_state(data_store, run_store.clone());
2196 let task_id = StepId::new();
2197 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2198
2199 let owner_task = TaskId::new();
2200 let run_id = RunId::new();
2201 run_store
2202 .create(run_record(&owner_task, &run_id, vec![]))
2203 .await
2204 .expect("run create");
2205 link_task_to_run_with_agent(&state, &task_id, 1, &run_id, "planner").await;
2206
2207 let status = worker_degradation(
2208 State(state.clone()),
2209 bearer_headers(&handle),
2210 Json(degradation_body("web_search", Some("rate limited"))),
2211 )
2212 .await
2213 .expect("worker_degradation");
2214 assert_eq!(status, StatusCode::NO_CONTENT);
2215
2216 let rec = run_store.get(&run_id).await.expect("run get");
2217 assert_eq!(
2218 rec.degradations.len(),
2219 1,
2220 "degradations: {:?}",
2221 rec.degradations
2222 );
2223 let entry = &rec.degradations[0];
2224 assert_eq!(entry.tool, "web_search");
2225 assert_eq!(entry.error, "boom");
2226 assert_eq!(entry.fallback, "used cached value");
2227 assert_eq!(entry.note.as_deref(), Some("rate limited"));
2228 assert_eq!(entry.step_ref.as_deref(), Some("planner"));
2229 assert_eq!(entry.attempt, Some(1));
2230 assert!(entry.at > 0, "at must be a real timestamp: {}", entry.at);
2231 }
2232
2233 #[tokio::test]
2235 async fn worker_degradation_appends_in_order() {
2236 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2237 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2238 let state = test_state(data_store, run_store.clone());
2239 let task_id = StepId::new();
2240 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2241
2242 let owner_task = TaskId::new();
2243 let run_id = RunId::new();
2244 run_store
2245 .create(run_record(&owner_task, &run_id, vec![]))
2246 .await
2247 .expect("run create");
2248 link_task_to_run(&state, &task_id, 1, &run_id).await;
2249
2250 for tool in ["first_tool", "second_tool"] {
2251 worker_degradation(
2252 State(state.clone()),
2253 bearer_headers(&handle),
2254 Json(degradation_body(tool, None)),
2255 )
2256 .await
2257 .expect("worker_degradation");
2258 }
2259
2260 let rec = run_store.get(&run_id).await.expect("run get");
2261 let tools: Vec<&str> = rec.degradations.iter().map(|e| e.tool.as_str()).collect();
2262 assert_eq!(tools, vec!["first_tool", "second_tool"]);
2263 }
2264
2265 #[tokio::test]
2269 async fn worker_degradation_silent_ok_when_no_run_tracked() {
2270 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2271 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2272 let state = test_state(data_store, run_store);
2273 let task_id = StepId::new();
2274 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2275
2276 let status = worker_degradation(
2277 State(state.clone()),
2278 bearer_headers(&handle),
2279 Json(degradation_body("some_tool", None)),
2280 )
2281 .await
2282 .expect("worker_degradation must not error on missing run linkage");
2283 assert_eq!(status, StatusCode::NO_CONTENT);
2284 }
2285
2286 #[tokio::test]
2289 async fn worker_degradation_rejects_terminal_run() {
2290 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2291 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2292 let state = test_state(data_store, run_store.clone());
2293 let task_id = StepId::new();
2294 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2295
2296 let owner_task = TaskId::new();
2297 let run_id = RunId::new();
2298 let mut rec = run_record(&owner_task, &run_id, vec![]);
2299 rec.status = RunStatus::Done;
2300 run_store.create(rec).await.expect("run create");
2301 link_task_to_run(&state, &task_id, 1, &run_id).await;
2302
2303 let err = worker_degradation(
2304 State(state.clone()),
2305 bearer_headers(&handle),
2306 Json(degradation_body("some_tool", None)),
2307 )
2308 .await
2309 .expect_err("a degradation against a Done run must be rejected");
2310 assert_eq!(err.status, StatusCode::GONE);
2311
2312 let rec = run_store.get(&run_id).await.expect("run get");
2313 assert!(
2314 rec.degradations.is_empty(),
2315 "rejected degradation must not land: {:?}",
2316 rec.degradations
2317 );
2318 }
2319
2320 async fn seed_work_dir(
2334 state: &AppState,
2335 task_id: &StepId,
2336 attempt: u32,
2337 work_dir: &str,
2338 allow_file_submit: Option<Value>,
2339 ) {
2340 let tid = task_id.clone();
2341 let work_dir = work_dir.to_string();
2342 state
2343 .engine
2344 .with_state("test.seed_work_dir", move |s| {
2345 let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2346 entry.view.work_dir = Some(work_dir);
2347 if let Some(v) = allow_file_submit {
2348 entry
2349 .view
2350 .extra
2351 .insert(FILE_SENTINEL_ALLOW_KEY.to_string(), v);
2352 }
2353 s.agent_ctx.insert((tid, attempt), entry);
2354 })
2355 .await
2356 .expect("seed_work_dir");
2357 }
2358
2359 #[tokio::test]
2363 async fn worker_submit_resolves_file_sentinel_under_work_dir() {
2364 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2365 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2366 let state = test_state(data_store.clone(), run_store);
2367 let task_id = StepId::new();
2368 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2369
2370 let tmp = tempfile::tempdir().expect("tempdir");
2371 let work_dir = tmp.path().to_path_buf();
2372 seed_work_dir(
2373 &state,
2374 &task_id,
2375 1,
2376 work_dir.to_str().expect("work_dir utf-8"),
2377 Some(Value::Bool(true)),
2378 )
2379 .await;
2380
2381 let payload_path = work_dir.join("scout.md");
2382 let payload = "## Context Package (broad)\n\nlarge body content\n";
2383 tokio::fs::write(&payload_path, payload)
2384 .await
2385 .expect("write payload");
2386 let body = format!(
2387 "@file:{}",
2388 payload_path.to_str().expect("payload path utf-8")
2389 );
2390
2391 let status = worker_submit(
2392 State(state.clone()),
2393 bearer_headers(&handle),
2394 Query(SubmitQuery { ok: None }),
2395 axum::body::Bytes::from(body),
2396 )
2397 .await
2398 .expect("worker_submit sentinel");
2399 assert_eq!(status, StatusCode::NO_CONTENT);
2400
2401 let tid = task_id.clone();
2405 let value = state
2406 .engine
2407 .with_state("test.inspect_output_store", move |s| {
2408 s.output_store.get(&(tid.clone(), 1)).and_then(|evs| {
2409 evs.iter().find_map(|ev| match ev {
2410 OutputEvent::Final {
2411 content: ContentRef::Inline { value },
2412 ..
2413 } => Some(value.clone()),
2414 _ => None,
2415 })
2416 })
2417 })
2418 .await
2419 .expect("with_state")
2420 .expect("Final event present");
2421 assert_eq!(value, Value::String(payload.trim_end().to_string()));
2422 }
2423
2424 #[tokio::test]
2427 async fn worker_submit_passes_non_sentinel_body_unchanged() {
2428 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2429 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2430 let state = test_state(data_store.clone(), run_store);
2431 let task_id = StepId::new();
2432 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2433 let status = worker_submit(
2437 State(state.clone()),
2438 bearer_headers(&handle),
2439 Query(SubmitQuery { ok: None }),
2440 axum::body::Bytes::from_static(b"DONE yes=1 maybe=0 no=0"),
2441 )
2442 .await
2443 .expect("worker_submit inline");
2444 assert_eq!(status, StatusCode::NO_CONTENT);
2445
2446 let tid = task_id.clone();
2447 let value = state
2448 .engine
2449 .with_state("test.inspect_output_store", move |s| {
2450 s.output_store.get(&(tid.clone(), 1)).and_then(|evs| {
2451 evs.iter().find_map(|ev| match ev {
2452 OutputEvent::Final {
2453 content: ContentRef::Inline { value },
2454 ..
2455 } => Some(value.clone()),
2456 _ => None,
2457 })
2458 })
2459 })
2460 .await
2461 .expect("with_state")
2462 .expect("Final event present");
2463 assert_eq!(value, Value::String("DONE yes=1 maybe=0 no=0".to_string()));
2464 }
2465
2466 #[tokio::test]
2471 async fn worker_submit_rejects_sentinel_path_outside_work_dir() {
2472 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2473 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2474 let state = test_state(data_store, run_store);
2475 let task_id = StepId::new();
2476 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2477
2478 let allowed = tempfile::tempdir().expect("allowed tempdir");
2479 let outside = tempfile::tempdir().expect("outside tempdir");
2480 seed_work_dir(
2481 &state,
2482 &task_id,
2483 1,
2484 allowed.path().to_str().expect("utf-8"),
2485 Some(Value::Bool(true)),
2486 )
2487 .await;
2488
2489 let outside_file = outside.path().join("leak.md");
2490 tokio::fs::write(&outside_file, b"outside content")
2491 .await
2492 .expect("write outside");
2493 let body = format!(
2494 "@file:{}",
2495 outside_file.to_str().expect("outside path utf-8")
2496 );
2497
2498 let err = worker_submit(
2499 State(state.clone()),
2500 bearer_headers(&handle),
2501 Query(SubmitQuery { ok: None }),
2502 axum::body::Bytes::from(body),
2503 )
2504 .await
2505 .expect_err("outside-work_dir sentinel must be rejected");
2506 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2507 }
2508
2509 #[tokio::test]
2511 async fn worker_submit_rejects_sentinel_missing_file() {
2512 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2513 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2514 let state = test_state(data_store, run_store);
2515 let task_id = StepId::new();
2516 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2517
2518 let tmp = tempfile::tempdir().expect("tempdir");
2519 seed_work_dir(
2520 &state,
2521 &task_id,
2522 1,
2523 tmp.path().to_str().expect("utf-8"),
2524 Some(Value::Bool(true)),
2525 )
2526 .await;
2527 let missing = tmp.path().join("does-not-exist.md");
2528 let body = format!("@file:{}", missing.to_str().expect("utf-8"));
2529
2530 let err = worker_submit(
2531 State(state.clone()),
2532 bearer_headers(&handle),
2533 Query(SubmitQuery { ok: None }),
2534 axum::body::Bytes::from(body),
2535 )
2536 .await
2537 .expect_err("missing-file sentinel must be rejected");
2538 assert_eq!(err.status, StatusCode::NOT_FOUND);
2539 }
2540
2541 #[tokio::test]
2543 async fn worker_submit_rejects_sentinel_relative_path() {
2544 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2545 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2546 let state = test_state(data_store, run_store);
2547 let task_id = StepId::new();
2548 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2549
2550 let err = worker_submit(
2551 State(state.clone()),
2552 bearer_headers(&handle),
2553 Query(SubmitQuery { ok: None }),
2554 axum::body::Bytes::from_static(b"@file:relative/path.md"),
2555 )
2556 .await
2557 .expect_err("relative-path sentinel must be rejected");
2558 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2559 }
2560
2561 #[tokio::test]
2565 async fn worker_submit_rejects_sentinel_without_agent_context_view() {
2566 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2567 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2568 let state = test_state(data_store, run_store);
2569 let task_id = StepId::new();
2570 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2571 let err = worker_submit(
2574 State(state.clone()),
2575 bearer_headers(&handle),
2576 Query(SubmitQuery { ok: None }),
2577 axum::body::Bytes::from_static(b"@file:/tmp/anywhere.md"),
2578 )
2579 .await
2580 .expect_err("missing AgentContextView must reject sentinel");
2581 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2582 }
2583
2584 #[tokio::test]
2588 async fn worker_artifact_resolves_file_sentinel_under_work_dir() {
2589 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2590 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2591 let state = test_state(data_store, run_store);
2592 let task_id = StepId::new();
2593 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2594
2595 let tmp = tempfile::tempdir().expect("tempdir");
2596 seed_work_dir(
2597 &state,
2598 &task_id,
2599 1,
2600 tmp.path().to_str().expect("utf-8"),
2601 Some(Value::Bool(true)),
2602 )
2603 .await;
2604
2605 let payload_path = tmp.path().join("part.md");
2606 let payload = "artifact part body\n";
2607 tokio::fs::write(&payload_path, payload)
2608 .await
2609 .expect("write payload");
2610 let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
2611
2612 let status = worker_artifact(
2613 State(state.clone()),
2614 bearer_headers(&handle),
2615 Query(ArtifactQuery {
2616 name: "scout".to_string(),
2617 }),
2618 axum::body::Bytes::from(body),
2619 )
2620 .await
2621 .expect("worker_artifact sentinel");
2622 assert_eq!(status, StatusCode::NO_CONTENT);
2623 }
2624
2625 #[tokio::test]
2630 async fn worker_submit_rejects_sentinel_without_allow_flag() {
2631 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2632 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2633 let state = test_state(data_store, run_store);
2634 let task_id = StepId::new();
2635 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2636
2637 let tmp = tempfile::tempdir().expect("tempdir");
2638 seed_work_dir(
2639 &state,
2640 &task_id,
2641 1,
2642 tmp.path().to_str().expect("utf-8"),
2643 None,
2644 )
2645 .await;
2646
2647 let payload_path = tmp.path().join("out.md");
2648 tokio::fs::write(&payload_path, b"resolvable body")
2649 .await
2650 .expect("write payload");
2651 let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
2652
2653 let err = worker_submit(
2654 State(state.clone()),
2655 bearer_headers(&handle),
2656 Query(SubmitQuery { ok: None }),
2657 axum::body::Bytes::from(body),
2658 )
2659 .await
2660 .expect_err("missing opt-in must reject sentinel");
2661 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2662 assert!(
2663 err.message.contains("not allowed"),
2664 "rejection must name the opt-in guard, got: {}",
2665 err.message
2666 );
2667 }
2668
2669 #[tokio::test]
2672 async fn worker_submit_rejects_sentinel_with_non_true_allow_values() {
2673 for allow in [Value::Bool(false), Value::String("true".to_string())] {
2674 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2675 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2676 let state = test_state(data_store, run_store);
2677 let task_id = StepId::new();
2678 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2679
2680 let tmp = tempfile::tempdir().expect("tempdir");
2681 seed_work_dir(
2682 &state,
2683 &task_id,
2684 1,
2685 tmp.path().to_str().expect("utf-8"),
2686 Some(allow.clone()),
2687 )
2688 .await;
2689
2690 let payload_path = tmp.path().join("out.md");
2691 tokio::fs::write(&payload_path, b"resolvable body")
2692 .await
2693 .expect("write payload");
2694 let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
2695
2696 let err = worker_submit(
2697 State(state.clone()),
2698 bearer_headers(&handle),
2699 Query(SubmitQuery { ok: None }),
2700 axum::body::Bytes::from(body),
2701 )
2702 .await
2703 .expect_err("non-true opt-in value must reject sentinel");
2704 assert_eq!(err.status, StatusCode::BAD_REQUEST, "value: {allow:?}");
2705 }
2706 }
2707
2708 fn body_verdict_contract(values: &[&str]) -> mlua_swarm_schema::VerdictContract {
2719 mlua_swarm_schema::VerdictContract {
2720 channel: VerdictChannel::Body,
2721 values: values.iter().map(|v| v.to_string()).collect(),
2722 }
2723 }
2724
2725 fn part_verdict_contract(values: &[&str]) -> mlua_swarm_schema::VerdictContract {
2726 mlua_swarm_schema::VerdictContract {
2727 channel: VerdictChannel::Part,
2728 values: values.iter().map(|v| v.to_string()).collect(),
2729 }
2730 }
2731
2732 #[tokio::test]
2735 async fn worker_submit_rejects_body_outside_contract_values_with_422() {
2736 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2737 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2738 let state = test_state(data_store, run_store);
2739 let task_id = StepId::new();
2740 let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
2741 state.engine.register_verdict_contracts(HashMap::from([(
2742 "gate".to_string(),
2743 body_verdict_contract(&["PASS", "BLOCKED"]),
2744 )]));
2745
2746 let err = worker_submit(
2747 State(state.clone()),
2748 bearer_headers(&handle),
2749 Query(SubmitQuery { ok: None }),
2750 axum::body::Bytes::from("UNKNOWN"),
2751 )
2752 .await
2753 .expect_err("value outside declared values must reject");
2754 assert_eq!(err.status, StatusCode::UNPROCESSABLE_ENTITY);
2755 assert!(
2756 err.message.contains("PASS") && err.message.contains("BLOCKED"),
2757 "rejection must echo the declared values, got: {}",
2758 err.message
2759 );
2760 }
2761
2762 #[tokio::test]
2765 async fn worker_submit_accepts_body_inside_contract_values() {
2766 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2767 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2768 let state = test_state(data_store, run_store);
2769 let task_id = StepId::new();
2770 let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
2771 state.engine.register_verdict_contracts(HashMap::from([(
2772 "gate".to_string(),
2773 body_verdict_contract(&["PASS", "BLOCKED"]),
2774 )]));
2775
2776 let status = worker_submit(
2777 State(state.clone()),
2778 bearer_headers(&handle),
2779 Query(SubmitQuery { ok: None }),
2780 axum::body::Bytes::from("PASS"),
2781 )
2782 .await
2783 .expect("value inside declared values must succeed");
2784 assert_eq!(status, StatusCode::NO_CONTENT);
2785 }
2786
2787 #[tokio::test]
2791 async fn worker_submit_without_a_declared_contract_is_unaffected() {
2792 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2793 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2794 let state = test_state(data_store, run_store);
2795 let task_id = StepId::new();
2796 let handle = seed_task_with_handle(&state, &task_id, "undeclared-agent", 1, None).await;
2798
2799 let status = worker_submit(
2800 State(state.clone()),
2801 bearer_headers(&handle),
2802 Query(SubmitQuery { ok: None }),
2803 axum::body::Bytes::from("anything at all, no contract to violate"),
2804 )
2805 .await
2806 .expect("no contract declared must never reject");
2807 assert_eq!(status, StatusCode::NO_CONTENT);
2808 }
2809
2810 #[tokio::test]
2813 async fn worker_artifact_verdict_part_rejects_value_outside_contract_with_422() {
2814 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2815 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2816 let state = test_state(data_store, run_store);
2817 let task_id = StepId::new();
2818 let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
2819 state.engine.register_verdict_contracts(HashMap::from([(
2820 "gate".to_string(),
2821 part_verdict_contract(&["PASS", "BLOCKED"]),
2822 )]));
2823
2824 let err = worker_artifact(
2825 State(state.clone()),
2826 bearer_headers(&handle),
2827 Query(ArtifactQuery {
2828 name: "verdict".to_string(),
2829 }),
2830 axum::body::Bytes::from("UNKNOWN"),
2831 )
2832 .await
2833 .expect_err("value outside declared values must reject");
2834 assert_eq!(err.status, StatusCode::UNPROCESSABLE_ENTITY);
2835 }
2836
2837 #[tokio::test]
2841 async fn worker_artifact_non_verdict_part_skips_the_gate() {
2842 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2843 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2844 let state = test_state(data_store, run_store);
2845 let task_id = StepId::new();
2846 let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
2847 state.engine.register_verdict_contracts(HashMap::from([(
2848 "gate".to_string(),
2849 part_verdict_contract(&["PASS", "BLOCKED"]),
2850 )]));
2851
2852 let status = worker_artifact(
2853 State(state.clone()),
2854 bearer_headers(&handle),
2855 Query(ArtifactQuery {
2856 name: "notes".to_string(),
2857 }),
2858 axum::body::Bytes::from("anything at all"),
2859 )
2860 .await
2861 .expect("non-verdict part name must never be gated");
2862 assert_eq!(status, StatusCode::NO_CONTENT);
2863 }
2864}