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