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 subprocesses: vec![],
1752 check_policy: None,
1753 blueprint_ref_includes: Vec::new(),
1754 }
1755 }
1756
1757 #[tokio::test]
1763 async fn declared_projection_name_pointer_name_is_canonical_and_policy_matches_it() {
1764 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1765 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1766 let task_id = TaskId::new();
1767 let run_id = RunId::new();
1768 let planner_id = StepId::new();
1769
1770 append_final(
1773 &data_store,
1774 planner_id.as_str(),
1775 "plan-out",
1776 json!({"plan": "x"}),
1777 )
1778 .await;
1779 run_store
1780 .create(run_record(
1781 &task_id,
1782 &run_id,
1783 vec![step_entry(&planner_id, "planner")],
1784 ))
1785 .await
1786 .expect("create run");
1787
1788 let state = test_state(data_store, run_store);
1789
1790 let (naming, _warnings) =
1796 mlua_swarm::core::step_naming::StepNaming::from_blueprint(&declared_name_bp())
1797 .expect("no collision");
1798 let naming = Arc::new(naming);
1799 let consumer_id = StepId::new();
1800 state
1801 .engine
1802 .with_state("test.seed_step_naming", {
1803 let naming = naming.clone();
1804 let planner_id = planner_id.clone();
1805 let consumer_id = consumer_id.clone();
1806 move |s| {
1807 s.step_namings.insert(planner_id, naming.clone());
1808 s.step_namings.insert(consumer_id, naming);
1809 }
1810 })
1811 .await
1812 .expect("seed step naming");
1813 state
1814 .engine
1815 .with_state("test.seed_policy", {
1816 let consumer_id = consumer_id.clone();
1817 move |s| {
1818 s.agent_ctx.insert(
1819 (consumer_id, 1),
1820 mlua_swarm::core::state::AgentCtxEntry {
1821 policy: mlua_swarm_schema::ContextPolicy {
1822 steps: Some(vec!["plan-out".to_string()]),
1823 ..Default::default()
1824 },
1825 ..Default::default()
1826 },
1827 );
1828 }
1829 })
1830 .await
1831 .expect("seed policy");
1832
1833 let mut payload = consumer_payload(&consumer_id, &run_id);
1834 assemble_step_pointers(&state, &mut payload).await;
1835
1836 let steps = &payload.context.expect("context").steps;
1837 assert_eq!(steps.len(), 1, "steps: {steps:?}");
1838 assert_eq!(
1839 steps[0].name, "plan-out",
1840 "StepPointer.name must be the canonical name"
1841 );
1842 }
1843
1844 async fn seed_task_with_handle(
1854 state: &AppState,
1855 task_id: &StepId,
1856 agent: &str,
1857 attempt: u32,
1858 system: Option<String>,
1859 ) -> String {
1860 let handle = format!("wh-{}", mlua_swarm::types::secure_hex(4));
1861 let task_id = task_id.clone();
1862 let agent = agent.to_string();
1863 let handle_clone = handle.clone();
1864 state
1865 .engine
1866 .with_state("test.seed_task_with_handle", move |s| {
1867 let mut task = mlua_swarm::core::state::TaskState::new(
1868 task_id.clone(),
1869 mlua_swarm::core::state::TaskSpec {
1870 agent: agent.clone(),
1871 initial_directive: json!("x"),
1872 step_ctx: None,
1873 check_policy: None,
1874 },
1875 );
1876 task.attempt = attempt;
1877 s.tasks.insert(task_id.clone(), task);
1878 s.systems.insert((task_id.clone(), attempt), system);
1879 let token = CapToken {
1880 agent_id: agent,
1881 role: mlua_swarm::Role::Worker,
1882 scopes: vec!["*".to_string()],
1883 issued_at: 0,
1884 expire_at: u64::MAX,
1885 max_uses: None,
1886 nonce: format!("test-nonce-{task_id}"),
1887 sig_hex: String::new(),
1888 };
1889 let fp = token.fingerprint();
1890 s.tokens.insert(
1891 fp.clone(),
1892 mlua_swarm::core::state::CapTokenRecord {
1893 token,
1894 uses_left: None,
1895 revoked: false,
1896 task_id: Some(task_id),
1897 },
1898 );
1899 s.worker_handles.insert(handle_clone, fp);
1900 })
1901 .await
1902 .expect("seed_task_with_handle");
1903 handle
1904 }
1905
1906 fn bearer_headers(handle: &str) -> HeaderMap {
1907 let mut headers = HeaderMap::new();
1908 headers.insert(
1909 AUTHORIZATION,
1910 format!("Bearer {handle}").parse().expect("header value"),
1911 );
1912 headers
1913 }
1914
1915 #[tokio::test]
1919 async fn worker_prompt_system_returns_raw_bytes_for_baked_system() {
1920 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1921 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1922 let state = test_state(data_store, run_store);
1923 let task_id = StepId::new();
1924 let rendered = "# Hello\n\nThis is the baked system prompt.".to_string();
1925 let handle =
1926 seed_task_with_handle(&state, &task_id, "planner", 1, Some(rendered.clone())).await;
1927
1928 let resp = worker_prompt_system(
1929 State(state.clone()),
1930 bearer_headers(&handle),
1931 Query(PromptSystemQuery {
1932 task_id: task_id.clone(),
1933 attempt: 1,
1934 }),
1935 )
1936 .await
1937 .expect("worker_prompt_system")
1938 .into_response();
1939
1940 assert_eq!(resp.status(), StatusCode::OK);
1941 let content_type = resp
1942 .headers()
1943 .get(header::CONTENT_TYPE)
1944 .expect("content-type header")
1945 .to_str()
1946 .expect("ascii");
1947 assert_eq!(content_type, "text/plain; charset=utf-8");
1948 let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1949 .await
1950 .expect("body bytes");
1951 assert_eq!(body_bytes.as_ref(), rendered.as_bytes());
1952 }
1953
1954 #[tokio::test]
1957 async fn worker_prompt_system_404s_when_no_baked_system() {
1958 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1959 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1960 let state = test_state(data_store, run_store);
1961 let task_id = StepId::new();
1962 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
1963
1964 let result = worker_prompt_system(
1965 State(state.clone()),
1966 bearer_headers(&handle),
1967 Query(PromptSystemQuery {
1968 task_id: task_id.clone(),
1969 attempt: 1,
1970 }),
1971 )
1972 .await;
1973 let err = match result {
1974 Ok(_) => panic!("expected 404 ApiError, got Ok"),
1975 Err(e) => e,
1976 };
1977 assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND);
1978 }
1979
1980 #[tokio::test]
1983 async fn worker_prompt_system_rejects_handle_task_mismatch() {
1984 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1985 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1986 let state = test_state(data_store, run_store);
1987 let task_id = StepId::new();
1988 let other_task_id = StepId::new();
1989 let handle =
1990 seed_task_with_handle(&state, &task_id, "planner", 1, Some("x".to_string())).await;
1991
1992 let result = worker_prompt_system(
1993 State(state.clone()),
1994 bearer_headers(&handle),
1995 Query(PromptSystemQuery {
1996 task_id: other_task_id,
1997 attempt: 1,
1998 }),
1999 )
2000 .await;
2001 let err = match result {
2002 Ok(_) => panic!("expected 400 ApiError for task mismatch, got Ok"),
2003 Err(e) => e,
2004 };
2005 assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
2006 }
2007
2008 #[tokio::test]
2012 async fn agent_render_size_returns_null_for_unknown_agent() {
2013 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2014 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2015 let state = test_state(data_store, run_store);
2016
2017 let Json(body) = agent_render_size(
2018 State(state.clone()),
2019 axum::extract::Path("never-dispatched".to_string()),
2020 )
2021 .await;
2022 assert_eq!(body.agent, "never-dispatched");
2023 assert_eq!(body.last_rendered_bytes, None);
2024 }
2025
2026 #[tokio::test]
2029 async fn agent_render_size_reports_last_rendered_bytes() {
2030 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2031 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2032 let state = test_state(data_store, run_store);
2033 let task_id = StepId::new();
2034 state
2035 .engine
2036 .with_state("test.seed_agent_ctx_for_bake", {
2037 let task_id = task_id.clone();
2038 move |s| {
2039 s.tasks.insert(
2040 task_id.clone(),
2041 mlua_swarm::core::state::TaskState::new(
2042 task_id,
2043 mlua_swarm::core::state::TaskSpec {
2044 agent: "coder".to_string(),
2045 initial_directive: json!("x"),
2046 step_ctx: None,
2047 check_policy: None,
2048 },
2049 ),
2050 );
2051 }
2052 })
2053 .await
2054 .expect("seed task");
2055 state
2056 .engine
2057 .bake_worker_system_prompt(&task_id, 1, Some("z".repeat(42)))
2058 .await
2059 .expect("bake_worker_system_prompt");
2060
2061 let Json(body) = agent_render_size(
2062 State(state.clone()),
2063 axum::extract::Path("coder".to_string()),
2064 )
2065 .await;
2066 assert_eq!(body.agent, "coder");
2067 assert_eq!(body.last_rendered_bytes, Some(42));
2068 }
2069
2070 #[tokio::test]
2078 async fn worker_artifact_stages_and_204s_for_valid_request() {
2079 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2080 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2081 let state = test_state(data_store, run_store);
2082 let task_id = StepId::new();
2083 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2084
2085 let status = worker_artifact(
2086 State(state.clone()),
2087 bearer_headers(&handle),
2088 Query(ArtifactQuery {
2089 name: "summary".to_string(),
2090 }),
2091 axum::body::Bytes::from_static(b"hello artifact\n"),
2092 )
2093 .await
2094 .expect("worker_artifact");
2095 assert_eq!(status, StatusCode::NO_CONTENT);
2096
2097 let tail = state.engine.output_tail(&task_id, 1).await;
2098 assert_eq!(tail.len(), 1, "tail: {tail:?}");
2099 match &tail[0] {
2100 OutputEvent::Artifact { name, content } => {
2101 assert_eq!(name, "summary");
2102 match content {
2103 ContentRef::Inline { value } => {
2104 assert_eq!(value, &json!("hello artifact"));
2105 }
2106 other => panic!("expected Inline content, got {other:?}"),
2107 }
2108 }
2109 other => panic!("expected Artifact event, got {other:?}"),
2110 }
2111 }
2112
2113 #[tokio::test]
2120 async fn worker_artifact_rejects_blank_name() {
2121 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2122 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2123 let state = test_state(data_store, run_store);
2124 let task_id = StepId::new();
2125 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2126
2127 let result = worker_artifact(
2128 State(state.clone()),
2129 bearer_headers(&handle),
2130 Query(ArtifactQuery {
2131 name: " ".to_string(),
2132 }),
2133 axum::body::Bytes::from_static(b"x"),
2134 )
2135 .await;
2136 let err = match result {
2137 Ok(_) => panic!("expected 400 ApiError for blank name, got Ok"),
2138 Err(e) => e,
2139 };
2140 assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
2141
2142 assert!(state.engine.output_tail(&task_id, 1).await.is_empty());
2144 }
2145
2146 #[tokio::test]
2152 async fn worker_artifact_staging_same_name_twice_appends_both_events_in_order() {
2153 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2154 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2155 let state = test_state(data_store, run_store);
2156 let task_id = StepId::new();
2157 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2158
2159 for body in [b"first".as_slice(), b"second".as_slice()] {
2160 worker_artifact(
2161 State(state.clone()),
2162 bearer_headers(&handle),
2163 Query(ArtifactQuery {
2164 name: "a".to_string(),
2165 }),
2166 axum::body::Bytes::copy_from_slice(body),
2167 )
2168 .await
2169 .expect("worker_artifact");
2170 }
2171
2172 let tail = state.engine.output_tail(&task_id, 1).await;
2173 assert_eq!(tail.len(), 2, "tail: {tail:?}");
2174 let values: Vec<&str> = tail
2175 .iter()
2176 .map(|ev| match ev {
2177 OutputEvent::Artifact {
2178 content: ContentRef::Inline { value },
2179 ..
2180 } => value.as_str().expect("string value"),
2181 other => panic!("expected Artifact/Inline event, got {other:?}"),
2182 })
2183 .collect();
2184 assert_eq!(values, vec!["first", "second"]);
2185 }
2186
2187 async fn link_task_to_run(state: &AppState, task_id: &StepId, attempt: u32, run_id: &RunId) {
2195 let tid = task_id.clone();
2196 let rid_str = run_id.to_string();
2197 state
2198 .engine
2199 .with_state("test.link_task_to_run", move |s| {
2200 let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2201 entry.view.run_id = Some(rid_str);
2202 s.agent_ctx.insert((tid, attempt), entry);
2203 })
2204 .await
2205 .expect("link_task_to_run");
2206 }
2207
2208 #[tokio::test]
2213 async fn submit_and_artifact_against_terminal_run_return_410() {
2214 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2215 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2216 let state = test_state(data_store, run_store.clone());
2217 let task_id = StepId::new();
2218 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2219
2220 let owner_task = TaskId::new();
2221 let run_id = RunId::new();
2222 let mut rec = run_record(&owner_task, &run_id, vec![]);
2223 rec.status = RunStatus::Failed;
2224 run_store.create(rec).await.expect("run create");
2225 link_task_to_run(&state, &task_id, 1, &run_id).await;
2226
2227 let err = worker_submit(
2228 State(state.clone()),
2229 bearer_headers(&handle),
2230 Query(SubmitQuery {
2231 ok: None,
2232 verdict: None,
2233 }),
2234 axum::body::Bytes::from_static(b"LATE OUTPUT"),
2235 )
2236 .await
2237 .expect_err("a submit against a Failed run must be rejected");
2238 assert_eq!(err.status, StatusCode::GONE);
2239 assert!(
2240 err.message.contains(&run_id.to_string()),
2241 "the 410 must name the terminal run: {}",
2242 err.message
2243 );
2244
2245 let err = worker_artifact(
2246 State(state.clone()),
2247 bearer_headers(&handle),
2248 Query(ArtifactQuery {
2249 name: "part.md".to_string(),
2250 }),
2251 axum::body::Bytes::from_static(b"LATE PART"),
2252 )
2253 .await
2254 .expect_err("an artifact staged against a Failed run must be rejected");
2255 assert_eq!(err.status, StatusCode::GONE);
2256
2257 let tail = state.engine.output_tail(&task_id, 1).await;
2259 assert!(tail.is_empty(), "rejected submits must not land: {tail:?}");
2260 }
2261
2262 #[tokio::test]
2266 async fn terminal_run_guard_is_fail_open() {
2267 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2268 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2269 let state = test_state(data_store, run_store.clone());
2270 let task_id = StepId::new();
2271 seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2272
2273 reject_if_run_terminal(&state, &task_id, 1)
2275 .await
2276 .expect("no linkage must fail open");
2277
2278 let unknown_run = RunId::new();
2280 link_task_to_run(&state, &task_id, 1, &unknown_run).await;
2281 reject_if_run_terminal(&state, &task_id, 1)
2282 .await
2283 .expect("unknown run must fail open");
2284
2285 let owner_task = TaskId::new();
2287 let live_run = RunId::new();
2288 run_store
2289 .create(run_record(&owner_task, &live_run, vec![]))
2290 .await
2291 .expect("run create");
2292 link_task_to_run(&state, &task_id, 1, &live_run).await;
2293 reject_if_run_terminal(&state, &task_id, 1)
2294 .await
2295 .expect("a Running run must pass the guard");
2296 }
2297
2298 fn degradation_body(tool: &str, note: Option<&str>) -> DegradationBody {
2303 DegradationBody {
2304 tool: tool.to_string(),
2305 error: "boom".to_string(),
2306 fallback: "used cached value".to_string(),
2307 note: note.map(str::to_string),
2308 }
2309 }
2310
2311 async fn link_task_to_run_with_agent(
2317 state: &AppState,
2318 task_id: &StepId,
2319 attempt: u32,
2320 run_id: &RunId,
2321 agent: &str,
2322 ) {
2323 let tid = task_id.clone();
2324 let rid_str = run_id.to_string();
2325 let agent = agent.to_string();
2326 state
2327 .engine
2328 .with_state("test.link_task_to_run_with_agent", move |s| {
2329 let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2330 entry.view.run_id = Some(rid_str);
2331 entry.view.agent = agent;
2332 s.agent_ctx.insert((tid, attempt), entry);
2333 })
2334 .await
2335 .expect("link_task_to_run_with_agent");
2336 }
2337
2338 #[tokio::test]
2343 async fn worker_degradation_persists_entry_when_run_tracked() {
2344 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2345 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2346 let state = test_state(data_store, run_store.clone());
2347 let task_id = StepId::new();
2348 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2349
2350 let owner_task = TaskId::new();
2351 let run_id = RunId::new();
2352 run_store
2353 .create(run_record(&owner_task, &run_id, vec![]))
2354 .await
2355 .expect("run create");
2356 link_task_to_run_with_agent(&state, &task_id, 1, &run_id, "planner").await;
2357
2358 let status = worker_degradation(
2359 State(state.clone()),
2360 bearer_headers(&handle),
2361 Json(degradation_body("web_search", Some("rate limited"))),
2362 )
2363 .await
2364 .expect("worker_degradation");
2365 assert_eq!(status, StatusCode::NO_CONTENT);
2366
2367 let rec = run_store.get(&run_id).await.expect("run get");
2368 assert_eq!(
2369 rec.degradations.len(),
2370 1,
2371 "degradations: {:?}",
2372 rec.degradations
2373 );
2374 let entry = &rec.degradations[0];
2375 assert_eq!(entry.tool, "web_search");
2376 assert_eq!(entry.error, "boom");
2377 assert_eq!(entry.fallback, "used cached value");
2378 assert_eq!(entry.note.as_deref(), Some("rate limited"));
2379 assert_eq!(entry.step_ref.as_deref(), Some("planner"));
2380 assert_eq!(entry.attempt, Some(1));
2381 assert!(entry.at > 0, "at must be a real timestamp: {}", entry.at);
2382 }
2383
2384 #[tokio::test]
2386 async fn worker_degradation_appends_in_order() {
2387 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2388 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2389 let state = test_state(data_store, run_store.clone());
2390 let task_id = StepId::new();
2391 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2392
2393 let owner_task = TaskId::new();
2394 let run_id = RunId::new();
2395 run_store
2396 .create(run_record(&owner_task, &run_id, vec![]))
2397 .await
2398 .expect("run create");
2399 link_task_to_run(&state, &task_id, 1, &run_id).await;
2400
2401 for tool in ["first_tool", "second_tool"] {
2402 worker_degradation(
2403 State(state.clone()),
2404 bearer_headers(&handle),
2405 Json(degradation_body(tool, None)),
2406 )
2407 .await
2408 .expect("worker_degradation");
2409 }
2410
2411 let rec = run_store.get(&run_id).await.expect("run get");
2412 let tools: Vec<&str> = rec.degradations.iter().map(|e| e.tool.as_str()).collect();
2413 assert_eq!(tools, vec!["first_tool", "second_tool"]);
2414 }
2415
2416 #[tokio::test]
2420 async fn worker_degradation_silent_ok_when_no_run_tracked() {
2421 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2422 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2423 let state = test_state(data_store, run_store);
2424 let task_id = StepId::new();
2425 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2426
2427 let status = worker_degradation(
2428 State(state.clone()),
2429 bearer_headers(&handle),
2430 Json(degradation_body("some_tool", None)),
2431 )
2432 .await
2433 .expect("worker_degradation must not error on missing run linkage");
2434 assert_eq!(status, StatusCode::NO_CONTENT);
2435 }
2436
2437 #[tokio::test]
2440 async fn worker_degradation_rejects_terminal_run() {
2441 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2442 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2443 let state = test_state(data_store, run_store.clone());
2444 let task_id = StepId::new();
2445 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2446
2447 let owner_task = TaskId::new();
2448 let run_id = RunId::new();
2449 let mut rec = run_record(&owner_task, &run_id, vec![]);
2450 rec.status = RunStatus::Done;
2451 run_store.create(rec).await.expect("run create");
2452 link_task_to_run(&state, &task_id, 1, &run_id).await;
2453
2454 let err = worker_degradation(
2455 State(state.clone()),
2456 bearer_headers(&handle),
2457 Json(degradation_body("some_tool", None)),
2458 )
2459 .await
2460 .expect_err("a degradation against a Done run must be rejected");
2461 assert_eq!(err.status, StatusCode::GONE);
2462
2463 let rec = run_store.get(&run_id).await.expect("run get");
2464 assert!(
2465 rec.degradations.is_empty(),
2466 "rejected degradation must not land: {:?}",
2467 rec.degradations
2468 );
2469 }
2470
2471 async fn seed_work_dir(
2485 state: &AppState,
2486 task_id: &StepId,
2487 attempt: u32,
2488 work_dir: &str,
2489 allow_file_submit: Option<Value>,
2490 ) {
2491 let tid = task_id.clone();
2492 let work_dir = work_dir.to_string();
2493 state
2494 .engine
2495 .with_state("test.seed_work_dir", move |s| {
2496 let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2497 entry.view.work_dir = Some(work_dir);
2498 if let Some(v) = allow_file_submit {
2499 entry
2500 .view
2501 .extra
2502 .insert(FILE_SENTINEL_ALLOW_KEY.to_string(), v);
2503 }
2504 s.agent_ctx.insert((tid, attempt), entry);
2505 })
2506 .await
2507 .expect("seed_work_dir");
2508 }
2509
2510 #[tokio::test]
2514 async fn worker_submit_resolves_file_sentinel_under_work_dir() {
2515 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2516 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2517 let state = test_state(data_store.clone(), run_store);
2518 let task_id = StepId::new();
2519 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2520
2521 let tmp = tempfile::tempdir().expect("tempdir");
2522 let work_dir = tmp.path().to_path_buf();
2523 seed_work_dir(
2524 &state,
2525 &task_id,
2526 1,
2527 work_dir.to_str().expect("work_dir utf-8"),
2528 Some(Value::Bool(true)),
2529 )
2530 .await;
2531
2532 let payload_path = work_dir.join("scout.md");
2533 let payload = "## Context Package (broad)\n\nlarge body content\n";
2534 tokio::fs::write(&payload_path, payload)
2535 .await
2536 .expect("write payload");
2537 let body = format!(
2538 "@file:{}",
2539 payload_path.to_str().expect("payload path utf-8")
2540 );
2541
2542 let status = worker_submit(
2543 State(state.clone()),
2544 bearer_headers(&handle),
2545 Query(SubmitQuery {
2546 ok: None,
2547 verdict: None,
2548 }),
2549 axum::body::Bytes::from(body),
2550 )
2551 .await
2552 .expect("worker_submit sentinel");
2553 assert_eq!(status, StatusCode::NO_CONTENT);
2554
2555 let tid = task_id.clone();
2559 let value = state
2560 .engine
2561 .with_state("test.inspect_output_store", move |s| {
2562 s.output_store.get(&(tid.clone(), 1)).and_then(|evs| {
2563 evs.iter().find_map(|ev| match ev {
2564 OutputEvent::Final {
2565 content: ContentRef::Inline { value },
2566 ..
2567 } => Some(value.clone()),
2568 _ => None,
2569 })
2570 })
2571 })
2572 .await
2573 .expect("with_state")
2574 .expect("Final event present");
2575 assert_eq!(value, Value::String(payload.trim_end().to_string()));
2576 }
2577
2578 #[tokio::test]
2581 async fn worker_submit_passes_non_sentinel_body_unchanged() {
2582 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2583 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2584 let state = test_state(data_store.clone(), run_store);
2585 let task_id = StepId::new();
2586 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2587 let status = worker_submit(
2591 State(state.clone()),
2592 bearer_headers(&handle),
2593 Query(SubmitQuery {
2594 ok: None,
2595 verdict: None,
2596 }),
2597 axum::body::Bytes::from_static(b"DONE yes=1 maybe=0 no=0"),
2598 )
2599 .await
2600 .expect("worker_submit inline");
2601 assert_eq!(status, StatusCode::NO_CONTENT);
2602
2603 let tid = task_id.clone();
2604 let value = state
2605 .engine
2606 .with_state("test.inspect_output_store", move |s| {
2607 s.output_store.get(&(tid.clone(), 1)).and_then(|evs| {
2608 evs.iter().find_map(|ev| match ev {
2609 OutputEvent::Final {
2610 content: ContentRef::Inline { value },
2611 ..
2612 } => Some(value.clone()),
2613 _ => None,
2614 })
2615 })
2616 })
2617 .await
2618 .expect("with_state")
2619 .expect("Final event present");
2620 assert_eq!(value, Value::String("DONE yes=1 maybe=0 no=0".to_string()));
2621 }
2622
2623 #[tokio::test]
2628 async fn worker_submit_rejects_sentinel_path_outside_work_dir() {
2629 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2630 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2631 let state = test_state(data_store, run_store);
2632 let task_id = StepId::new();
2633 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2634
2635 let allowed = tempfile::tempdir().expect("allowed tempdir");
2636 let outside = tempfile::tempdir().expect("outside tempdir");
2637 seed_work_dir(
2638 &state,
2639 &task_id,
2640 1,
2641 allowed.path().to_str().expect("utf-8"),
2642 Some(Value::Bool(true)),
2643 )
2644 .await;
2645
2646 let outside_file = outside.path().join("leak.md");
2647 tokio::fs::write(&outside_file, b"outside content")
2648 .await
2649 .expect("write outside");
2650 let body = format!(
2651 "@file:{}",
2652 outside_file.to_str().expect("outside path utf-8")
2653 );
2654
2655 let err = worker_submit(
2656 State(state.clone()),
2657 bearer_headers(&handle),
2658 Query(SubmitQuery {
2659 ok: None,
2660 verdict: None,
2661 }),
2662 axum::body::Bytes::from(body),
2663 )
2664 .await
2665 .expect_err("outside-work_dir sentinel must be rejected");
2666 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2667 }
2668
2669 #[tokio::test]
2671 async fn worker_submit_rejects_sentinel_missing_file() {
2672 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2673 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2674 let state = test_state(data_store, run_store);
2675 let task_id = StepId::new();
2676 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2677
2678 let tmp = tempfile::tempdir().expect("tempdir");
2679 seed_work_dir(
2680 &state,
2681 &task_id,
2682 1,
2683 tmp.path().to_str().expect("utf-8"),
2684 Some(Value::Bool(true)),
2685 )
2686 .await;
2687 let missing = tmp.path().join("does-not-exist.md");
2688 let body = format!("@file:{}", missing.to_str().expect("utf-8"));
2689
2690 let err = worker_submit(
2691 State(state.clone()),
2692 bearer_headers(&handle),
2693 Query(SubmitQuery {
2694 ok: None,
2695 verdict: None,
2696 }),
2697 axum::body::Bytes::from(body),
2698 )
2699 .await
2700 .expect_err("missing-file sentinel must be rejected");
2701 assert_eq!(err.status, StatusCode::NOT_FOUND);
2702 }
2703
2704 #[tokio::test]
2706 async fn worker_submit_rejects_sentinel_relative_path() {
2707 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2708 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2709 let state = test_state(data_store, run_store);
2710 let task_id = StepId::new();
2711 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2712
2713 let err = worker_submit(
2714 State(state.clone()),
2715 bearer_headers(&handle),
2716 Query(SubmitQuery {
2717 ok: None,
2718 verdict: None,
2719 }),
2720 axum::body::Bytes::from_static(b"@file:relative/path.md"),
2721 )
2722 .await
2723 .expect_err("relative-path sentinel must be rejected");
2724 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2725 }
2726
2727 #[tokio::test]
2731 async fn worker_submit_rejects_sentinel_without_agent_context_view() {
2732 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2733 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2734 let state = test_state(data_store, run_store);
2735 let task_id = StepId::new();
2736 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2737 let err = worker_submit(
2740 State(state.clone()),
2741 bearer_headers(&handle),
2742 Query(SubmitQuery {
2743 ok: None,
2744 verdict: None,
2745 }),
2746 axum::body::Bytes::from_static(b"@file:/tmp/anywhere.md"),
2747 )
2748 .await
2749 .expect_err("missing AgentContextView must reject sentinel");
2750 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2751 }
2752
2753 #[tokio::test]
2757 async fn worker_artifact_resolves_file_sentinel_under_work_dir() {
2758 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2759 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2760 let state = test_state(data_store, run_store);
2761 let task_id = StepId::new();
2762 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2763
2764 let tmp = tempfile::tempdir().expect("tempdir");
2765 seed_work_dir(
2766 &state,
2767 &task_id,
2768 1,
2769 tmp.path().to_str().expect("utf-8"),
2770 Some(Value::Bool(true)),
2771 )
2772 .await;
2773
2774 let payload_path = tmp.path().join("part.md");
2775 let payload = "artifact part body\n";
2776 tokio::fs::write(&payload_path, payload)
2777 .await
2778 .expect("write payload");
2779 let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
2780
2781 let status = worker_artifact(
2782 State(state.clone()),
2783 bearer_headers(&handle),
2784 Query(ArtifactQuery {
2785 name: "scout".to_string(),
2786 }),
2787 axum::body::Bytes::from(body),
2788 )
2789 .await
2790 .expect("worker_artifact sentinel");
2791 assert_eq!(status, StatusCode::NO_CONTENT);
2792 }
2793
2794 #[tokio::test]
2799 async fn worker_submit_rejects_sentinel_without_allow_flag() {
2800 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2801 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2802 let state = test_state(data_store, run_store);
2803 let task_id = StepId::new();
2804 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2805
2806 let tmp = tempfile::tempdir().expect("tempdir");
2807 seed_work_dir(
2808 &state,
2809 &task_id,
2810 1,
2811 tmp.path().to_str().expect("utf-8"),
2812 None,
2813 )
2814 .await;
2815
2816 let payload_path = tmp.path().join("out.md");
2817 tokio::fs::write(&payload_path, b"resolvable body")
2818 .await
2819 .expect("write payload");
2820 let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
2821
2822 let err = worker_submit(
2823 State(state.clone()),
2824 bearer_headers(&handle),
2825 Query(SubmitQuery {
2826 ok: None,
2827 verdict: None,
2828 }),
2829 axum::body::Bytes::from(body),
2830 )
2831 .await
2832 .expect_err("missing opt-in must reject sentinel");
2833 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2834 assert!(
2835 err.message.contains("not allowed"),
2836 "rejection must name the opt-in guard, got: {}",
2837 err.message
2838 );
2839 }
2840
2841 #[tokio::test]
2844 async fn worker_submit_rejects_sentinel_with_non_true_allow_values() {
2845 for allow in [Value::Bool(false), Value::String("true".to_string())] {
2846 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2847 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2848 let state = test_state(data_store, run_store);
2849 let task_id = StepId::new();
2850 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2851
2852 let tmp = tempfile::tempdir().expect("tempdir");
2853 seed_work_dir(
2854 &state,
2855 &task_id,
2856 1,
2857 tmp.path().to_str().expect("utf-8"),
2858 Some(allow.clone()),
2859 )
2860 .await;
2861
2862 let payload_path = tmp.path().join("out.md");
2863 tokio::fs::write(&payload_path, b"resolvable body")
2864 .await
2865 .expect("write payload");
2866 let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
2867
2868 let err = worker_submit(
2869 State(state.clone()),
2870 bearer_headers(&handle),
2871 Query(SubmitQuery {
2872 ok: None,
2873 verdict: None,
2874 }),
2875 axum::body::Bytes::from(body),
2876 )
2877 .await
2878 .expect_err("non-true opt-in value must reject sentinel");
2879 assert_eq!(err.status, StatusCode::BAD_REQUEST, "value: {allow:?}");
2880 }
2881 }
2882
2883 fn body_verdict_contract(values: &[&str]) -> mlua_swarm_schema::VerdictContract {
2894 mlua_swarm_schema::VerdictContract {
2895 channel: VerdictChannel::Body,
2896 values: values.iter().map(|v| v.to_string()).collect(),
2897 }
2898 }
2899
2900 fn part_verdict_contract(values: &[&str]) -> mlua_swarm_schema::VerdictContract {
2901 mlua_swarm_schema::VerdictContract {
2902 channel: VerdictChannel::Part,
2903 values: values.iter().map(|v| v.to_string()).collect(),
2904 }
2905 }
2906
2907 #[tokio::test]
2910 async fn worker_submit_rejects_body_outside_contract_values_with_422() {
2911 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2912 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2913 let state = test_state(data_store, run_store);
2914 let task_id = StepId::new();
2915 let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
2916 state.engine.register_verdict_contracts(HashMap::from([(
2917 "gate".to_string(),
2918 body_verdict_contract(&["PASS", "BLOCKED"]),
2919 )]));
2920
2921 let err = worker_submit(
2922 State(state.clone()),
2923 bearer_headers(&handle),
2924 Query(SubmitQuery {
2925 ok: None,
2926 verdict: None,
2927 }),
2928 axum::body::Bytes::from("UNKNOWN"),
2929 )
2930 .await
2931 .expect_err("value outside declared values must reject");
2932 assert_eq!(err.status, StatusCode::UNPROCESSABLE_ENTITY);
2933 assert!(
2934 err.message.contains("PASS") && err.message.contains("BLOCKED"),
2935 "rejection must echo the declared values, got: {}",
2936 err.message
2937 );
2938 }
2939
2940 #[tokio::test]
2943 async fn worker_submit_accepts_body_inside_contract_values() {
2944 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2945 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2946 let state = test_state(data_store, run_store);
2947 let task_id = StepId::new();
2948 let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
2949 state.engine.register_verdict_contracts(HashMap::from([(
2950 "gate".to_string(),
2951 body_verdict_contract(&["PASS", "BLOCKED"]),
2952 )]));
2953
2954 let status = worker_submit(
2955 State(state.clone()),
2956 bearer_headers(&handle),
2957 Query(SubmitQuery {
2958 ok: None,
2959 verdict: None,
2960 }),
2961 axum::body::Bytes::from("PASS"),
2962 )
2963 .await
2964 .expect("value inside declared values must succeed");
2965 assert_eq!(status, StatusCode::NO_CONTENT);
2966 }
2967
2968 #[tokio::test]
2972 async fn worker_submit_without_a_declared_contract_is_unaffected() {
2973 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2974 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2975 let state = test_state(data_store, run_store);
2976 let task_id = StepId::new();
2977 let handle = seed_task_with_handle(&state, &task_id, "undeclared-agent", 1, None).await;
2979
2980 let status = worker_submit(
2981 State(state.clone()),
2982 bearer_headers(&handle),
2983 Query(SubmitQuery {
2984 ok: None,
2985 verdict: None,
2986 }),
2987 axum::body::Bytes::from("anything at all, no contract to violate"),
2988 )
2989 .await
2990 .expect("no contract declared must never reject");
2991 assert_eq!(status, StatusCode::NO_CONTENT);
2992 }
2993
2994 #[tokio::test]
2997 async fn worker_artifact_verdict_part_rejects_value_outside_contract_with_422() {
2998 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2999 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3000 let state = test_state(data_store, run_store);
3001 let task_id = StepId::new();
3002 let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
3003 state.engine.register_verdict_contracts(HashMap::from([(
3004 "gate".to_string(),
3005 part_verdict_contract(&["PASS", "BLOCKED"]),
3006 )]));
3007
3008 let err = worker_artifact(
3009 State(state.clone()),
3010 bearer_headers(&handle),
3011 Query(ArtifactQuery {
3012 name: "verdict".to_string(),
3013 }),
3014 axum::body::Bytes::from("UNKNOWN"),
3015 )
3016 .await
3017 .expect_err("value outside declared values must reject");
3018 assert_eq!(err.status, StatusCode::UNPROCESSABLE_ENTITY);
3019 }
3020
3021 #[tokio::test]
3025 async fn worker_artifact_non_verdict_part_skips_the_gate() {
3026 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
3027 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
3028 let state = test_state(data_store, run_store);
3029 let task_id = StepId::new();
3030 let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
3031 state.engine.register_verdict_contracts(HashMap::from([(
3032 "gate".to_string(),
3033 part_verdict_contract(&["PASS", "BLOCKED"]),
3034 )]));
3035
3036 let status = worker_artifact(
3037 State(state.clone()),
3038 bearer_headers(&handle),
3039 Query(ArtifactQuery {
3040 name: "notes".to_string(),
3041 }),
3042 axum::body::Bytes::from("anything at all"),
3043 )
3044 .await
3045 .expect("non-verdict part name must never be gated");
3046 assert_eq!(status, StatusCode::NO_CONTENT);
3047 }
3048}