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::state::SubmitOutcome;
77use mlua_swarm::core::step_naming::StepNaming;
78use mlua_swarm::store::run::{DegradationEntry, RunStatus, RunStoreError};
79use mlua_swarm::{CapToken, ContentRef, EngineError, OutputEvent, RunId, StepId, WorkerPayload};
80use mlua_swarm_schema::{ContextPolicy, VerdictChannel};
81use serde::Deserialize;
82use serde_json::Value;
83
84use crate::projection::McpQueryAdapter;
85use crate::{ApiError, AppState};
86
87#[derive(Debug, Deserialize)]
89pub struct PromptQuery {
90 pub task_id: StepId,
94}
95
96pub async fn worker_prompt(
102 State(state): State<AppState>,
103 headers: HeaderMap,
104 Query(q): Query<PromptQuery>,
105) -> Result<Json<WorkerPayload>, ApiError> {
106 let task_id = q.task_id;
107 let bearer = extract_bearer_raw(&headers)?;
108 let mut payload = if let Some(handle) = parse_worker_handle(&bearer) {
109 let resolved = state
111 .engine
112 .task_id_from_handle(handle)
113 .await
114 .map_err(map_handle_lookup_err)?;
115 if resolved != task_id {
116 return Err(ApiError::bad_request(format!(
117 "handle {handle} is bound to task {resolved}, not {task_id}"
118 )));
119 }
120 state
121 .engine
122 .fetch_worker_payload_trusted(&task_id)
123 .await
124 .map_err(|e| ApiError::engine(format!("fetch_worker_payload_trusted: {e}")))?
125 } else {
126 let token = CapToken::decode(bearer.trim())
128 .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
129 state
130 .engine
131 .fetch_worker_payload(&token, &task_id)
132 .await
133 .map_err(|e| ApiError::engine(format!("fetch_worker_payload: {e}")))?
134 };
135 assemble_step_pointers(&state, &mut payload).await;
136 Ok(Json(payload))
137}
138
139async fn assemble_step_pointers(state: &AppState, payload: &mut WorkerPayload) {
167 let Some(context) = payload.context.as_mut() else {
168 return;
169 };
170 let Some(run_id_str) = context.run_id.clone() else {
171 return;
172 };
173 let Ok(run_id) = RunId::parse(run_id_str) else {
174 return;
175 };
176
177 let adapter = McpQueryAdapter::new(
178 state.data_store.clone(),
179 state.run_store.clone(),
180 state.engine.clone(),
181 );
182 let Ok((run, resolved_steps)) = adapter.list_steps_by_run_id(&run_id).await else {
183 return;
184 };
185
186 let naming = state.engine.step_naming_for(&payload.task_id).await;
187 let policy = state
188 .engine
189 .context_policy_for(&payload.task_id, payload.attempt)
190 .await;
191 let self_canonical = naming
192 .as_deref()
193 .and_then(|n| n.canonical_of_producer(&payload.agent))
194 .map(str::to_string)
195 .unwrap_or_else(|| payload.agent.clone());
196
197 let mut pointers = Vec::new();
198 for step in &resolved_steps {
199 if step.name == self_canonical
200 || !allows_step_canonical(&policy, naming.as_deref(), &step.name)
201 {
202 continue;
203 }
204 if let Some((size_bytes, file_path, content_url, sha256)) =
205 crate::projection::resolve_step_pointer_fields(state, &run, step).await
206 {
207 pointers.push(StepPointer {
208 name: step.name.clone(),
209 size_bytes,
210 file_path,
211 content_url,
212 sha256,
213 });
214 }
215 }
216 context.steps = pointers;
217}
218
219fn allows_step_canonical(
234 policy: &ContextPolicy,
235 naming: Option<&StepNaming>,
236 canonical_name: &str,
237) -> bool {
238 let resolves_to = |raw: &str| -> bool {
239 match naming {
240 Some(n) => n
241 .resolve(raw)
242 .map(|c| c == canonical_name)
243 .unwrap_or(raw == canonical_name),
244 None => raw == canonical_name,
245 }
246 };
247 if policy
248 .steps_exclude
249 .iter()
250 .any(|excluded| resolves_to(excluded))
251 {
252 return false;
253 }
254 match &policy.steps {
255 None => true,
256 Some(list) => list.iter().any(|included| resolves_to(included)),
257 }
258}
259
260#[derive(Debug, Deserialize)]
262pub struct WorkerResultReq {
263 pub task_id: StepId,
266 pub value: Value,
268 #[serde(default = "default_ok_true")]
272 pub ok: bool,
273 #[serde(default)]
276 pub attempt: Option<u32>,
277}
278
279fn default_ok_true() -> bool {
280 true
281}
282
283pub async fn worker_result(
286 State(state): State<AppState>,
287 headers: HeaderMap,
288 Json(req): Json<WorkerResultReq>,
289) -> Result<StatusCode, ApiError> {
290 let token = decode_worker_bearer(&headers)?;
291 let task_id = req.task_id.clone();
292
293 let attempt = match req.attempt {
295 Some(n) => n,
296 None => state
297 .engine
298 .task_attempt(&task_id)
299 .await
300 .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?,
301 };
302
303 let event = OutputEvent::Final {
304 content: ContentRef::Inline {
305 value: req.value.clone(),
306 },
307 ok: req.ok,
308 };
309 map_completion_result(
314 state
315 .engine
316 .submit_output(&token, &task_id, attempt, event)
317 .await,
318 "submit_output",
319 )?;
320 state
321 .engine
322 .post_result(&token, &task_id, req.value)
323 .await
324 .map_err(|e| ApiError::engine(format!("post_result: {e}")))?;
325 Ok(StatusCode::NO_CONTENT)
326}
327
328const FILE_SENTINEL_PREFIX: &str = "@file:";
336
337const FILE_SENTINEL_MAX_BYTES: u64 = 2 * 1024 * 1024;
343
344const FILE_SENTINEL_ALLOW_KEY: &str = "allow_file_submit";
356
357async fn resolve_file_sentinel(
390 state: &AppState,
391 task_id: &StepId,
392 attempt: u32,
393 body_str: String,
394) -> Result<String, ApiError> {
395 let Some(rest) = body_str.strip_prefix(FILE_SENTINEL_PREFIX) else {
396 return Ok(body_str);
397 };
398 let path_str = rest.trim();
399 if path_str.is_empty() {
400 return Err(ApiError::bad_request(
401 "@file: sentinel: empty path".to_string(),
402 ));
403 }
404 if path_str.contains('\n') || path_str.contains('\r') {
405 return Err(ApiError::bad_request(
406 "@file: sentinel: path must be a single line".to_string(),
407 ));
408 }
409 let path = std::path::Path::new(path_str);
410 if !path.is_absolute() {
411 return Err(ApiError::bad_request(format!(
412 "@file: sentinel: path must be absolute (got {path_str:?})"
413 )));
414 }
415 let view = state
416 .engine
417 .agent_context_for(task_id, attempt)
418 .await
419 .ok_or_else(|| {
420 ApiError::bad_request(
421 "@file: sentinel: no AgentContextView for this task/attempt \
422 (spawn must run through AgentContextMiddleware to enable \
423 sentinel resolution)"
424 .to_string(),
425 )
426 })?;
427 if view.extra.get(FILE_SENTINEL_ALLOW_KEY) != Some(&Value::Bool(true)) {
431 return Err(ApiError::bad_request(format!(
432 "@file: sentinel: file submission is not allowed for this step \
433 (declare `{FILE_SENTINEL_ALLOW_KEY}: true` via `$step_meta` / \
434 `AgentMeta.ctx` / `Blueprint.metas`; strict boolean `true` \
435 required)"
436 )));
437 }
438 let work_dir = view.work_dir.ok_or_else(|| {
439 ApiError::bad_request("@file: sentinel: task has no resolved work_dir".to_string())
440 })?;
441 let work_dir_canon = tokio::fs::canonicalize(&work_dir).await.map_err(|e| {
442 ApiError::engine(format!(
443 "@file: sentinel: canonicalize work_dir {work_dir:?}: {e}"
444 ))
445 })?;
446 let path_canon = match tokio::fs::canonicalize(path).await {
447 Ok(p) => p,
448 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
449 return Err(ApiError::not_found(format!(
450 "@file: sentinel: file not found: {path_str}"
451 )));
452 }
453 Err(e) => {
454 return Err(ApiError::engine(format!(
455 "@file: sentinel: canonicalize {path_str:?}: {e}"
456 )));
457 }
458 };
459 if !path_canon.starts_with(&work_dir_canon) {
460 return Err(ApiError::bad_request(format!(
461 "@file: sentinel: path {} is not under work_dir {} (canonicalized: {} vs {})",
462 path_str,
463 work_dir,
464 path_canon.display(),
465 work_dir_canon.display(),
466 )));
467 }
468 let meta = tokio::fs::metadata(&path_canon)
469 .await
470 .map_err(|e| ApiError::engine(format!("@file: sentinel: metadata {path_str:?}: {e}")))?;
471 if meta.len() > FILE_SENTINEL_MAX_BYTES {
472 return Err(ApiError::payload_too_large(format!(
473 "@file: sentinel: file size {} exceeds limit {}",
474 meta.len(),
475 FILE_SENTINEL_MAX_BYTES
476 )));
477 }
478 let bytes = tokio::fs::read(&path_canon)
479 .await
480 .map_err(|e| ApiError::engine(format!("@file: sentinel: read {path_str:?}: {e}")))?;
481 Ok(String::from_utf8_lossy(&bytes).trim_end().to_string())
483}
484
485async fn check_verdict_contract(
506 state: &AppState,
507 task_id: &StepId,
508 channel: VerdictChannel,
509 value: &str,
510) -> Result<(), ApiError> {
511 let Some(contract) = state.engine.verdict_contract_for_task(task_id).await else {
512 return Ok(());
513 };
514 if contract.channel != channel {
515 return Ok(());
516 }
517 if contract.values.iter().any(|v| v == value) {
518 return Ok(());
519 }
520 Err(ApiError::unprocessable(format!(
521 "verdict contract violation: {value:?} is not a member of the declared values {:?}",
522 contract.values
523 )))
524}
525
526fn map_completion_result<T>(result: Result<T, EngineError>, context: &str) -> Result<T, ApiError> {
543 result.map_err(|e| match e {
544 EngineError::VerdictValueRejected { value, allowed } => ApiError::unprocessable(format!(
545 "verdict contract violation: {value:?} is not a member of the declared values {allowed:?}"
546 )),
547 EngineError::VerdictPartMissing { allowed } => ApiError::unprocessable(format!(
548 "verdict contract violation: no staged \"verdict\" part found for this attempt; declared values {allowed:?}"
549 )),
550 other => ApiError::engine(format!("{context}: {other}")),
551 })
552}
553
554#[derive(Debug, Deserialize, Default)]
576pub struct SubmitQuery {
577 #[serde(default)]
581 pub ok: Option<bool>,
582 #[serde(default)]
592 pub verdict: Option<String>,
593}
594
595fn resolve_submit_outcome(
607 verdict: Option<&str>,
608 ok: Option<bool>,
609) -> Result<SubmitOutcome, String> {
610 match verdict {
611 None => Ok(if ok.unwrap_or(true) {
612 SubmitOutcome::Pass
613 } else {
614 SubmitOutcome::Blocked
615 }),
616 Some(v) => match v {
617 "pass" => {
618 if ok == Some(false) {
619 Err(
620 "conflicting signal: verdict=pass with ok=false; drop one of them"
621 .to_string(),
622 )
623 } else {
624 Ok(SubmitOutcome::Pass)
625 }
626 }
627 "blocked" => {
628 if ok == Some(true) {
629 Err(
630 "conflicting signal: verdict=blocked with ok=true; drop one of them"
631 .to_string(),
632 )
633 } else {
634 Ok(SubmitOutcome::Blocked)
635 }
636 }
637 "skip" => {
638 if ok == Some(false) {
639 Err(
640 "conflicting signal: verdict=skip with ok=false; drop one of them"
641 .to_string(),
642 )
643 } else {
644 Ok(SubmitOutcome::Skip)
645 }
646 }
647 other => Err(format!(
648 "verdict must be one of: pass, blocked, skip (got {other:?})"
649 )),
650 },
651 }
652}
653
654pub async fn worker_submit(
660 State(state): State<AppState>,
661 headers: HeaderMap,
662 Query(q): Query<SubmitQuery>,
663 body: axum::body::Bytes,
664) -> Result<StatusCode, ApiError> {
665 let bearer = extract_bearer_raw(&headers)?;
668 let task_id = if let Some(handle) = parse_worker_handle(&bearer) {
669 state
670 .engine
671 .task_id_from_handle(handle)
672 .await
673 .map_err(map_handle_lookup_err)?
674 } else {
675 let token = CapToken::decode(bearer.trim())
676 .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
677 state
678 .engine
679 .task_id_from_token(&token)
680 .await
681 .map_err(|e| ApiError::engine(format!("task_id_from_token: {e}")))?
682 };
683 let attempt = state
684 .engine
685 .task_attempt(&task_id)
686 .await
687 .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?;
688 reject_if_run_terminal(&state, &task_id, attempt).await?;
691 let body_str = String::from_utf8_lossy(&body).trim_end().to_string();
696 let body_str = resolve_file_sentinel(&state, &task_id, attempt, body_str).await?;
699 let value = Value::String(body_str);
708
709 let outcome =
718 resolve_submit_outcome(q.verdict.as_deref(), q.ok).map_err(ApiError::bad_request)?;
719 let submit_result = state
720 .engine
721 .submit_worker_result_trusted(&task_id, attempt, value, outcome)
722 .await;
723 map_completion_result(submit_result, "submit_worker_result_trusted")?;
724 Ok(StatusCode::NO_CONTENT)
725}
726
727#[derive(Debug, Deserialize)]
729pub struct ArtifactQuery {
730 pub name: String,
737}
738
739pub async fn worker_artifact(
761 State(state): State<AppState>,
762 headers: HeaderMap,
763 Query(q): Query<ArtifactQuery>,
764 body: axum::body::Bytes,
765) -> Result<StatusCode, ApiError> {
766 let name = q.name.trim();
767 if name.is_empty() {
768 return Err(ApiError::bad_request("name must not be empty".into()));
769 }
770 let name = name.to_string();
771
772 let bearer = extract_bearer_raw(&headers)?;
773 let task_id = if let Some(handle) = parse_worker_handle(&bearer) {
774 state
775 .engine
776 .task_id_from_handle(handle)
777 .await
778 .map_err(map_handle_lookup_err)?
779 } else {
780 let token = CapToken::decode(bearer.trim())
781 .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
782 state
783 .engine
784 .task_id_from_token(&token)
785 .await
786 .map_err(|e| ApiError::engine(format!("task_id_from_token: {e}")))?
787 };
788 let attempt = state
789 .engine
790 .task_attempt(&task_id)
791 .await
792 .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?;
793 reject_if_run_terminal(&state, &task_id, attempt).await?;
796 let body_str = String::from_utf8_lossy(&body).trim_end().to_string();
797 let body_str = resolve_file_sentinel(&state, &task_id, attempt, body_str).await?;
799 if name == "verdict" {
805 check_verdict_contract(&state, &task_id, VerdictChannel::Part, &body_str).await?;
806 }
807 let value = Value::String(body_str);
808
809 state
810 .engine
811 .stage_worker_artifact_trusted(&task_id, attempt, name, value)
812 .await
813 .map_err(|e| ApiError::engine(format!("stage_worker_artifact_trusted: {e}")))?;
814 Ok(StatusCode::NO_CONTENT)
815}
816
817#[derive(Debug, Deserialize)]
819pub struct DegradationBody {
820 pub tool: String,
822 pub error: String,
824 pub fallback: String,
826 #[serde(default)]
828 pub note: Option<String>,
829}
830
831pub async fn worker_degradation(
859 State(state): State<AppState>,
860 headers: HeaderMap,
861 Json(body): Json<DegradationBody>,
862) -> Result<StatusCode, ApiError> {
863 let bearer = extract_bearer_raw(&headers)?;
864 let task_id = if let Some(handle) = parse_worker_handle(&bearer) {
865 state
866 .engine
867 .task_id_from_handle(handle)
868 .await
869 .map_err(map_handle_lookup_err)?
870 } else {
871 let token = CapToken::decode(bearer.trim())
872 .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
873 state
874 .engine
875 .task_id_from_token(&token)
876 .await
877 .map_err(|e| ApiError::engine(format!("task_id_from_token: {e}")))?
878 };
879 let attempt = state
880 .engine
881 .task_attempt(&task_id)
882 .await
883 .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?;
884 reject_if_run_terminal(&state, &task_id, attempt).await?;
887
888 let tid = task_id.clone();
893 let (run_id_str, agent) = match state
894 .engine
895 .with_state("worker_degradation_run_lookup", move |s| {
896 s.agent_ctx.get(&(tid, attempt)).and_then(|e| {
897 e.view
898 .run_id
899 .clone()
900 .map(|run_id| (run_id, e.view.agent.clone()))
901 })
902 })
903 .await
904 {
905 Ok(Some(pair)) => pair,
906 _ => {
907 tracing::warn!(%task_id, "worker_degradation: no run linkage for this task; entry dropped");
908 return Ok(StatusCode::NO_CONTENT);
909 }
910 };
911 let Ok(run_id) = RunId::parse(run_id_str) else {
912 tracing::warn!(%task_id, "worker_degradation: run_id failed to parse; entry dropped");
913 return Ok(StatusCode::NO_CONTENT);
914 };
915
916 let entry = DegradationEntry {
917 tool: body.tool,
918 error: body.error,
919 fallback: body.fallback,
920 note: body.note,
921 step_ref: Some(agent),
922 attempt: Some(attempt),
923 at: crate::tasks::now_secs(),
924 };
925 match state.run_store.append_degradation(&run_id, entry).await {
926 Ok(()) => Ok(StatusCode::NO_CONTENT),
927 Err(RunStoreError::NotFound(_)) => {
928 tracing::warn!(%task_id, %run_id, "worker_degradation: run not found in run_store; entry dropped");
929 Ok(StatusCode::NO_CONTENT)
930 }
931 Err(e) => Err(ApiError::engine(format!("append_degradation: {e}"))),
932 }
933}
934
935async fn reject_if_run_terminal(
952 state: &AppState,
953 task_id: &StepId,
954 attempt: u32,
955) -> Result<(), ApiError> {
956 let tid = task_id.clone();
957 let run_id_str = match state
958 .engine
959 .with_state("worker_terminal_run_guard", move |s| {
960 s.agent_ctx
961 .get(&(tid, attempt))
962 .and_then(|e| e.view.run_id.clone())
963 })
964 .await
965 {
966 Ok(Some(rid)) => rid,
967 _ => return Ok(()),
968 };
969 let Ok(run_id) = RunId::parse(run_id_str) else {
970 return Ok(());
971 };
972 let Ok(rec) = state.run_store.get(&run_id).await else {
973 return Ok(());
974 };
975 match rec.status {
976 RunStatus::Done | RunStatus::Failed | RunStatus::Interrupted => {
977 Err(ApiError::gone(format!(
978 "run {run_id} is already terminal ({:?}): this attempt's output cannot be \
979 delivered to a flow context; re-kick the task (POST /v1/tasks/:id/runs) and \
980 fetch a fresh prompt",
981 rec.status
982 )))
983 }
984 RunStatus::Pending | RunStatus::Running => Ok(()),
985 }
986}
987
988#[derive(Debug, Deserialize)]
993pub struct PromptSystemQuery {
994 pub task_id: StepId,
997 pub attempt: u32,
999}
1000
1001pub async fn worker_prompt_system(
1011 State(state): State<AppState>,
1012 headers: HeaderMap,
1013 Query(q): Query<PromptSystemQuery>,
1014) -> Result<impl axum::response::IntoResponse, ApiError> {
1015 let task_id = q.task_id;
1016 let attempt = q.attempt;
1017 let bearer = extract_bearer_raw(&headers)?;
1018 if let Some(handle) = parse_worker_handle(&bearer) {
1019 let resolved = state
1020 .engine
1021 .task_id_from_handle(handle)
1022 .await
1023 .map_err(map_handle_lookup_err)?;
1024 if resolved != task_id {
1025 return Err(ApiError::bad_request(format!(
1026 "handle {handle} is bound to task {resolved}, not {task_id}"
1027 )));
1028 }
1029 } else {
1030 let token = CapToken::decode(bearer.trim())
1031 .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
1032 state
1033 .engine
1034 .verify_token_for_task(&token, mlua_swarm::Verb::FetchPrompt, &task_id)
1035 .await
1036 .map_err(|e| ApiError::engine(format!("verify_token_for_task: {e}")))?;
1037 }
1038 let system = state
1039 .engine
1040 .raw_system_prompt(&task_id, attempt)
1041 .await
1042 .map_err(|e| ApiError::engine(format!("raw_system_prompt: {e}")))?
1043 .ok_or_else(|| {
1044 ApiError::not_found(format!(
1045 "no baked system prompt for task {task_id} attempt {attempt}"
1046 ))
1047 })?;
1048 Ok((
1049 [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
1050 system,
1051 ))
1052}
1053
1054#[derive(Debug, serde::Serialize)]
1056pub struct AgentRenderSizeResponse {
1057 pub agent: String,
1059 pub last_rendered_bytes: Option<usize>,
1063}
1064
1065pub async fn agent_render_size(
1075 State(state): State<AppState>,
1076 axum::extract::Path(name): axum::extract::Path<String>,
1077) -> Json<AgentRenderSizeResponse> {
1078 let last_rendered_bytes = state.engine.agent_last_rendered_size(&name).await;
1079 Json(AgentRenderSizeResponse {
1080 agent: name,
1081 last_rendered_bytes,
1082 })
1083}
1084
1085fn extract_bearer_raw(headers: &HeaderMap) -> Result<String, ApiError> {
1089 let v = headers
1090 .get(AUTHORIZATION)
1091 .ok_or_else(|| ApiError::bad_request("missing Authorization header".into()))?
1092 .to_str()
1093 .map_err(|_| ApiError::bad_request("invalid Authorization header encoding".into()))?;
1094 let s = v
1095 .strip_prefix("Bearer ")
1096 .ok_or_else(|| ApiError::bad_request("Authorization must be 'Bearer <token>'".into()))?
1097 .trim();
1098 if s.is_empty() {
1099 return Err(ApiError::bad_request("Bearer is empty".into()));
1100 }
1101 Ok(s.to_string())
1102}
1103
1104fn map_handle_lookup_err(e: EngineError) -> ApiError {
1117 match e {
1118 EngineError::TokenNotFound(_) => ApiError::gone(
1119 "worker handle is no longer valid (the engine's in-flight state was reset, \
1120 e.g. by a server restart): re-kick the task (POST /v1/tasks/:id/runs) and \
1121 fetch a fresh prompt/handle"
1122 .to_string(),
1123 ),
1124 other => ApiError::engine(format!("task_id_from_handle: {other}")),
1125 }
1126}
1127
1128fn parse_worker_handle(s: &str) -> Option<&str> {
1132 let s = s.trim();
1133 if s.starts_with("wh-")
1134 && s.len() >= 5
1135 && s.len() <= 64
1136 && s[3..].chars().all(|c| c.is_ascii_alphanumeric())
1137 {
1138 Some(s)
1139 } else {
1140 None
1141 }
1142}
1143
1144fn decode_worker_bearer(headers: &HeaderMap) -> Result<CapToken, ApiError> {
1148 let v = headers
1149 .get(AUTHORIZATION)
1150 .ok_or_else(|| ApiError::bad_request("missing Authorization header".into()))?
1151 .to_str()
1152 .map_err(|_| ApiError::bad_request("invalid Authorization header encoding".into()))?;
1153 let encoded = v
1154 .strip_prefix("Bearer ")
1155 .ok_or_else(|| ApiError::bad_request("Authorization must be 'Bearer <token>'".into()))?
1156 .trim();
1157 if encoded.is_empty() {
1158 return Err(ApiError::bad_request("Bearer token is empty".into()));
1159 }
1160 CapToken::decode(encoded).map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))
1161}
1162
1163#[cfg(test)]
1168mod tests {
1169 use super::*;
1170 use axum::response::IntoResponse;
1171 use mlua_swarm::core::agent_context::AgentContextView;
1172 use mlua_swarm::core::config::EngineCfg;
1173 use mlua_swarm::core::engine::Engine;
1174 use mlua_swarm::store::output::{InMemoryOutputStore, OutputStore};
1175 use mlua_swarm::store::run::{InMemoryRunStore, RunRecord, RunStatus, RunStore, StepEntry};
1176 use mlua_swarm::store::task::InMemoryTaskStore;
1177 use mlua_swarm::{RunId, StepId, TaskId};
1178 use serde_json::json;
1179 use std::collections::HashMap;
1180 use std::sync::Arc;
1181 use tokio::sync::Mutex;
1182
1183 fn test_state(data_store: Arc<dyn OutputStore>, run_store: Arc<dyn RunStore>) -> AppState {
1189 let engine = Engine::new(EngineCfg::default());
1190 let compiler = mlua_swarm::Compiler::new(crate::default_registry());
1191 let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
1192 AppState {
1193 engine,
1194 sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
1195 task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
1196 ws_operator_factory: None,
1197 data_store,
1198 operator_sessions: Arc::new(Mutex::new(HashMap::new())),
1199 roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
1200 task_store: Arc::new(InMemoryTaskStore::new()),
1201 run_store,
1202 replay_store: Arc::new(mlua_swarm::store::replay::InMemoryReplayStore::new()),
1203 base_url: None,
1204 sync_timeout_secs: 300,
1205 }
1206 }
1207
1208 #[test]
1212 fn resolve_submit_outcome_absent_verdict_preserves_pre_gh76_wire() {
1213 assert!(matches!(
1215 resolve_submit_outcome(None, None),
1216 Ok(SubmitOutcome::Pass)
1217 ));
1218 assert!(matches!(
1219 resolve_submit_outcome(None, Some(true)),
1220 Ok(SubmitOutcome::Pass)
1221 ));
1222 assert!(matches!(
1223 resolve_submit_outcome(None, Some(false)),
1224 Ok(SubmitOutcome::Blocked)
1225 ));
1226 }
1227
1228 #[test]
1229 fn resolve_submit_outcome_verdict_pass_and_blocked_match_ok_bool_or_default() {
1230 assert!(matches!(
1231 resolve_submit_outcome(Some("pass"), None),
1232 Ok(SubmitOutcome::Pass)
1233 ));
1234 assert!(matches!(
1235 resolve_submit_outcome(Some("pass"), Some(true)),
1236 Ok(SubmitOutcome::Pass)
1237 ));
1238 assert!(resolve_submit_outcome(Some("pass"), Some(false)).is_err());
1239
1240 assert!(matches!(
1241 resolve_submit_outcome(Some("blocked"), None),
1242 Ok(SubmitOutcome::Blocked)
1243 ));
1244 assert!(matches!(
1245 resolve_submit_outcome(Some("blocked"), Some(false)),
1246 Ok(SubmitOutcome::Blocked)
1247 ));
1248 assert!(resolve_submit_outcome(Some("blocked"), Some(true)).is_err());
1249 }
1250
1251 #[test]
1252 fn resolve_submit_outcome_verdict_skip_is_ok_true_only() {
1253 assert!(matches!(
1254 resolve_submit_outcome(Some("skip"), None),
1255 Ok(SubmitOutcome::Skip)
1256 ));
1257 assert!(matches!(
1258 resolve_submit_outcome(Some("skip"), Some(true)),
1259 Ok(SubmitOutcome::Skip)
1260 ));
1261 let err = resolve_submit_outcome(Some("skip"), Some(false))
1263 .expect_err("skip + ok=false must be a conflict");
1264 assert!(
1265 err.contains("conflict") || err.contains("conflicting"),
1266 "err should name the conflict: {err}"
1267 );
1268 }
1269
1270 #[test]
1271 fn resolve_submit_outcome_invalid_verdict_names_valid_set() {
1272 let err = resolve_submit_outcome(Some("bogus"), None)
1273 .expect_err("unknown verdict must be an error");
1274 assert!(
1275 err.contains("pass") && err.contains("blocked") && err.contains("skip"),
1276 "err should enumerate the valid tier set: {err}"
1277 );
1278 }
1279
1280 async fn append_final(
1281 data_store: &Arc<dyn OutputStore>,
1282 task_id: &str,
1283 producer: &str,
1284 value: Value,
1285 ) {
1286 data_store
1287 .append(
1288 task_id,
1289 1,
1290 producer,
1291 OutputEvent::Final {
1292 content: ContentRef::Inline { value },
1293 ok: true,
1294 },
1295 vec![],
1296 )
1297 .await
1298 .expect("append final");
1299 }
1300
1301 fn step_entry(step_id: &StepId, step_ref: &str) -> StepEntry {
1302 StepEntry {
1303 step_id: step_id.clone(),
1304 step_ref: Some(step_ref.to_string()),
1305 status: Some("passed".to_string()),
1306 binding_digest: None,
1307 at: 0,
1308 }
1309 }
1310
1311 fn run_record(task_id: &TaskId, run_id: &RunId, step_entries: Vec<StepEntry>) -> RunRecord {
1312 RunRecord {
1313 id: run_id.clone(),
1314 task_id: task_id.clone(),
1315 status: RunStatus::Running,
1316 step_entries,
1317 degradations: Vec::new(),
1318 operator_sid: None,
1319 result_ref: None,
1320 input_json: None,
1321 created_at: 0,
1322 updated_at: 0,
1323 }
1324 }
1325
1326 fn consumer_payload(consumer_step_id: &StepId, run_id: &RunId) -> WorkerPayload {
1327 WorkerPayload {
1328 task_id: consumer_step_id.clone(),
1329 attempt: 1,
1330 agent: "consumer".to_string(),
1331 system: None,
1332 prompt: String::new(),
1333 context: Some(AgentContextView {
1334 task_id: consumer_step_id.to_string(),
1335 agent: "consumer".to_string(),
1336 attempt: 1,
1337 run_id: Some(run_id.to_string()),
1338 ..Default::default()
1339 }),
1340 system_ref: None,
1341 }
1342 }
1343
1344 #[tokio::test]
1349 async fn context_policy_unspecified_yields_every_submitted_step() {
1350 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1351 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1352 let task_id = TaskId::new();
1353 let run_id = RunId::new();
1354 let planner_id = StepId::new();
1355 let coder_id = StepId::new();
1356
1357 append_final(
1358 &data_store,
1359 planner_id.as_str(),
1360 "planner",
1361 json!({"plan": "x"}),
1362 )
1363 .await;
1364 append_final(
1365 &data_store,
1366 coder_id.as_str(),
1367 "coder",
1368 json!({"code": "y"}),
1369 )
1370 .await;
1371 run_store
1372 .create(run_record(
1373 &task_id,
1374 &run_id,
1375 vec![
1376 step_entry(&planner_id, "planner"),
1377 step_entry(&coder_id, "coder"),
1378 ],
1379 ))
1380 .await
1381 .expect("create run");
1382
1383 let state = test_state(data_store, run_store);
1384 let consumer_id = StepId::new();
1385 let mut payload = consumer_payload(&consumer_id, &run_id);
1386 assemble_step_pointers(&state, &mut payload).await;
1387
1388 let names: Vec<&str> = payload
1389 .context
1390 .as_ref()
1391 .expect("context")
1392 .steps
1393 .iter()
1394 .map(|p| p.name.as_str())
1395 .collect();
1396 assert!(names.contains(&"planner"), "names: {names:?}");
1397 assert!(names.contains(&"coder"), "names: {names:?}");
1398 }
1399
1400 #[tokio::test]
1402 async fn context_policy_steps_include_list_filters_to_named_steps() {
1403 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1404 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1405 let task_id = TaskId::new();
1406 let run_id = RunId::new();
1407 let planner_id = StepId::new();
1408 let coder_id = StepId::new();
1409 append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1410 append_final(&data_store, coder_id.as_str(), "coder", json!("y")).await;
1411 run_store
1412 .create(run_record(
1413 &task_id,
1414 &run_id,
1415 vec![
1416 step_entry(&planner_id, "planner"),
1417 step_entry(&coder_id, "coder"),
1418 ],
1419 ))
1420 .await
1421 .expect("create run");
1422
1423 let state = test_state(data_store, run_store);
1424 let consumer_id = StepId::new();
1425 state
1426 .engine
1427 .with_state("test.seed_policy", {
1428 let consumer_id = consumer_id.clone();
1429 move |s| {
1430 s.agent_ctx.insert(
1431 (consumer_id, 1),
1432 mlua_swarm::core::state::AgentCtxEntry {
1433 policy: mlua_swarm_schema::ContextPolicy {
1434 steps: Some(vec!["planner".to_string()]),
1435 ..Default::default()
1436 },
1437 ..Default::default()
1438 },
1439 );
1440 }
1441 })
1442 .await
1443 .expect("seed policy");
1444
1445 let mut payload = consumer_payload(&consumer_id, &run_id);
1446 assemble_step_pointers(&state, &mut payload).await;
1447
1448 let names: Vec<&str> = payload
1449 .context
1450 .as_ref()
1451 .expect("context")
1452 .steps
1453 .iter()
1454 .map(|p| p.name.as_str())
1455 .collect();
1456 assert_eq!(names, vec!["planner"], "names: {names:?}");
1457 }
1458
1459 #[tokio::test]
1461 async fn context_policy_steps_empty_list_yields_no_pointers() {
1462 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1463 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1464 let task_id = TaskId::new();
1465 let run_id = RunId::new();
1466 let planner_id = StepId::new();
1467 append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1468 run_store
1469 .create(run_record(
1470 &task_id,
1471 &run_id,
1472 vec![step_entry(&planner_id, "planner")],
1473 ))
1474 .await
1475 .expect("create run");
1476
1477 let state = test_state(data_store, run_store);
1478 let consumer_id = StepId::new();
1479 state
1480 .engine
1481 .with_state("test.seed_policy", {
1482 let consumer_id = consumer_id.clone();
1483 move |s| {
1484 s.agent_ctx.insert(
1485 (consumer_id, 1),
1486 mlua_swarm::core::state::AgentCtxEntry {
1487 policy: mlua_swarm_schema::ContextPolicy {
1488 steps: Some(vec![]),
1489 ..Default::default()
1490 },
1491 ..Default::default()
1492 },
1493 );
1494 }
1495 })
1496 .await
1497 .expect("seed policy");
1498
1499 let mut payload = consumer_payload(&consumer_id, &run_id);
1500 assemble_step_pointers(&state, &mut payload).await;
1501
1502 assert!(payload.context.expect("context").steps.is_empty());
1503 }
1504
1505 #[tokio::test]
1507 async fn context_policy_steps_exclude_wins_over_steps() {
1508 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1509 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1510 let task_id = TaskId::new();
1511 let run_id = RunId::new();
1512 let planner_id = StepId::new();
1513 let coder_id = StepId::new();
1514 append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1515 append_final(&data_store, coder_id.as_str(), "coder", json!("y")).await;
1516 run_store
1517 .create(run_record(
1518 &task_id,
1519 &run_id,
1520 vec![
1521 step_entry(&planner_id, "planner"),
1522 step_entry(&coder_id, "coder"),
1523 ],
1524 ))
1525 .await
1526 .expect("create run");
1527
1528 let state = test_state(data_store, run_store);
1529 let consumer_id = StepId::new();
1530 state
1531 .engine
1532 .with_state("test.seed_policy", {
1533 let consumer_id = consumer_id.clone();
1534 move |s| {
1535 s.agent_ctx.insert(
1536 (consumer_id, 1),
1537 mlua_swarm::core::state::AgentCtxEntry {
1538 policy: mlua_swarm_schema::ContextPolicy {
1539 steps: Some(vec!["planner".to_string(), "coder".to_string()]),
1540 steps_exclude: vec!["planner".to_string()],
1541 ..Default::default()
1542 },
1543 ..Default::default()
1544 },
1545 );
1546 }
1547 })
1548 .await
1549 .expect("seed policy");
1550
1551 let mut payload = consumer_payload(&consumer_id, &run_id);
1552 assemble_step_pointers(&state, &mut payload).await;
1553
1554 let names: Vec<&str> = payload
1555 .context
1556 .as_ref()
1557 .expect("context")
1558 .steps
1559 .iter()
1560 .map(|p| p.name.as_str())
1561 .collect();
1562 assert_eq!(names, vec!["coder"], "names: {names:?}");
1563 }
1564
1565 #[tokio::test]
1574 async fn in_flight_step_output_is_visible_before_run_finalizes() {
1575 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1576 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1577 let task_id = TaskId::new();
1578 let run_id = RunId::new();
1579 let step1_id = StepId::new();
1580 append_final(
1581 &data_store,
1582 step1_id.as_str(),
1583 "step1",
1584 json!({"step1_out": "hi"}),
1585 )
1586 .await;
1587 let mut run = run_record(&task_id, &run_id, vec![step_entry(&step1_id, "step1")]);
1588 run.status = RunStatus::Running;
1589 run.result_ref = None; run_store.create(run).await.expect("create run");
1591
1592 let state = test_state(data_store, run_store);
1593 let consumer_id = StepId::new();
1594 let mut payload = consumer_payload(&consumer_id, &run_id);
1595 assemble_step_pointers(&state, &mut payload).await;
1596
1597 let steps = &payload.context.expect("context").steps;
1598 assert_eq!(steps.len(), 1);
1599 assert_eq!(steps[0].name, "step1");
1600 }
1601
1602 #[tokio::test]
1606 async fn self_agent_name_is_always_excluded() {
1607 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1608 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1609 let task_id = TaskId::new();
1610 let run_id = RunId::new();
1611 let planner_id = StepId::new();
1612 let consumer_prior_id = StepId::new();
1613 append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1614 append_final(
1615 &data_store,
1616 consumer_prior_id.as_str(),
1617 "consumer",
1618 json!("self"),
1619 )
1620 .await;
1621 run_store
1622 .create(run_record(
1623 &task_id,
1624 &run_id,
1625 vec![
1626 step_entry(&planner_id, "planner"),
1627 step_entry(&consumer_prior_id, "consumer"),
1628 ],
1629 ))
1630 .await
1631 .expect("create run");
1632
1633 let state = test_state(data_store, run_store);
1634 let consumer_id = StepId::new();
1635 let mut payload = consumer_payload(&consumer_id, &run_id);
1636 assemble_step_pointers(&state, &mut payload).await;
1637
1638 let names: Vec<&str> = payload
1639 .context
1640 .as_ref()
1641 .expect("context")
1642 .steps
1643 .iter()
1644 .map(|p| p.name.as_str())
1645 .collect();
1646 assert!(!names.contains(&"consumer"), "names: {names:?}");
1647 assert!(names.contains(&"planner"), "names: {names:?}");
1648 }
1649
1650 #[tokio::test]
1654 async fn step_pointer_serializes_with_no_preview_or_content_bytes() {
1655 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1656 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1657 let task_id = TaskId::new();
1658 let run_id = RunId::new();
1659 let planner_id = StepId::new();
1660 append_final(
1661 &data_store,
1662 planner_id.as_str(),
1663 "planner",
1664 json!({"plan": "do the thing, at length".repeat(50)}),
1665 )
1666 .await;
1667 run_store
1668 .create(run_record(
1669 &task_id,
1670 &run_id,
1671 vec![step_entry(&planner_id, "planner")],
1672 ))
1673 .await
1674 .expect("create run");
1675
1676 let state = test_state(data_store, run_store);
1677 let consumer_id = StepId::new();
1678 let mut payload = consumer_payload(&consumer_id, &run_id);
1679 assemble_step_pointers(&state, &mut payload).await;
1680
1681 let steps = &payload.context.expect("context").steps;
1682 assert_eq!(steps.len(), 1);
1683 let json_value = serde_json::to_value(&steps[0]).expect("serialize StepPointer");
1684 let obj = json_value.as_object().expect("object");
1685 for forbidden in ["preview", "content", "value", "bytes"] {
1686 assert!(
1687 !obj.contains_key(forbidden),
1688 "StepPointer must not carry a {forbidden:?} field: {obj:?}"
1689 );
1690 }
1691 assert!(obj.contains_key("name"));
1692 assert!(obj.contains_key("size_bytes"));
1693 assert!(obj.contains_key("content_url"));
1694 assert!(obj.contains_key("sha256"));
1695 }
1696
1697 fn declared_name_bp() -> mlua_swarm::blueprint::Blueprint {
1705 use mlua_flow_ir::{Expr, Node};
1706 use mlua_swarm::blueprint::{
1707 current_schema_version, AgentDef, AgentKind, AgentMeta, Blueprint, BlueprintMetadata,
1708 CompilerHints, CompilerStrategy,
1709 };
1710 Blueprint {
1711 schema_version: current_schema_version(),
1712 id: "worker-test-declared-name-bp".into(),
1713 flow: Node::Step {
1714 ref_: "planner".to_string(),
1715 in_: Expr::Path {
1716 at: "$.in".parse().expect("literal test path: $.in"),
1717 },
1718 out: Expr::Path {
1719 at: "$.plan".parse().expect("literal test path: $.plan"),
1720 },
1721 },
1722 agents: vec![AgentDef {
1723 name: "planner".to_string(),
1724 kind: AgentKind::RustFn,
1725 spec: json!({"fn_id": "planner"}),
1726 profile: None,
1727 meta: Some(AgentMeta {
1728 projection_name: Some("plan-out".to_string()),
1729 ..Default::default()
1730 }),
1731 runner: None,
1732 runner_ref: None,
1733 verdict: None,
1734 }],
1735 operators: vec![],
1736 metas: vec![],
1737 hints: CompilerHints::default(),
1738 strategy: CompilerStrategy::default(),
1739 metadata: BlueprintMetadata::default(),
1740 spawner_hints: Default::default(),
1741 default_agent_kind: AgentKind::Operator,
1742 default_operator_kind: None,
1743 default_init_ctx: None,
1744 default_agent_ctx: None,
1745 default_context_policy: None,
1746 projection_placement: None,
1747 audits: vec![],
1748 degradation_policy: None,
1749 runners: vec![],
1750 default_runner: None,
1751 check_policy: None,
1752 blueprint_ref_includes: Vec::new(),
1753 }
1754 }
1755
1756 #[tokio::test]
1762 async fn declared_projection_name_pointer_name_is_canonical_and_policy_matches_it() {
1763 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1764 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1765 let task_id = TaskId::new();
1766 let run_id = RunId::new();
1767 let planner_id = StepId::new();
1768
1769 append_final(
1772 &data_store,
1773 planner_id.as_str(),
1774 "plan-out",
1775 json!({"plan": "x"}),
1776 )
1777 .await;
1778 run_store
1779 .create(run_record(
1780 &task_id,
1781 &run_id,
1782 vec![step_entry(&planner_id, "planner")],
1783 ))
1784 .await
1785 .expect("create run");
1786
1787 let state = test_state(data_store, run_store);
1788
1789 let (naming, _warnings) =
1795 mlua_swarm::core::step_naming::StepNaming::from_blueprint(&declared_name_bp())
1796 .expect("no collision");
1797 let naming = Arc::new(naming);
1798 let consumer_id = StepId::new();
1799 state
1800 .engine
1801 .with_state("test.seed_step_naming", {
1802 let naming = naming.clone();
1803 let planner_id = planner_id.clone();
1804 let consumer_id = consumer_id.clone();
1805 move |s| {
1806 s.step_namings.insert(planner_id, naming.clone());
1807 s.step_namings.insert(consumer_id, naming);
1808 }
1809 })
1810 .await
1811 .expect("seed step naming");
1812 state
1813 .engine
1814 .with_state("test.seed_policy", {
1815 let consumer_id = consumer_id.clone();
1816 move |s| {
1817 s.agent_ctx.insert(
1818 (consumer_id, 1),
1819 mlua_swarm::core::state::AgentCtxEntry {
1820 policy: mlua_swarm_schema::ContextPolicy {
1821 steps: Some(vec!["plan-out".to_string()]),
1822 ..Default::default()
1823 },
1824 ..Default::default()
1825 },
1826 );
1827 }
1828 })
1829 .await
1830 .expect("seed policy");
1831
1832 let mut payload = consumer_payload(&consumer_id, &run_id);
1833 assemble_step_pointers(&state, &mut payload).await;
1834
1835 let steps = &payload.context.expect("context").steps;
1836 assert_eq!(steps.len(), 1, "steps: {steps:?}");
1837 assert_eq!(
1838 steps[0].name, "plan-out",
1839 "StepPointer.name must be the canonical name"
1840 );
1841 }
1842
1843 async fn seed_task_with_handle(
1853 state: &AppState,
1854 task_id: &StepId,
1855 agent: &str,
1856 attempt: u32,
1857 system: Option<String>,
1858 ) -> String {
1859 let handle = format!("wh-{}", mlua_swarm::types::secure_hex(4));
1860 let task_id = task_id.clone();
1861 let agent = agent.to_string();
1862 let handle_clone = handle.clone();
1863 state
1864 .engine
1865 .with_state("test.seed_task_with_handle", move |s| {
1866 let mut task = mlua_swarm::core::state::TaskState::new(
1867 task_id.clone(),
1868 mlua_swarm::core::state::TaskSpec {
1869 agent: agent.clone(),
1870 initial_directive: json!("x"),
1871 step_ctx: None,
1872 check_policy: None,
1873 },
1874 );
1875 task.attempt = attempt;
1876 s.tasks.insert(task_id.clone(), task);
1877 s.systems.insert((task_id.clone(), attempt), system);
1878 let token = CapToken {
1879 agent_id: agent,
1880 role: mlua_swarm::Role::Worker,
1881 scopes: vec!["*".to_string()],
1882 issued_at: 0,
1883 expire_at: u64::MAX,
1884 max_uses: None,
1885 nonce: format!("test-nonce-{task_id}"),
1886 sig_hex: String::new(),
1887 };
1888 let fp = token.fingerprint();
1889 s.tokens.insert(
1890 fp.clone(),
1891 mlua_swarm::core::state::CapTokenRecord {
1892 token,
1893 uses_left: None,
1894 revoked: false,
1895 task_id: Some(task_id),
1896 },
1897 );
1898 s.worker_handles.insert(handle_clone, fp);
1899 })
1900 .await
1901 .expect("seed_task_with_handle");
1902 handle
1903 }
1904
1905 fn bearer_headers(handle: &str) -> HeaderMap {
1906 let mut headers = HeaderMap::new();
1907 headers.insert(
1908 AUTHORIZATION,
1909 format!("Bearer {handle}").parse().expect("header value"),
1910 );
1911 headers
1912 }
1913
1914 #[tokio::test]
1918 async fn worker_prompt_system_returns_raw_bytes_for_baked_system() {
1919 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1920 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1921 let state = test_state(data_store, run_store);
1922 let task_id = StepId::new();
1923 let rendered = "# Hello\n\nThis is the baked system prompt.".to_string();
1924 let handle =
1925 seed_task_with_handle(&state, &task_id, "planner", 1, Some(rendered.clone())).await;
1926
1927 let resp = worker_prompt_system(
1928 State(state.clone()),
1929 bearer_headers(&handle),
1930 Query(PromptSystemQuery {
1931 task_id: task_id.clone(),
1932 attempt: 1,
1933 }),
1934 )
1935 .await
1936 .expect("worker_prompt_system")
1937 .into_response();
1938
1939 assert_eq!(resp.status(), StatusCode::OK);
1940 let content_type = resp
1941 .headers()
1942 .get(header::CONTENT_TYPE)
1943 .expect("content-type header")
1944 .to_str()
1945 .expect("ascii");
1946 assert_eq!(content_type, "text/plain; charset=utf-8");
1947 let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1948 .await
1949 .expect("body bytes");
1950 assert_eq!(body_bytes.as_ref(), rendered.as_bytes());
1951 }
1952
1953 #[tokio::test]
1956 async fn worker_prompt_system_404s_when_no_baked_system() {
1957 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1958 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1959 let state = test_state(data_store, run_store);
1960 let task_id = StepId::new();
1961 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
1962
1963 let result = worker_prompt_system(
1964 State(state.clone()),
1965 bearer_headers(&handle),
1966 Query(PromptSystemQuery {
1967 task_id: task_id.clone(),
1968 attempt: 1,
1969 }),
1970 )
1971 .await;
1972 let err = match result {
1973 Ok(_) => panic!("expected 404 ApiError, got Ok"),
1974 Err(e) => e,
1975 };
1976 assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND);
1977 }
1978
1979 #[tokio::test]
1982 async fn worker_prompt_system_rejects_handle_task_mismatch() {
1983 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1984 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1985 let state = test_state(data_store, run_store);
1986 let task_id = StepId::new();
1987 let other_task_id = StepId::new();
1988 let handle =
1989 seed_task_with_handle(&state, &task_id, "planner", 1, Some("x".to_string())).await;
1990
1991 let result = worker_prompt_system(
1992 State(state.clone()),
1993 bearer_headers(&handle),
1994 Query(PromptSystemQuery {
1995 task_id: other_task_id,
1996 attempt: 1,
1997 }),
1998 )
1999 .await;
2000 let err = match result {
2001 Ok(_) => panic!("expected 400 ApiError for task mismatch, got Ok"),
2002 Err(e) => e,
2003 };
2004 assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
2005 }
2006
2007 #[tokio::test]
2011 async fn agent_render_size_returns_null_for_unknown_agent() {
2012 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2013 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2014 let state = test_state(data_store, run_store);
2015
2016 let Json(body) = agent_render_size(
2017 State(state.clone()),
2018 axum::extract::Path("never-dispatched".to_string()),
2019 )
2020 .await;
2021 assert_eq!(body.agent, "never-dispatched");
2022 assert_eq!(body.last_rendered_bytes, None);
2023 }
2024
2025 #[tokio::test]
2028 async fn agent_render_size_reports_last_rendered_bytes() {
2029 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2030 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2031 let state = test_state(data_store, run_store);
2032 let task_id = StepId::new();
2033 state
2034 .engine
2035 .with_state("test.seed_agent_ctx_for_bake", {
2036 let task_id = task_id.clone();
2037 move |s| {
2038 s.tasks.insert(
2039 task_id.clone(),
2040 mlua_swarm::core::state::TaskState::new(
2041 task_id,
2042 mlua_swarm::core::state::TaskSpec {
2043 agent: "coder".to_string(),
2044 initial_directive: json!("x"),
2045 step_ctx: None,
2046 check_policy: None,
2047 },
2048 ),
2049 );
2050 }
2051 })
2052 .await
2053 .expect("seed task");
2054 state
2055 .engine
2056 .bake_worker_system_prompt(&task_id, 1, Some("z".repeat(42)))
2057 .await
2058 .expect("bake_worker_system_prompt");
2059
2060 let Json(body) = agent_render_size(
2061 State(state.clone()),
2062 axum::extract::Path("coder".to_string()),
2063 )
2064 .await;
2065 assert_eq!(body.agent, "coder");
2066 assert_eq!(body.last_rendered_bytes, Some(42));
2067 }
2068
2069 #[tokio::test]
2077 async fn worker_artifact_stages_and_204s_for_valid_request() {
2078 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2079 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2080 let state = test_state(data_store, run_store);
2081 let task_id = StepId::new();
2082 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2083
2084 let status = worker_artifact(
2085 State(state.clone()),
2086 bearer_headers(&handle),
2087 Query(ArtifactQuery {
2088 name: "summary".to_string(),
2089 }),
2090 axum::body::Bytes::from_static(b"hello artifact\n"),
2091 )
2092 .await
2093 .expect("worker_artifact");
2094 assert_eq!(status, StatusCode::NO_CONTENT);
2095
2096 let tail = state.engine.output_tail(&task_id, 1).await;
2097 assert_eq!(tail.len(), 1, "tail: {tail:?}");
2098 match &tail[0] {
2099 OutputEvent::Artifact { name, content } => {
2100 assert_eq!(name, "summary");
2101 match content {
2102 ContentRef::Inline { value } => {
2103 assert_eq!(value, &json!("hello artifact"));
2104 }
2105 other => panic!("expected Inline content, got {other:?}"),
2106 }
2107 }
2108 other => panic!("expected Artifact event, got {other:?}"),
2109 }
2110 }
2111
2112 #[tokio::test]
2119 async fn worker_artifact_rejects_blank_name() {
2120 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2121 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2122 let state = test_state(data_store, run_store);
2123 let task_id = StepId::new();
2124 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2125
2126 let result = worker_artifact(
2127 State(state.clone()),
2128 bearer_headers(&handle),
2129 Query(ArtifactQuery {
2130 name: " ".to_string(),
2131 }),
2132 axum::body::Bytes::from_static(b"x"),
2133 )
2134 .await;
2135 let err = match result {
2136 Ok(_) => panic!("expected 400 ApiError for blank name, got Ok"),
2137 Err(e) => e,
2138 };
2139 assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
2140
2141 assert!(state.engine.output_tail(&task_id, 1).await.is_empty());
2143 }
2144
2145 #[tokio::test]
2151 async fn worker_artifact_staging_same_name_twice_appends_both_events_in_order() {
2152 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2153 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2154 let state = test_state(data_store, run_store);
2155 let task_id = StepId::new();
2156 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2157
2158 for body in [b"first".as_slice(), b"second".as_slice()] {
2159 worker_artifact(
2160 State(state.clone()),
2161 bearer_headers(&handle),
2162 Query(ArtifactQuery {
2163 name: "a".to_string(),
2164 }),
2165 axum::body::Bytes::copy_from_slice(body),
2166 )
2167 .await
2168 .expect("worker_artifact");
2169 }
2170
2171 let tail = state.engine.output_tail(&task_id, 1).await;
2172 assert_eq!(tail.len(), 2, "tail: {tail:?}");
2173 let values: Vec<&str> = tail
2174 .iter()
2175 .map(|ev| match ev {
2176 OutputEvent::Artifact {
2177 content: ContentRef::Inline { value },
2178 ..
2179 } => value.as_str().expect("string value"),
2180 other => panic!("expected Artifact/Inline event, got {other:?}"),
2181 })
2182 .collect();
2183 assert_eq!(values, vec!["first", "second"]);
2184 }
2185
2186 async fn link_task_to_run(state: &AppState, task_id: &StepId, attempt: u32, run_id: &RunId) {
2194 let tid = task_id.clone();
2195 let rid_str = run_id.to_string();
2196 state
2197 .engine
2198 .with_state("test.link_task_to_run", move |s| {
2199 let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2200 entry.view.run_id = Some(rid_str);
2201 s.agent_ctx.insert((tid, attempt), entry);
2202 })
2203 .await
2204 .expect("link_task_to_run");
2205 }
2206
2207 #[tokio::test]
2212 async fn submit_and_artifact_against_terminal_run_return_410() {
2213 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2214 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2215 let state = test_state(data_store, run_store.clone());
2216 let task_id = StepId::new();
2217 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2218
2219 let owner_task = TaskId::new();
2220 let run_id = RunId::new();
2221 let mut rec = run_record(&owner_task, &run_id, vec![]);
2222 rec.status = RunStatus::Failed;
2223 run_store.create(rec).await.expect("run create");
2224 link_task_to_run(&state, &task_id, 1, &run_id).await;
2225
2226 let err = worker_submit(
2227 State(state.clone()),
2228 bearer_headers(&handle),
2229 Query(SubmitQuery {
2230 ok: None,
2231 verdict: None,
2232 }),
2233 axum::body::Bytes::from_static(b"LATE OUTPUT"),
2234 )
2235 .await
2236 .expect_err("a submit against a Failed run must be rejected");
2237 assert_eq!(err.status, StatusCode::GONE);
2238 assert!(
2239 err.message.contains(&run_id.to_string()),
2240 "the 410 must name the terminal run: {}",
2241 err.message
2242 );
2243
2244 let err = worker_artifact(
2245 State(state.clone()),
2246 bearer_headers(&handle),
2247 Query(ArtifactQuery {
2248 name: "part.md".to_string(),
2249 }),
2250 axum::body::Bytes::from_static(b"LATE PART"),
2251 )
2252 .await
2253 .expect_err("an artifact staged against a Failed run must be rejected");
2254 assert_eq!(err.status, StatusCode::GONE);
2255
2256 let tail = state.engine.output_tail(&task_id, 1).await;
2258 assert!(tail.is_empty(), "rejected submits must not land: {tail:?}");
2259 }
2260
2261 #[tokio::test]
2265 async fn terminal_run_guard_is_fail_open() {
2266 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2267 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2268 let state = test_state(data_store, run_store.clone());
2269 let task_id = StepId::new();
2270 seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2271
2272 reject_if_run_terminal(&state, &task_id, 1)
2274 .await
2275 .expect("no linkage must fail open");
2276
2277 let unknown_run = RunId::new();
2279 link_task_to_run(&state, &task_id, 1, &unknown_run).await;
2280 reject_if_run_terminal(&state, &task_id, 1)
2281 .await
2282 .expect("unknown run must fail open");
2283
2284 let owner_task = TaskId::new();
2286 let live_run = RunId::new();
2287 run_store
2288 .create(run_record(&owner_task, &live_run, vec![]))
2289 .await
2290 .expect("run create");
2291 link_task_to_run(&state, &task_id, 1, &live_run).await;
2292 reject_if_run_terminal(&state, &task_id, 1)
2293 .await
2294 .expect("a Running run must pass the guard");
2295 }
2296
2297 fn degradation_body(tool: &str, note: Option<&str>) -> DegradationBody {
2302 DegradationBody {
2303 tool: tool.to_string(),
2304 error: "boom".to_string(),
2305 fallback: "used cached value".to_string(),
2306 note: note.map(str::to_string),
2307 }
2308 }
2309
2310 async fn link_task_to_run_with_agent(
2316 state: &AppState,
2317 task_id: &StepId,
2318 attempt: u32,
2319 run_id: &RunId,
2320 agent: &str,
2321 ) {
2322 let tid = task_id.clone();
2323 let rid_str = run_id.to_string();
2324 let agent = agent.to_string();
2325 state
2326 .engine
2327 .with_state("test.link_task_to_run_with_agent", move |s| {
2328 let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2329 entry.view.run_id = Some(rid_str);
2330 entry.view.agent = agent;
2331 s.agent_ctx.insert((tid, attempt), entry);
2332 })
2333 .await
2334 .expect("link_task_to_run_with_agent");
2335 }
2336
2337 #[tokio::test]
2342 async fn worker_degradation_persists_entry_when_run_tracked() {
2343 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2344 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2345 let state = test_state(data_store, run_store.clone());
2346 let task_id = StepId::new();
2347 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2348
2349 let owner_task = TaskId::new();
2350 let run_id = RunId::new();
2351 run_store
2352 .create(run_record(&owner_task, &run_id, vec![]))
2353 .await
2354 .expect("run create");
2355 link_task_to_run_with_agent(&state, &task_id, 1, &run_id, "planner").await;
2356
2357 let status = worker_degradation(
2358 State(state.clone()),
2359 bearer_headers(&handle),
2360 Json(degradation_body("web_search", Some("rate limited"))),
2361 )
2362 .await
2363 .expect("worker_degradation");
2364 assert_eq!(status, StatusCode::NO_CONTENT);
2365
2366 let rec = run_store.get(&run_id).await.expect("run get");
2367 assert_eq!(
2368 rec.degradations.len(),
2369 1,
2370 "degradations: {:?}",
2371 rec.degradations
2372 );
2373 let entry = &rec.degradations[0];
2374 assert_eq!(entry.tool, "web_search");
2375 assert_eq!(entry.error, "boom");
2376 assert_eq!(entry.fallback, "used cached value");
2377 assert_eq!(entry.note.as_deref(), Some("rate limited"));
2378 assert_eq!(entry.step_ref.as_deref(), Some("planner"));
2379 assert_eq!(entry.attempt, Some(1));
2380 assert!(entry.at > 0, "at must be a real timestamp: {}", entry.at);
2381 }
2382
2383 #[tokio::test]
2385 async fn worker_degradation_appends_in_order() {
2386 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2387 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2388 let state = test_state(data_store, run_store.clone());
2389 let task_id = StepId::new();
2390 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2391
2392 let owner_task = TaskId::new();
2393 let run_id = RunId::new();
2394 run_store
2395 .create(run_record(&owner_task, &run_id, vec![]))
2396 .await
2397 .expect("run create");
2398 link_task_to_run(&state, &task_id, 1, &run_id).await;
2399
2400 for tool in ["first_tool", "second_tool"] {
2401 worker_degradation(
2402 State(state.clone()),
2403 bearer_headers(&handle),
2404 Json(degradation_body(tool, None)),
2405 )
2406 .await
2407 .expect("worker_degradation");
2408 }
2409
2410 let rec = run_store.get(&run_id).await.expect("run get");
2411 let tools: Vec<&str> = rec.degradations.iter().map(|e| e.tool.as_str()).collect();
2412 assert_eq!(tools, vec!["first_tool", "second_tool"]);
2413 }
2414
2415 #[tokio::test]
2419 async fn worker_degradation_silent_ok_when_no_run_tracked() {
2420 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2421 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2422 let state = test_state(data_store, run_store);
2423 let task_id = StepId::new();
2424 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2425
2426 let status = worker_degradation(
2427 State(state.clone()),
2428 bearer_headers(&handle),
2429 Json(degradation_body("some_tool", None)),
2430 )
2431 .await
2432 .expect("worker_degradation must not error on missing run linkage");
2433 assert_eq!(status, StatusCode::NO_CONTENT);
2434 }
2435
2436 #[tokio::test]
2439 async fn worker_degradation_rejects_terminal_run() {
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.clone());
2443 let task_id = StepId::new();
2444 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2445
2446 let owner_task = TaskId::new();
2447 let run_id = RunId::new();
2448 let mut rec = run_record(&owner_task, &run_id, vec![]);
2449 rec.status = RunStatus::Done;
2450 run_store.create(rec).await.expect("run create");
2451 link_task_to_run(&state, &task_id, 1, &run_id).await;
2452
2453 let err = worker_degradation(
2454 State(state.clone()),
2455 bearer_headers(&handle),
2456 Json(degradation_body("some_tool", None)),
2457 )
2458 .await
2459 .expect_err("a degradation against a Done run must be rejected");
2460 assert_eq!(err.status, StatusCode::GONE);
2461
2462 let rec = run_store.get(&run_id).await.expect("run get");
2463 assert!(
2464 rec.degradations.is_empty(),
2465 "rejected degradation must not land: {:?}",
2466 rec.degradations
2467 );
2468 }
2469
2470 async fn seed_work_dir(
2484 state: &AppState,
2485 task_id: &StepId,
2486 attempt: u32,
2487 work_dir: &str,
2488 allow_file_submit: Option<Value>,
2489 ) {
2490 let tid = task_id.clone();
2491 let work_dir = work_dir.to_string();
2492 state
2493 .engine
2494 .with_state("test.seed_work_dir", move |s| {
2495 let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2496 entry.view.work_dir = Some(work_dir);
2497 if let Some(v) = allow_file_submit {
2498 entry
2499 .view
2500 .extra
2501 .insert(FILE_SENTINEL_ALLOW_KEY.to_string(), v);
2502 }
2503 s.agent_ctx.insert((tid, attempt), entry);
2504 })
2505 .await
2506 .expect("seed_work_dir");
2507 }
2508
2509 #[tokio::test]
2513 async fn worker_submit_resolves_file_sentinel_under_work_dir() {
2514 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2515 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2516 let state = test_state(data_store.clone(), run_store);
2517 let task_id = StepId::new();
2518 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2519
2520 let tmp = tempfile::tempdir().expect("tempdir");
2521 let work_dir = tmp.path().to_path_buf();
2522 seed_work_dir(
2523 &state,
2524 &task_id,
2525 1,
2526 work_dir.to_str().expect("work_dir utf-8"),
2527 Some(Value::Bool(true)),
2528 )
2529 .await;
2530
2531 let payload_path = work_dir.join("scout.md");
2532 let payload = "## Context Package (broad)\n\nlarge body content\n";
2533 tokio::fs::write(&payload_path, payload)
2534 .await
2535 .expect("write payload");
2536 let body = format!(
2537 "@file:{}",
2538 payload_path.to_str().expect("payload path utf-8")
2539 );
2540
2541 let status = worker_submit(
2542 State(state.clone()),
2543 bearer_headers(&handle),
2544 Query(SubmitQuery {
2545 ok: None,
2546 verdict: None,
2547 }),
2548 axum::body::Bytes::from(body),
2549 )
2550 .await
2551 .expect("worker_submit sentinel");
2552 assert_eq!(status, StatusCode::NO_CONTENT);
2553
2554 let tid = task_id.clone();
2558 let value = state
2559 .engine
2560 .with_state("test.inspect_output_store", move |s| {
2561 s.output_store.get(&(tid.clone(), 1)).and_then(|evs| {
2562 evs.iter().find_map(|ev| match ev {
2563 OutputEvent::Final {
2564 content: ContentRef::Inline { value },
2565 ..
2566 } => Some(value.clone()),
2567 _ => None,
2568 })
2569 })
2570 })
2571 .await
2572 .expect("with_state")
2573 .expect("Final event present");
2574 assert_eq!(value, Value::String(payload.trim_end().to_string()));
2575 }
2576
2577 #[tokio::test]
2580 async fn worker_submit_passes_non_sentinel_body_unchanged() {
2581 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2582 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2583 let state = test_state(data_store.clone(), run_store);
2584 let task_id = StepId::new();
2585 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2586 let status = worker_submit(
2590 State(state.clone()),
2591 bearer_headers(&handle),
2592 Query(SubmitQuery {
2593 ok: None,
2594 verdict: None,
2595 }),
2596 axum::body::Bytes::from_static(b"DONE yes=1 maybe=0 no=0"),
2597 )
2598 .await
2599 .expect("worker_submit inline");
2600 assert_eq!(status, StatusCode::NO_CONTENT);
2601
2602 let tid = task_id.clone();
2603 let value = state
2604 .engine
2605 .with_state("test.inspect_output_store", move |s| {
2606 s.output_store.get(&(tid.clone(), 1)).and_then(|evs| {
2607 evs.iter().find_map(|ev| match ev {
2608 OutputEvent::Final {
2609 content: ContentRef::Inline { value },
2610 ..
2611 } => Some(value.clone()),
2612 _ => None,
2613 })
2614 })
2615 })
2616 .await
2617 .expect("with_state")
2618 .expect("Final event present");
2619 assert_eq!(value, Value::String("DONE yes=1 maybe=0 no=0".to_string()));
2620 }
2621
2622 #[tokio::test]
2627 async fn worker_submit_rejects_sentinel_path_outside_work_dir() {
2628 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2629 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2630 let state = test_state(data_store, run_store);
2631 let task_id = StepId::new();
2632 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2633
2634 let allowed = tempfile::tempdir().expect("allowed tempdir");
2635 let outside = tempfile::tempdir().expect("outside tempdir");
2636 seed_work_dir(
2637 &state,
2638 &task_id,
2639 1,
2640 allowed.path().to_str().expect("utf-8"),
2641 Some(Value::Bool(true)),
2642 )
2643 .await;
2644
2645 let outside_file = outside.path().join("leak.md");
2646 tokio::fs::write(&outside_file, b"outside content")
2647 .await
2648 .expect("write outside");
2649 let body = format!(
2650 "@file:{}",
2651 outside_file.to_str().expect("outside path utf-8")
2652 );
2653
2654 let err = worker_submit(
2655 State(state.clone()),
2656 bearer_headers(&handle),
2657 Query(SubmitQuery {
2658 ok: None,
2659 verdict: None,
2660 }),
2661 axum::body::Bytes::from(body),
2662 )
2663 .await
2664 .expect_err("outside-work_dir sentinel must be rejected");
2665 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2666 }
2667
2668 #[tokio::test]
2670 async fn worker_submit_rejects_sentinel_missing_file() {
2671 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2672 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2673 let state = test_state(data_store, run_store);
2674 let task_id = StepId::new();
2675 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2676
2677 let tmp = tempfile::tempdir().expect("tempdir");
2678 seed_work_dir(
2679 &state,
2680 &task_id,
2681 1,
2682 tmp.path().to_str().expect("utf-8"),
2683 Some(Value::Bool(true)),
2684 )
2685 .await;
2686 let missing = tmp.path().join("does-not-exist.md");
2687 let body = format!("@file:{}", missing.to_str().expect("utf-8"));
2688
2689 let err = worker_submit(
2690 State(state.clone()),
2691 bearer_headers(&handle),
2692 Query(SubmitQuery {
2693 ok: None,
2694 verdict: None,
2695 }),
2696 axum::body::Bytes::from(body),
2697 )
2698 .await
2699 .expect_err("missing-file sentinel must be rejected");
2700 assert_eq!(err.status, StatusCode::NOT_FOUND);
2701 }
2702
2703 #[tokio::test]
2705 async fn worker_submit_rejects_sentinel_relative_path() {
2706 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2707 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2708 let state = test_state(data_store, run_store);
2709 let task_id = StepId::new();
2710 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2711
2712 let err = worker_submit(
2713 State(state.clone()),
2714 bearer_headers(&handle),
2715 Query(SubmitQuery {
2716 ok: None,
2717 verdict: None,
2718 }),
2719 axum::body::Bytes::from_static(b"@file:relative/path.md"),
2720 )
2721 .await
2722 .expect_err("relative-path sentinel must be rejected");
2723 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2724 }
2725
2726 #[tokio::test]
2730 async fn worker_submit_rejects_sentinel_without_agent_context_view() {
2731 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2732 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2733 let state = test_state(data_store, run_store);
2734 let task_id = StepId::new();
2735 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2736 let err = worker_submit(
2739 State(state.clone()),
2740 bearer_headers(&handle),
2741 Query(SubmitQuery {
2742 ok: None,
2743 verdict: None,
2744 }),
2745 axum::body::Bytes::from_static(b"@file:/tmp/anywhere.md"),
2746 )
2747 .await
2748 .expect_err("missing AgentContextView must reject sentinel");
2749 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2750 }
2751
2752 #[tokio::test]
2756 async fn worker_artifact_resolves_file_sentinel_under_work_dir() {
2757 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2758 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2759 let state = test_state(data_store, run_store);
2760 let task_id = StepId::new();
2761 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2762
2763 let tmp = tempfile::tempdir().expect("tempdir");
2764 seed_work_dir(
2765 &state,
2766 &task_id,
2767 1,
2768 tmp.path().to_str().expect("utf-8"),
2769 Some(Value::Bool(true)),
2770 )
2771 .await;
2772
2773 let payload_path = tmp.path().join("part.md");
2774 let payload = "artifact part body\n";
2775 tokio::fs::write(&payload_path, payload)
2776 .await
2777 .expect("write payload");
2778 let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
2779
2780 let status = worker_artifact(
2781 State(state.clone()),
2782 bearer_headers(&handle),
2783 Query(ArtifactQuery {
2784 name: "scout".to_string(),
2785 }),
2786 axum::body::Bytes::from(body),
2787 )
2788 .await
2789 .expect("worker_artifact sentinel");
2790 assert_eq!(status, StatusCode::NO_CONTENT);
2791 }
2792
2793 #[tokio::test]
2798 async fn worker_submit_rejects_sentinel_without_allow_flag() {
2799 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2800 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2801 let state = test_state(data_store, run_store);
2802 let task_id = StepId::new();
2803 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2804
2805 let tmp = tempfile::tempdir().expect("tempdir");
2806 seed_work_dir(
2807 &state,
2808 &task_id,
2809 1,
2810 tmp.path().to_str().expect("utf-8"),
2811 None,
2812 )
2813 .await;
2814
2815 let payload_path = tmp.path().join("out.md");
2816 tokio::fs::write(&payload_path, b"resolvable body")
2817 .await
2818 .expect("write payload");
2819 let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
2820
2821 let err = worker_submit(
2822 State(state.clone()),
2823 bearer_headers(&handle),
2824 Query(SubmitQuery {
2825 ok: None,
2826 verdict: None,
2827 }),
2828 axum::body::Bytes::from(body),
2829 )
2830 .await
2831 .expect_err("missing opt-in must reject sentinel");
2832 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2833 assert!(
2834 err.message.contains("not allowed"),
2835 "rejection must name the opt-in guard, got: {}",
2836 err.message
2837 );
2838 }
2839
2840 #[tokio::test]
2843 async fn worker_submit_rejects_sentinel_with_non_true_allow_values() {
2844 for allow in [Value::Bool(false), Value::String("true".to_string())] {
2845 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2846 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2847 let state = test_state(data_store, run_store);
2848 let task_id = StepId::new();
2849 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2850
2851 let tmp = tempfile::tempdir().expect("tempdir");
2852 seed_work_dir(
2853 &state,
2854 &task_id,
2855 1,
2856 tmp.path().to_str().expect("utf-8"),
2857 Some(allow.clone()),
2858 )
2859 .await;
2860
2861 let payload_path = tmp.path().join("out.md");
2862 tokio::fs::write(&payload_path, b"resolvable body")
2863 .await
2864 .expect("write payload");
2865 let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
2866
2867 let err = worker_submit(
2868 State(state.clone()),
2869 bearer_headers(&handle),
2870 Query(SubmitQuery {
2871 ok: None,
2872 verdict: None,
2873 }),
2874 axum::body::Bytes::from(body),
2875 )
2876 .await
2877 .expect_err("non-true opt-in value must reject sentinel");
2878 assert_eq!(err.status, StatusCode::BAD_REQUEST, "value: {allow:?}");
2879 }
2880 }
2881
2882 fn body_verdict_contract(values: &[&str]) -> mlua_swarm_schema::VerdictContract {
2893 mlua_swarm_schema::VerdictContract {
2894 channel: VerdictChannel::Body,
2895 values: values.iter().map(|v| v.to_string()).collect(),
2896 }
2897 }
2898
2899 fn part_verdict_contract(values: &[&str]) -> mlua_swarm_schema::VerdictContract {
2900 mlua_swarm_schema::VerdictContract {
2901 channel: VerdictChannel::Part,
2902 values: values.iter().map(|v| v.to_string()).collect(),
2903 }
2904 }
2905
2906 #[tokio::test]
2909 async fn worker_submit_rejects_body_outside_contract_values_with_422() {
2910 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2911 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2912 let state = test_state(data_store, run_store);
2913 let task_id = StepId::new();
2914 let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
2915 state.engine.register_verdict_contracts(HashMap::from([(
2916 "gate".to_string(),
2917 body_verdict_contract(&["PASS", "BLOCKED"]),
2918 )]));
2919
2920 let err = worker_submit(
2921 State(state.clone()),
2922 bearer_headers(&handle),
2923 Query(SubmitQuery {
2924 ok: None,
2925 verdict: None,
2926 }),
2927 axum::body::Bytes::from("UNKNOWN"),
2928 )
2929 .await
2930 .expect_err("value outside declared values must reject");
2931 assert_eq!(err.status, StatusCode::UNPROCESSABLE_ENTITY);
2932 assert!(
2933 err.message.contains("PASS") && err.message.contains("BLOCKED"),
2934 "rejection must echo the declared values, got: {}",
2935 err.message
2936 );
2937 }
2938
2939 #[tokio::test]
2942 async fn worker_submit_accepts_body_inside_contract_values() {
2943 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2944 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2945 let state = test_state(data_store, run_store);
2946 let task_id = StepId::new();
2947 let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
2948 state.engine.register_verdict_contracts(HashMap::from([(
2949 "gate".to_string(),
2950 body_verdict_contract(&["PASS", "BLOCKED"]),
2951 )]));
2952
2953 let status = worker_submit(
2954 State(state.clone()),
2955 bearer_headers(&handle),
2956 Query(SubmitQuery {
2957 ok: None,
2958 verdict: None,
2959 }),
2960 axum::body::Bytes::from("PASS"),
2961 )
2962 .await
2963 .expect("value inside declared values must succeed");
2964 assert_eq!(status, StatusCode::NO_CONTENT);
2965 }
2966
2967 #[tokio::test]
2971 async fn worker_submit_without_a_declared_contract_is_unaffected() {
2972 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2973 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2974 let state = test_state(data_store, run_store);
2975 let task_id = StepId::new();
2976 let handle = seed_task_with_handle(&state, &task_id, "undeclared-agent", 1, None).await;
2978
2979 let status = worker_submit(
2980 State(state.clone()),
2981 bearer_headers(&handle),
2982 Query(SubmitQuery {
2983 ok: None,
2984 verdict: None,
2985 }),
2986 axum::body::Bytes::from("anything at all, no contract to violate"),
2987 )
2988 .await
2989 .expect("no contract declared must never reject");
2990 assert_eq!(status, StatusCode::NO_CONTENT);
2991 }
2992
2993 #[tokio::test]
2996 async fn worker_artifact_verdict_part_rejects_value_outside_contract_with_422() {
2997 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2998 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2999 let state = test_state(data_store, run_store);
3000 let task_id = StepId::new();
3001 let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
3002 state.engine.register_verdict_contracts(HashMap::from([(
3003 "gate".to_string(),
3004 part_verdict_contract(&["PASS", "BLOCKED"]),
3005 )]));
3006
3007 let err = worker_artifact(
3008 State(state.clone()),
3009 bearer_headers(&handle),
3010 Query(ArtifactQuery {
3011 name: "verdict".to_string(),
3012 }),
3013 axum::body::Bytes::from("UNKNOWN"),
3014 )
3015 .await
3016 .expect_err("value outside declared values must reject");
3017 assert_eq!(err.status, StatusCode::UNPROCESSABLE_ENTITY);
3018 }
3019
3020 #[tokio::test]
3024 async fn worker_artifact_non_verdict_part_skips_the_gate() {
3025 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3026 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3027 let state = test_state(data_store, run_store);
3028 let task_id = StepId::new();
3029 let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
3030 state.engine.register_verdict_contracts(HashMap::from([(
3031 "gate".to_string(),
3032 part_verdict_contract(&["PASS", "BLOCKED"]),
3033 )]));
3034
3035 let status = worker_artifact(
3036 State(state.clone()),
3037 bearer_headers(&handle),
3038 Query(ArtifactQuery {
3039 name: "notes".to_string(),
3040 }),
3041 axum::body::Bytes::from("anything at all"),
3042 )
3043 .await
3044 .expect("non-verdict part name must never be gated");
3045 assert_eq!(status, StatusCode::NO_CONTENT);
3046 }
3047}