1use axum::{
68 extract::{Query, State},
69 http::{header, header::AUTHORIZATION, HeaderMap, StatusCode},
70 Json,
71};
72use mlua_swarm::core::agent_context::StepPointer;
73use mlua_swarm::core::step_naming::StepNaming;
74use mlua_swarm::store::run::{DegradationEntry, RunStatus, RunStoreError};
75use mlua_swarm::{CapToken, ContentRef, OutputEvent, RunId, StepId, WorkerPayload};
76use mlua_swarm_schema::ContextPolicy;
77use serde::Deserialize;
78use serde_json::Value;
79
80use crate::projection::McpQueryAdapter;
81use crate::{ApiError, AppState};
82
83#[derive(Debug, Deserialize)]
85pub struct PromptQuery {
86 pub task_id: StepId,
90}
91
92pub async fn worker_prompt(
98 State(state): State<AppState>,
99 headers: HeaderMap,
100 Query(q): Query<PromptQuery>,
101) -> Result<Json<WorkerPayload>, ApiError> {
102 let task_id = q.task_id;
103 let bearer = extract_bearer_raw(&headers)?;
104 let mut payload = if let Some(handle) = parse_worker_handle(&bearer) {
105 let resolved = state
107 .engine
108 .task_id_from_handle(handle)
109 .await
110 .map_err(|e| ApiError::engine(format!("task_id_from_handle: {e}")))?;
111 if resolved != task_id {
112 return Err(ApiError::bad_request(format!(
113 "handle {handle} is bound to task {resolved}, not {task_id}"
114 )));
115 }
116 state
117 .engine
118 .fetch_worker_payload_trusted(&task_id)
119 .await
120 .map_err(|e| ApiError::engine(format!("fetch_worker_payload_trusted: {e}")))?
121 } else {
122 let token = CapToken::decode(bearer.trim())
124 .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
125 state
126 .engine
127 .fetch_worker_payload(&token, &task_id)
128 .await
129 .map_err(|e| ApiError::engine(format!("fetch_worker_payload: {e}")))?
130 };
131 assemble_step_pointers(&state, &mut payload).await;
132 Ok(Json(payload))
133}
134
135async fn assemble_step_pointers(state: &AppState, payload: &mut WorkerPayload) {
163 let Some(context) = payload.context.as_mut() else {
164 return;
165 };
166 let Some(run_id_str) = context.run_id.clone() else {
167 return;
168 };
169 let Ok(run_id) = RunId::parse(run_id_str) else {
170 return;
171 };
172
173 let adapter = McpQueryAdapter::new(
174 state.data_store.clone(),
175 state.run_store.clone(),
176 state.engine.clone(),
177 );
178 let Ok((run, resolved_steps)) = adapter.list_steps_by_run_id(&run_id).await else {
179 return;
180 };
181
182 let naming = state.engine.step_naming_for(&payload.task_id).await;
183 let policy = state
184 .engine
185 .context_policy_for(&payload.task_id, payload.attempt)
186 .await;
187 let self_canonical = naming
188 .as_deref()
189 .and_then(|n| n.canonical_of_producer(&payload.agent))
190 .map(str::to_string)
191 .unwrap_or_else(|| payload.agent.clone());
192
193 let mut pointers = Vec::new();
194 for step in &resolved_steps {
195 if step.name == self_canonical
196 || !allows_step_canonical(&policy, naming.as_deref(), &step.name)
197 {
198 continue;
199 }
200 if let Some((size_bytes, file_path, content_url, sha256)) =
201 crate::projection::resolve_step_pointer_fields(state, &run, step).await
202 {
203 pointers.push(StepPointer {
204 name: step.name.clone(),
205 size_bytes,
206 file_path,
207 content_url,
208 sha256,
209 });
210 }
211 }
212 context.steps = pointers;
213}
214
215fn allows_step_canonical(
230 policy: &ContextPolicy,
231 naming: Option<&StepNaming>,
232 canonical_name: &str,
233) -> bool {
234 let resolves_to = |raw: &str| -> bool {
235 match naming {
236 Some(n) => n
237 .resolve(raw)
238 .map(|c| c == canonical_name)
239 .unwrap_or(raw == canonical_name),
240 None => raw == canonical_name,
241 }
242 };
243 if policy
244 .steps_exclude
245 .iter()
246 .any(|excluded| resolves_to(excluded))
247 {
248 return false;
249 }
250 match &policy.steps {
251 None => true,
252 Some(list) => list.iter().any(|included| resolves_to(included)),
253 }
254}
255
256#[derive(Debug, Deserialize)]
258pub struct WorkerResultReq {
259 pub task_id: StepId,
262 pub value: Value,
264 #[serde(default = "default_ok_true")]
268 pub ok: bool,
269 #[serde(default)]
272 pub attempt: Option<u32>,
273}
274
275fn default_ok_true() -> bool {
276 true
277}
278
279pub async fn worker_result(
282 State(state): State<AppState>,
283 headers: HeaderMap,
284 Json(req): Json<WorkerResultReq>,
285) -> Result<StatusCode, ApiError> {
286 let token = decode_worker_bearer(&headers)?;
287 let task_id = req.task_id.clone();
288
289 let attempt = match req.attempt {
291 Some(n) => n,
292 None => state
293 .engine
294 .task_attempt(&task_id)
295 .await
296 .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?,
297 };
298
299 let event = OutputEvent::Final {
300 content: ContentRef::Inline {
301 value: req.value.clone(),
302 },
303 ok: req.ok,
304 };
305 state
306 .engine
307 .submit_output(&token, &task_id, attempt, event)
308 .await
309 .map_err(|e| ApiError::engine(format!("submit_output: {e}")))?;
310 state
311 .engine
312 .post_result(&token, &task_id, req.value)
313 .await
314 .map_err(|e| ApiError::engine(format!("post_result: {e}")))?;
315 Ok(StatusCode::NO_CONTENT)
316}
317
318const FILE_SENTINEL_PREFIX: &str = "@file:";
326
327const FILE_SENTINEL_MAX_BYTES: u64 = 2 * 1024 * 1024;
333
334const FILE_SENTINEL_ALLOW_KEY: &str = "allow_file_submit";
346
347async fn resolve_file_sentinel(
380 state: &AppState,
381 task_id: &StepId,
382 attempt: u32,
383 body_str: String,
384) -> Result<String, ApiError> {
385 let Some(rest) = body_str.strip_prefix(FILE_SENTINEL_PREFIX) else {
386 return Ok(body_str);
387 };
388 let path_str = rest.trim();
389 if path_str.is_empty() {
390 return Err(ApiError::bad_request(
391 "@file: sentinel: empty path".to_string(),
392 ));
393 }
394 if path_str.contains('\n') || path_str.contains('\r') {
395 return Err(ApiError::bad_request(
396 "@file: sentinel: path must be a single line".to_string(),
397 ));
398 }
399 let path = std::path::Path::new(path_str);
400 if !path.is_absolute() {
401 return Err(ApiError::bad_request(format!(
402 "@file: sentinel: path must be absolute (got {path_str:?})"
403 )));
404 }
405 let view = state
406 .engine
407 .agent_context_for(task_id, attempt)
408 .await
409 .ok_or_else(|| {
410 ApiError::bad_request(
411 "@file: sentinel: no AgentContextView for this task/attempt \
412 (spawn must run through AgentContextMiddleware to enable \
413 sentinel resolution)"
414 .to_string(),
415 )
416 })?;
417 if view.extra.get(FILE_SENTINEL_ALLOW_KEY) != Some(&Value::Bool(true)) {
421 return Err(ApiError::bad_request(format!(
422 "@file: sentinel: file submission is not allowed for this step \
423 (declare `{FILE_SENTINEL_ALLOW_KEY}: true` via `$step_meta` / \
424 `AgentMeta.ctx` / `Blueprint.metas`; strict boolean `true` \
425 required)"
426 )));
427 }
428 let work_dir = view.work_dir.ok_or_else(|| {
429 ApiError::bad_request("@file: sentinel: task has no resolved work_dir".to_string())
430 })?;
431 let work_dir_canon = tokio::fs::canonicalize(&work_dir).await.map_err(|e| {
432 ApiError::engine(format!(
433 "@file: sentinel: canonicalize work_dir {work_dir:?}: {e}"
434 ))
435 })?;
436 let path_canon = match tokio::fs::canonicalize(path).await {
437 Ok(p) => p,
438 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
439 return Err(ApiError::not_found(format!(
440 "@file: sentinel: file not found: {path_str}"
441 )));
442 }
443 Err(e) => {
444 return Err(ApiError::engine(format!(
445 "@file: sentinel: canonicalize {path_str:?}: {e}"
446 )));
447 }
448 };
449 if !path_canon.starts_with(&work_dir_canon) {
450 return Err(ApiError::bad_request(format!(
451 "@file: sentinel: path {} is not under work_dir {} (canonicalized: {} vs {})",
452 path_str,
453 work_dir,
454 path_canon.display(),
455 work_dir_canon.display(),
456 )));
457 }
458 let meta = tokio::fs::metadata(&path_canon)
459 .await
460 .map_err(|e| ApiError::engine(format!("@file: sentinel: metadata {path_str:?}: {e}")))?;
461 if meta.len() > FILE_SENTINEL_MAX_BYTES {
462 return Err(ApiError::payload_too_large(format!(
463 "@file: sentinel: file size {} exceeds limit {}",
464 meta.len(),
465 FILE_SENTINEL_MAX_BYTES
466 )));
467 }
468 let bytes = tokio::fs::read(&path_canon)
469 .await
470 .map_err(|e| ApiError::engine(format!("@file: sentinel: read {path_str:?}: {e}")))?;
471 Ok(String::from_utf8_lossy(&bytes).trim_end().to_string())
473}
474
475#[derive(Debug, Deserialize, Default)]
497pub struct SubmitQuery {
498 #[serde(default)]
502 pub ok: Option<bool>,
503}
504
505pub async fn worker_submit(
511 State(state): State<AppState>,
512 headers: HeaderMap,
513 Query(q): Query<SubmitQuery>,
514 body: axum::body::Bytes,
515) -> Result<StatusCode, ApiError> {
516 let bearer = extract_bearer_raw(&headers)?;
519 let task_id = if let Some(handle) = parse_worker_handle(&bearer) {
520 state
521 .engine
522 .task_id_from_handle(handle)
523 .await
524 .map_err(|e| ApiError::engine(format!("task_id_from_handle: {e}")))?
525 } else {
526 let token = CapToken::decode(bearer.trim())
527 .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
528 state
529 .engine
530 .task_id_from_token(&token)
531 .await
532 .map_err(|e| ApiError::engine(format!("task_id_from_token: {e}")))?
533 };
534 let attempt = state
535 .engine
536 .task_attempt(&task_id)
537 .await
538 .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?;
539 reject_if_run_terminal(&state, &task_id, attempt).await?;
542 let body_str = String::from_utf8_lossy(&body).trim_end().to_string();
547 let body_str = resolve_file_sentinel(&state, &task_id, attempt, body_str).await?;
550 let value = Value::String(body_str);
551
552 let ok = q.ok.unwrap_or(true);
558 state
559 .engine
560 .submit_worker_result_trusted(&task_id, attempt, value, ok)
561 .await
562 .map_err(|e| ApiError::engine(format!("submit_worker_result_trusted: {e}")))?;
563 Ok(StatusCode::NO_CONTENT)
564}
565
566#[derive(Debug, Deserialize)]
568pub struct ArtifactQuery {
569 pub name: String,
576}
577
578pub async fn worker_artifact(
600 State(state): State<AppState>,
601 headers: HeaderMap,
602 Query(q): Query<ArtifactQuery>,
603 body: axum::body::Bytes,
604) -> Result<StatusCode, ApiError> {
605 let name = q.name.trim();
606 if name.is_empty() {
607 return Err(ApiError::bad_request("name must not be empty".into()));
608 }
609 let name = name.to_string();
610
611 let bearer = extract_bearer_raw(&headers)?;
612 let task_id = if let Some(handle) = parse_worker_handle(&bearer) {
613 state
614 .engine
615 .task_id_from_handle(handle)
616 .await
617 .map_err(|e| ApiError::engine(format!("task_id_from_handle: {e}")))?
618 } else {
619 let token = CapToken::decode(bearer.trim())
620 .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
621 state
622 .engine
623 .task_id_from_token(&token)
624 .await
625 .map_err(|e| ApiError::engine(format!("task_id_from_token: {e}")))?
626 };
627 let attempt = state
628 .engine
629 .task_attempt(&task_id)
630 .await
631 .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?;
632 reject_if_run_terminal(&state, &task_id, attempt).await?;
635 let body_str = String::from_utf8_lossy(&body).trim_end().to_string();
636 let body_str = resolve_file_sentinel(&state, &task_id, attempt, body_str).await?;
638 let value = Value::String(body_str);
639
640 state
641 .engine
642 .stage_worker_artifact_trusted(&task_id, attempt, name, value)
643 .await
644 .map_err(|e| ApiError::engine(format!("stage_worker_artifact_trusted: {e}")))?;
645 Ok(StatusCode::NO_CONTENT)
646}
647
648#[derive(Debug, Deserialize)]
650pub struct DegradationBody {
651 pub tool: String,
653 pub error: String,
655 pub fallback: String,
657 #[serde(default)]
659 pub note: Option<String>,
660}
661
662pub async fn worker_degradation(
690 State(state): State<AppState>,
691 headers: HeaderMap,
692 Json(body): Json<DegradationBody>,
693) -> Result<StatusCode, ApiError> {
694 let bearer = extract_bearer_raw(&headers)?;
695 let task_id = if let Some(handle) = parse_worker_handle(&bearer) {
696 state
697 .engine
698 .task_id_from_handle(handle)
699 .await
700 .map_err(|e| ApiError::engine(format!("task_id_from_handle: {e}")))?
701 } else {
702 let token = CapToken::decode(bearer.trim())
703 .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
704 state
705 .engine
706 .task_id_from_token(&token)
707 .await
708 .map_err(|e| ApiError::engine(format!("task_id_from_token: {e}")))?
709 };
710 let attempt = state
711 .engine
712 .task_attempt(&task_id)
713 .await
714 .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?;
715 reject_if_run_terminal(&state, &task_id, attempt).await?;
718
719 let tid = task_id.clone();
724 let (run_id_str, agent) = match state
725 .engine
726 .with_state("worker_degradation_run_lookup", move |s| {
727 s.agent_ctx.get(&(tid, attempt)).and_then(|e| {
728 e.view
729 .run_id
730 .clone()
731 .map(|run_id| (run_id, e.view.agent.clone()))
732 })
733 })
734 .await
735 {
736 Ok(Some(pair)) => pair,
737 _ => {
738 tracing::warn!(%task_id, "worker_degradation: no run linkage for this task; entry dropped");
739 return Ok(StatusCode::NO_CONTENT);
740 }
741 };
742 let Ok(run_id) = RunId::parse(run_id_str) else {
743 tracing::warn!(%task_id, "worker_degradation: run_id failed to parse; entry dropped");
744 return Ok(StatusCode::NO_CONTENT);
745 };
746
747 let entry = DegradationEntry {
748 tool: body.tool,
749 error: body.error,
750 fallback: body.fallback,
751 note: body.note,
752 step_ref: Some(agent),
753 attempt: Some(attempt),
754 at: crate::tasks::now_secs(),
755 };
756 match state.run_store.append_degradation(&run_id, entry).await {
757 Ok(()) => Ok(StatusCode::NO_CONTENT),
758 Err(RunStoreError::NotFound(_)) => {
759 tracing::warn!(%task_id, %run_id, "worker_degradation: run not found in run_store; entry dropped");
760 Ok(StatusCode::NO_CONTENT)
761 }
762 Err(e) => Err(ApiError::engine(format!("append_degradation: {e}"))),
763 }
764}
765
766async fn reject_if_run_terminal(
783 state: &AppState,
784 task_id: &StepId,
785 attempt: u32,
786) -> Result<(), ApiError> {
787 let tid = task_id.clone();
788 let run_id_str = match state
789 .engine
790 .with_state("worker_terminal_run_guard", move |s| {
791 s.agent_ctx
792 .get(&(tid, attempt))
793 .and_then(|e| e.view.run_id.clone())
794 })
795 .await
796 {
797 Ok(Some(rid)) => rid,
798 _ => return Ok(()),
799 };
800 let Ok(run_id) = RunId::parse(run_id_str) else {
801 return Ok(());
802 };
803 let Ok(rec) = state.run_store.get(&run_id).await else {
804 return Ok(());
805 };
806 match rec.status {
807 RunStatus::Done | RunStatus::Failed | RunStatus::Interrupted => {
808 Err(ApiError::gone(format!(
809 "run {run_id} is already terminal ({:?}): this attempt's output cannot be \
810 delivered to a flow context; re-kick the task (POST /v1/tasks/:id/runs) and \
811 fetch a fresh prompt",
812 rec.status
813 )))
814 }
815 RunStatus::Pending | RunStatus::Running => Ok(()),
816 }
817}
818
819#[derive(Debug, Deserialize)]
824pub struct PromptSystemQuery {
825 pub task_id: StepId,
828 pub attempt: u32,
830}
831
832pub async fn worker_prompt_system(
842 State(state): State<AppState>,
843 headers: HeaderMap,
844 Query(q): Query<PromptSystemQuery>,
845) -> Result<impl axum::response::IntoResponse, ApiError> {
846 let task_id = q.task_id;
847 let attempt = q.attempt;
848 let bearer = extract_bearer_raw(&headers)?;
849 if let Some(handle) = parse_worker_handle(&bearer) {
850 let resolved = state
851 .engine
852 .task_id_from_handle(handle)
853 .await
854 .map_err(|e| ApiError::engine(format!("task_id_from_handle: {e}")))?;
855 if resolved != task_id {
856 return Err(ApiError::bad_request(format!(
857 "handle {handle} is bound to task {resolved}, not {task_id}"
858 )));
859 }
860 } else {
861 let token = CapToken::decode(bearer.trim())
862 .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
863 state
864 .engine
865 .verify_token_for_task(&token, mlua_swarm::Verb::FetchPrompt, &task_id)
866 .await
867 .map_err(|e| ApiError::engine(format!("verify_token_for_task: {e}")))?;
868 }
869 let system = state
870 .engine
871 .raw_system_prompt(&task_id, attempt)
872 .await
873 .map_err(|e| ApiError::engine(format!("raw_system_prompt: {e}")))?
874 .ok_or_else(|| {
875 ApiError::not_found(format!(
876 "no baked system prompt for task {task_id} attempt {attempt}"
877 ))
878 })?;
879 Ok((
880 [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
881 system,
882 ))
883}
884
885#[derive(Debug, serde::Serialize)]
887pub struct AgentRenderSizeResponse {
888 pub agent: String,
890 pub last_rendered_bytes: Option<usize>,
894}
895
896pub async fn agent_render_size(
906 State(state): State<AppState>,
907 axum::extract::Path(name): axum::extract::Path<String>,
908) -> Json<AgentRenderSizeResponse> {
909 let last_rendered_bytes = state.engine.agent_last_rendered_size(&name).await;
910 Json(AgentRenderSizeResponse {
911 agent: name,
912 last_rendered_bytes,
913 })
914}
915
916fn extract_bearer_raw(headers: &HeaderMap) -> Result<String, ApiError> {
920 let v = headers
921 .get(AUTHORIZATION)
922 .ok_or_else(|| ApiError::bad_request("missing Authorization header".into()))?
923 .to_str()
924 .map_err(|_| ApiError::bad_request("invalid Authorization header encoding".into()))?;
925 let s = v
926 .strip_prefix("Bearer ")
927 .ok_or_else(|| ApiError::bad_request("Authorization must be 'Bearer <token>'".into()))?
928 .trim();
929 if s.is_empty() {
930 return Err(ApiError::bad_request("Bearer is empty".into()));
931 }
932 Ok(s.to_string())
933}
934
935fn parse_worker_handle(s: &str) -> Option<&str> {
939 let s = s.trim();
940 if s.starts_with("wh-")
941 && s.len() >= 5
942 && s.len() <= 64
943 && s[3..].chars().all(|c| c.is_ascii_alphanumeric())
944 {
945 Some(s)
946 } else {
947 None
948 }
949}
950
951fn decode_worker_bearer(headers: &HeaderMap) -> Result<CapToken, ApiError> {
955 let v = headers
956 .get(AUTHORIZATION)
957 .ok_or_else(|| ApiError::bad_request("missing Authorization header".into()))?
958 .to_str()
959 .map_err(|_| ApiError::bad_request("invalid Authorization header encoding".into()))?;
960 let encoded = v
961 .strip_prefix("Bearer ")
962 .ok_or_else(|| ApiError::bad_request("Authorization must be 'Bearer <token>'".into()))?
963 .trim();
964 if encoded.is_empty() {
965 return Err(ApiError::bad_request("Bearer token is empty".into()));
966 }
967 CapToken::decode(encoded).map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))
968}
969
970#[cfg(test)]
975mod tests {
976 use super::*;
977 use axum::response::IntoResponse;
978 use mlua_swarm::core::agent_context::AgentContextView;
979 use mlua_swarm::core::config::EngineCfg;
980 use mlua_swarm::core::engine::Engine;
981 use mlua_swarm::store::output::{InMemoryOutputStore, OutputStore};
982 use mlua_swarm::store::run::{InMemoryRunStore, RunRecord, RunStatus, RunStore, StepEntry};
983 use mlua_swarm::store::task::InMemoryTaskStore;
984 use mlua_swarm::{RunId, StepId, TaskId};
985 use serde_json::json;
986 use std::collections::HashMap;
987 use std::sync::Arc;
988 use tokio::sync::Mutex;
989
990 fn test_state(data_store: Arc<dyn OutputStore>, run_store: Arc<dyn RunStore>) -> AppState {
996 let engine = Engine::new(EngineCfg::default());
997 let compiler = mlua_swarm::Compiler::new(crate::default_registry());
998 let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
999 AppState {
1000 engine,
1001 sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
1002 task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
1003 ws_operator_factory: None,
1004 data_store,
1005 operator_sessions: Arc::new(Mutex::new(HashMap::new())),
1006 roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
1007 task_store: Arc::new(InMemoryTaskStore::new()),
1008 run_store,
1009 base_url: None,
1010 sync_timeout_secs: 300,
1011 }
1012 }
1013
1014 async fn append_final(
1015 data_store: &Arc<dyn OutputStore>,
1016 task_id: &str,
1017 producer: &str,
1018 value: Value,
1019 ) {
1020 data_store
1021 .append(
1022 task_id,
1023 1,
1024 producer,
1025 OutputEvent::Final {
1026 content: ContentRef::Inline { value },
1027 ok: true,
1028 },
1029 vec![],
1030 )
1031 .await
1032 .expect("append final");
1033 }
1034
1035 fn step_entry(step_id: &StepId, step_ref: &str) -> StepEntry {
1036 StepEntry {
1037 step_id: step_id.clone(),
1038 step_ref: Some(step_ref.to_string()),
1039 status: Some("passed".to_string()),
1040 at: 0,
1041 }
1042 }
1043
1044 fn run_record(task_id: &TaskId, run_id: &RunId, step_entries: Vec<StepEntry>) -> RunRecord {
1045 RunRecord {
1046 id: run_id.clone(),
1047 task_id: task_id.clone(),
1048 status: RunStatus::Running,
1049 step_entries,
1050 degradations: Vec::new(),
1051 operator_sid: None,
1052 result_ref: None,
1053 created_at: 0,
1054 updated_at: 0,
1055 }
1056 }
1057
1058 fn consumer_payload(consumer_step_id: &StepId, run_id: &RunId) -> WorkerPayload {
1059 WorkerPayload {
1060 task_id: consumer_step_id.clone(),
1061 attempt: 1,
1062 agent: "consumer".to_string(),
1063 system: None,
1064 prompt: String::new(),
1065 context: Some(AgentContextView {
1066 task_id: consumer_step_id.to_string(),
1067 agent: "consumer".to_string(),
1068 attempt: 1,
1069 run_id: Some(run_id.to_string()),
1070 ..Default::default()
1071 }),
1072 system_ref: None,
1073 }
1074 }
1075
1076 #[tokio::test]
1081 async fn context_policy_unspecified_yields_every_submitted_step() {
1082 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1083 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1084 let task_id = TaskId::new();
1085 let run_id = RunId::new();
1086 let planner_id = StepId::new();
1087 let coder_id = StepId::new();
1088
1089 append_final(
1090 &data_store,
1091 planner_id.as_str(),
1092 "planner",
1093 json!({"plan": "x"}),
1094 )
1095 .await;
1096 append_final(
1097 &data_store,
1098 coder_id.as_str(),
1099 "coder",
1100 json!({"code": "y"}),
1101 )
1102 .await;
1103 run_store
1104 .create(run_record(
1105 &task_id,
1106 &run_id,
1107 vec![
1108 step_entry(&planner_id, "planner"),
1109 step_entry(&coder_id, "coder"),
1110 ],
1111 ))
1112 .await
1113 .expect("create run");
1114
1115 let state = test_state(data_store, run_store);
1116 let consumer_id = StepId::new();
1117 let mut payload = consumer_payload(&consumer_id, &run_id);
1118 assemble_step_pointers(&state, &mut payload).await;
1119
1120 let names: Vec<&str> = payload
1121 .context
1122 .as_ref()
1123 .expect("context")
1124 .steps
1125 .iter()
1126 .map(|p| p.name.as_str())
1127 .collect();
1128 assert!(names.contains(&"planner"), "names: {names:?}");
1129 assert!(names.contains(&"coder"), "names: {names:?}");
1130 }
1131
1132 #[tokio::test]
1134 async fn context_policy_steps_include_list_filters_to_named_steps() {
1135 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1136 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1137 let task_id = TaskId::new();
1138 let run_id = RunId::new();
1139 let planner_id = StepId::new();
1140 let coder_id = StepId::new();
1141 append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1142 append_final(&data_store, coder_id.as_str(), "coder", json!("y")).await;
1143 run_store
1144 .create(run_record(
1145 &task_id,
1146 &run_id,
1147 vec![
1148 step_entry(&planner_id, "planner"),
1149 step_entry(&coder_id, "coder"),
1150 ],
1151 ))
1152 .await
1153 .expect("create run");
1154
1155 let state = test_state(data_store, run_store);
1156 let consumer_id = StepId::new();
1157 state
1158 .engine
1159 .with_state("test.seed_policy", {
1160 let consumer_id = consumer_id.clone();
1161 move |s| {
1162 s.agent_ctx.insert(
1163 (consumer_id, 1),
1164 mlua_swarm::core::state::AgentCtxEntry {
1165 policy: mlua_swarm_schema::ContextPolicy {
1166 steps: Some(vec!["planner".to_string()]),
1167 ..Default::default()
1168 },
1169 ..Default::default()
1170 },
1171 );
1172 }
1173 })
1174 .await
1175 .expect("seed policy");
1176
1177 let mut payload = consumer_payload(&consumer_id, &run_id);
1178 assemble_step_pointers(&state, &mut payload).await;
1179
1180 let names: Vec<&str> = payload
1181 .context
1182 .as_ref()
1183 .expect("context")
1184 .steps
1185 .iter()
1186 .map(|p| p.name.as_str())
1187 .collect();
1188 assert_eq!(names, vec!["planner"], "names: {names:?}");
1189 }
1190
1191 #[tokio::test]
1193 async fn context_policy_steps_empty_list_yields_no_pointers() {
1194 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1195 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1196 let task_id = TaskId::new();
1197 let run_id = RunId::new();
1198 let planner_id = StepId::new();
1199 append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1200 run_store
1201 .create(run_record(
1202 &task_id,
1203 &run_id,
1204 vec![step_entry(&planner_id, "planner")],
1205 ))
1206 .await
1207 .expect("create run");
1208
1209 let state = test_state(data_store, run_store);
1210 let consumer_id = StepId::new();
1211 state
1212 .engine
1213 .with_state("test.seed_policy", {
1214 let consumer_id = consumer_id.clone();
1215 move |s| {
1216 s.agent_ctx.insert(
1217 (consumer_id, 1),
1218 mlua_swarm::core::state::AgentCtxEntry {
1219 policy: mlua_swarm_schema::ContextPolicy {
1220 steps: Some(vec![]),
1221 ..Default::default()
1222 },
1223 ..Default::default()
1224 },
1225 );
1226 }
1227 })
1228 .await
1229 .expect("seed policy");
1230
1231 let mut payload = consumer_payload(&consumer_id, &run_id);
1232 assemble_step_pointers(&state, &mut payload).await;
1233
1234 assert!(payload.context.expect("context").steps.is_empty());
1235 }
1236
1237 #[tokio::test]
1239 async fn context_policy_steps_exclude_wins_over_steps() {
1240 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1241 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1242 let task_id = TaskId::new();
1243 let run_id = RunId::new();
1244 let planner_id = StepId::new();
1245 let coder_id = StepId::new();
1246 append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1247 append_final(&data_store, coder_id.as_str(), "coder", json!("y")).await;
1248 run_store
1249 .create(run_record(
1250 &task_id,
1251 &run_id,
1252 vec![
1253 step_entry(&planner_id, "planner"),
1254 step_entry(&coder_id, "coder"),
1255 ],
1256 ))
1257 .await
1258 .expect("create run");
1259
1260 let state = test_state(data_store, run_store);
1261 let consumer_id = StepId::new();
1262 state
1263 .engine
1264 .with_state("test.seed_policy", {
1265 let consumer_id = consumer_id.clone();
1266 move |s| {
1267 s.agent_ctx.insert(
1268 (consumer_id, 1),
1269 mlua_swarm::core::state::AgentCtxEntry {
1270 policy: mlua_swarm_schema::ContextPolicy {
1271 steps: Some(vec!["planner".to_string(), "coder".to_string()]),
1272 steps_exclude: vec!["planner".to_string()],
1273 ..Default::default()
1274 },
1275 ..Default::default()
1276 },
1277 );
1278 }
1279 })
1280 .await
1281 .expect("seed policy");
1282
1283 let mut payload = consumer_payload(&consumer_id, &run_id);
1284 assemble_step_pointers(&state, &mut payload).await;
1285
1286 let names: Vec<&str> = payload
1287 .context
1288 .as_ref()
1289 .expect("context")
1290 .steps
1291 .iter()
1292 .map(|p| p.name.as_str())
1293 .collect();
1294 assert_eq!(names, vec!["coder"], "names: {names:?}");
1295 }
1296
1297 #[tokio::test]
1306 async fn in_flight_step_output_is_visible_before_run_finalizes() {
1307 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1308 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1309 let task_id = TaskId::new();
1310 let run_id = RunId::new();
1311 let step1_id = StepId::new();
1312 append_final(
1313 &data_store,
1314 step1_id.as_str(),
1315 "step1",
1316 json!({"step1_out": "hi"}),
1317 )
1318 .await;
1319 let mut run = run_record(&task_id, &run_id, vec![step_entry(&step1_id, "step1")]);
1320 run.status = RunStatus::Running;
1321 run.result_ref = None; run_store.create(run).await.expect("create run");
1323
1324 let state = test_state(data_store, run_store);
1325 let consumer_id = StepId::new();
1326 let mut payload = consumer_payload(&consumer_id, &run_id);
1327 assemble_step_pointers(&state, &mut payload).await;
1328
1329 let steps = &payload.context.expect("context").steps;
1330 assert_eq!(steps.len(), 1);
1331 assert_eq!(steps[0].name, "step1");
1332 }
1333
1334 #[tokio::test]
1338 async fn self_agent_name_is_always_excluded() {
1339 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1340 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1341 let task_id = TaskId::new();
1342 let run_id = RunId::new();
1343 let planner_id = StepId::new();
1344 let consumer_prior_id = StepId::new();
1345 append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1346 append_final(
1347 &data_store,
1348 consumer_prior_id.as_str(),
1349 "consumer",
1350 json!("self"),
1351 )
1352 .await;
1353 run_store
1354 .create(run_record(
1355 &task_id,
1356 &run_id,
1357 vec![
1358 step_entry(&planner_id, "planner"),
1359 step_entry(&consumer_prior_id, "consumer"),
1360 ],
1361 ))
1362 .await
1363 .expect("create run");
1364
1365 let state = test_state(data_store, run_store);
1366 let consumer_id = StepId::new();
1367 let mut payload = consumer_payload(&consumer_id, &run_id);
1368 assemble_step_pointers(&state, &mut payload).await;
1369
1370 let names: Vec<&str> = payload
1371 .context
1372 .as_ref()
1373 .expect("context")
1374 .steps
1375 .iter()
1376 .map(|p| p.name.as_str())
1377 .collect();
1378 assert!(!names.contains(&"consumer"), "names: {names:?}");
1379 assert!(names.contains(&"planner"), "names: {names:?}");
1380 }
1381
1382 #[tokio::test]
1386 async fn step_pointer_serializes_with_no_preview_or_content_bytes() {
1387 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1388 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1389 let task_id = TaskId::new();
1390 let run_id = RunId::new();
1391 let planner_id = StepId::new();
1392 append_final(
1393 &data_store,
1394 planner_id.as_str(),
1395 "planner",
1396 json!({"plan": "do the thing, at length".repeat(50)}),
1397 )
1398 .await;
1399 run_store
1400 .create(run_record(
1401 &task_id,
1402 &run_id,
1403 vec![step_entry(&planner_id, "planner")],
1404 ))
1405 .await
1406 .expect("create run");
1407
1408 let state = test_state(data_store, run_store);
1409 let consumer_id = StepId::new();
1410 let mut payload = consumer_payload(&consumer_id, &run_id);
1411 assemble_step_pointers(&state, &mut payload).await;
1412
1413 let steps = &payload.context.expect("context").steps;
1414 assert_eq!(steps.len(), 1);
1415 let json_value = serde_json::to_value(&steps[0]).expect("serialize StepPointer");
1416 let obj = json_value.as_object().expect("object");
1417 for forbidden in ["preview", "content", "value", "bytes"] {
1418 assert!(
1419 !obj.contains_key(forbidden),
1420 "StepPointer must not carry a {forbidden:?} field: {obj:?}"
1421 );
1422 }
1423 assert!(obj.contains_key("name"));
1424 assert!(obj.contains_key("size_bytes"));
1425 assert!(obj.contains_key("content_url"));
1426 assert!(obj.contains_key("sha256"));
1427 }
1428
1429 fn declared_name_bp() -> mlua_swarm::blueprint::Blueprint {
1437 use mlua_flow_ir::{Expr, Node};
1438 use mlua_swarm::blueprint::{
1439 current_schema_version, AgentDef, AgentKind, AgentMeta, Blueprint, BlueprintMetadata,
1440 CompilerHints, CompilerStrategy,
1441 };
1442 Blueprint {
1443 schema_version: current_schema_version(),
1444 id: "worker-test-declared-name-bp".into(),
1445 flow: Node::Step {
1446 ref_: "planner".to_string(),
1447 in_: Expr::Path {
1448 at: "$.in".parse().expect("literal test path: $.in"),
1449 },
1450 out: Expr::Path {
1451 at: "$.plan".parse().expect("literal test path: $.plan"),
1452 },
1453 },
1454 agents: vec![AgentDef {
1455 name: "planner".to_string(),
1456 kind: AgentKind::RustFn,
1457 spec: json!({"fn_id": "planner"}),
1458 profile: None,
1459 meta: Some(AgentMeta {
1460 projection_name: Some("plan-out".to_string()),
1461 ..Default::default()
1462 }),
1463 }],
1464 operators: vec![],
1465 metas: vec![],
1466 hints: CompilerHints::default(),
1467 strategy: CompilerStrategy::default(),
1468 metadata: BlueprintMetadata::default(),
1469 spawner_hints: Default::default(),
1470 default_agent_kind: AgentKind::Operator,
1471 default_operator_kind: None,
1472 default_init_ctx: None,
1473 default_agent_ctx: None,
1474 default_context_policy: None,
1475 projection_placement: None,
1476 audits: vec![],
1477 degradation_policy: None,
1478 }
1479 }
1480
1481 #[tokio::test]
1487 async fn declared_projection_name_pointer_name_is_canonical_and_policy_matches_it() {
1488 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1489 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1490 let task_id = TaskId::new();
1491 let run_id = RunId::new();
1492 let planner_id = StepId::new();
1493
1494 append_final(
1497 &data_store,
1498 planner_id.as_str(),
1499 "plan-out",
1500 json!({"plan": "x"}),
1501 )
1502 .await;
1503 run_store
1504 .create(run_record(
1505 &task_id,
1506 &run_id,
1507 vec![step_entry(&planner_id, "planner")],
1508 ))
1509 .await
1510 .expect("create run");
1511
1512 let state = test_state(data_store, run_store);
1513
1514 let (naming, _warnings) =
1520 mlua_swarm::core::step_naming::StepNaming::from_blueprint(&declared_name_bp())
1521 .expect("no collision");
1522 let naming = Arc::new(naming);
1523 let consumer_id = StepId::new();
1524 state
1525 .engine
1526 .with_state("test.seed_step_naming", {
1527 let naming = naming.clone();
1528 let planner_id = planner_id.clone();
1529 let consumer_id = consumer_id.clone();
1530 move |s| {
1531 s.step_namings.insert(planner_id, naming.clone());
1532 s.step_namings.insert(consumer_id, naming);
1533 }
1534 })
1535 .await
1536 .expect("seed step naming");
1537 state
1538 .engine
1539 .with_state("test.seed_policy", {
1540 let consumer_id = consumer_id.clone();
1541 move |s| {
1542 s.agent_ctx.insert(
1543 (consumer_id, 1),
1544 mlua_swarm::core::state::AgentCtxEntry {
1545 policy: mlua_swarm_schema::ContextPolicy {
1546 steps: Some(vec!["plan-out".to_string()]),
1547 ..Default::default()
1548 },
1549 ..Default::default()
1550 },
1551 );
1552 }
1553 })
1554 .await
1555 .expect("seed policy");
1556
1557 let mut payload = consumer_payload(&consumer_id, &run_id);
1558 assemble_step_pointers(&state, &mut payload).await;
1559
1560 let steps = &payload.context.expect("context").steps;
1561 assert_eq!(steps.len(), 1, "steps: {steps:?}");
1562 assert_eq!(
1563 steps[0].name, "plan-out",
1564 "StepPointer.name must be the canonical name"
1565 );
1566 }
1567
1568 async fn seed_task_with_handle(
1578 state: &AppState,
1579 task_id: &StepId,
1580 agent: &str,
1581 attempt: u32,
1582 system: Option<String>,
1583 ) -> String {
1584 let handle = format!("wh-{}", mlua_swarm::types::secure_hex(4));
1585 let task_id = task_id.clone();
1586 let agent = agent.to_string();
1587 let handle_clone = handle.clone();
1588 state
1589 .engine
1590 .with_state("test.seed_task_with_handle", move |s| {
1591 let mut task = mlua_swarm::core::state::TaskState::new(
1592 task_id.clone(),
1593 mlua_swarm::core::state::TaskSpec {
1594 agent: agent.clone(),
1595 initial_directive: json!("x"),
1596 step_ctx: None,
1597 },
1598 );
1599 task.attempt = attempt;
1600 s.tasks.insert(task_id.clone(), task);
1601 s.systems.insert((task_id.clone(), attempt), system);
1602 let token = CapToken {
1603 agent_id: agent,
1604 role: mlua_swarm::Role::Worker,
1605 scopes: vec!["*".to_string()],
1606 issued_at: 0,
1607 expire_at: u64::MAX,
1608 max_uses: None,
1609 nonce: format!("test-nonce-{task_id}"),
1610 sig_hex: String::new(),
1611 };
1612 let fp = token.fingerprint();
1613 s.tokens.insert(
1614 fp.clone(),
1615 mlua_swarm::core::state::CapTokenRecord {
1616 token,
1617 uses_left: None,
1618 revoked: false,
1619 task_id: Some(task_id),
1620 },
1621 );
1622 s.worker_handles.insert(handle_clone, fp);
1623 })
1624 .await
1625 .expect("seed_task_with_handle");
1626 handle
1627 }
1628
1629 fn bearer_headers(handle: &str) -> HeaderMap {
1630 let mut headers = HeaderMap::new();
1631 headers.insert(
1632 AUTHORIZATION,
1633 format!("Bearer {handle}").parse().expect("header value"),
1634 );
1635 headers
1636 }
1637
1638 #[tokio::test]
1642 async fn worker_prompt_system_returns_raw_bytes_for_baked_system() {
1643 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1644 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1645 let state = test_state(data_store, run_store);
1646 let task_id = StepId::new();
1647 let rendered = "# Hello\n\nThis is the baked system prompt.".to_string();
1648 let handle =
1649 seed_task_with_handle(&state, &task_id, "planner", 1, Some(rendered.clone())).await;
1650
1651 let resp = worker_prompt_system(
1652 State(state.clone()),
1653 bearer_headers(&handle),
1654 Query(PromptSystemQuery {
1655 task_id: task_id.clone(),
1656 attempt: 1,
1657 }),
1658 )
1659 .await
1660 .expect("worker_prompt_system")
1661 .into_response();
1662
1663 assert_eq!(resp.status(), StatusCode::OK);
1664 let content_type = resp
1665 .headers()
1666 .get(header::CONTENT_TYPE)
1667 .expect("content-type header")
1668 .to_str()
1669 .expect("ascii");
1670 assert_eq!(content_type, "text/plain; charset=utf-8");
1671 let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1672 .await
1673 .expect("body bytes");
1674 assert_eq!(body_bytes.as_ref(), rendered.as_bytes());
1675 }
1676
1677 #[tokio::test]
1680 async fn worker_prompt_system_404s_when_no_baked_system() {
1681 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1682 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1683 let state = test_state(data_store, run_store);
1684 let task_id = StepId::new();
1685 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
1686
1687 let result = worker_prompt_system(
1688 State(state.clone()),
1689 bearer_headers(&handle),
1690 Query(PromptSystemQuery {
1691 task_id: task_id.clone(),
1692 attempt: 1,
1693 }),
1694 )
1695 .await;
1696 let err = match result {
1697 Ok(_) => panic!("expected 404 ApiError, got Ok"),
1698 Err(e) => e,
1699 };
1700 assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND);
1701 }
1702
1703 #[tokio::test]
1706 async fn worker_prompt_system_rejects_handle_task_mismatch() {
1707 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1708 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1709 let state = test_state(data_store, run_store);
1710 let task_id = StepId::new();
1711 let other_task_id = StepId::new();
1712 let handle =
1713 seed_task_with_handle(&state, &task_id, "planner", 1, Some("x".to_string())).await;
1714
1715 let result = worker_prompt_system(
1716 State(state.clone()),
1717 bearer_headers(&handle),
1718 Query(PromptSystemQuery {
1719 task_id: other_task_id,
1720 attempt: 1,
1721 }),
1722 )
1723 .await;
1724 let err = match result {
1725 Ok(_) => panic!("expected 400 ApiError for task mismatch, got Ok"),
1726 Err(e) => e,
1727 };
1728 assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
1729 }
1730
1731 #[tokio::test]
1735 async fn agent_render_size_returns_null_for_unknown_agent() {
1736 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1737 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1738 let state = test_state(data_store, run_store);
1739
1740 let Json(body) = agent_render_size(
1741 State(state.clone()),
1742 axum::extract::Path("never-dispatched".to_string()),
1743 )
1744 .await;
1745 assert_eq!(body.agent, "never-dispatched");
1746 assert_eq!(body.last_rendered_bytes, None);
1747 }
1748
1749 #[tokio::test]
1752 async fn agent_render_size_reports_last_rendered_bytes() {
1753 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1754 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1755 let state = test_state(data_store, run_store);
1756 let task_id = StepId::new();
1757 state
1758 .engine
1759 .with_state("test.seed_agent_ctx_for_bake", {
1760 let task_id = task_id.clone();
1761 move |s| {
1762 s.tasks.insert(
1763 task_id.clone(),
1764 mlua_swarm::core::state::TaskState::new(
1765 task_id,
1766 mlua_swarm::core::state::TaskSpec {
1767 agent: "coder".to_string(),
1768 initial_directive: json!("x"),
1769 step_ctx: None,
1770 },
1771 ),
1772 );
1773 }
1774 })
1775 .await
1776 .expect("seed task");
1777 state
1778 .engine
1779 .bake_worker_system_prompt(&task_id, 1, Some("z".repeat(42)))
1780 .await
1781 .expect("bake_worker_system_prompt");
1782
1783 let Json(body) = agent_render_size(
1784 State(state.clone()),
1785 axum::extract::Path("coder".to_string()),
1786 )
1787 .await;
1788 assert_eq!(body.agent, "coder");
1789 assert_eq!(body.last_rendered_bytes, Some(42));
1790 }
1791
1792 #[tokio::test]
1800 async fn worker_artifact_stages_and_204s_for_valid_request() {
1801 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1802 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1803 let state = test_state(data_store, run_store);
1804 let task_id = StepId::new();
1805 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
1806
1807 let status = worker_artifact(
1808 State(state.clone()),
1809 bearer_headers(&handle),
1810 Query(ArtifactQuery {
1811 name: "summary".to_string(),
1812 }),
1813 axum::body::Bytes::from_static(b"hello artifact\n"),
1814 )
1815 .await
1816 .expect("worker_artifact");
1817 assert_eq!(status, StatusCode::NO_CONTENT);
1818
1819 let tail = state.engine.output_tail(&task_id, 1).await;
1820 assert_eq!(tail.len(), 1, "tail: {tail:?}");
1821 match &tail[0] {
1822 OutputEvent::Artifact { name, content } => {
1823 assert_eq!(name, "summary");
1824 match content {
1825 ContentRef::Inline { value } => {
1826 assert_eq!(value, &json!("hello artifact"));
1827 }
1828 other => panic!("expected Inline content, got {other:?}"),
1829 }
1830 }
1831 other => panic!("expected Artifact event, got {other:?}"),
1832 }
1833 }
1834
1835 #[tokio::test]
1842 async fn worker_artifact_rejects_blank_name() {
1843 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1844 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1845 let state = test_state(data_store, run_store);
1846 let task_id = StepId::new();
1847 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
1848
1849 let result = worker_artifact(
1850 State(state.clone()),
1851 bearer_headers(&handle),
1852 Query(ArtifactQuery {
1853 name: " ".to_string(),
1854 }),
1855 axum::body::Bytes::from_static(b"x"),
1856 )
1857 .await;
1858 let err = match result {
1859 Ok(_) => panic!("expected 400 ApiError for blank name, got Ok"),
1860 Err(e) => e,
1861 };
1862 assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
1863
1864 assert!(state.engine.output_tail(&task_id, 1).await.is_empty());
1866 }
1867
1868 #[tokio::test]
1874 async fn worker_artifact_staging_same_name_twice_appends_both_events_in_order() {
1875 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1876 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1877 let state = test_state(data_store, run_store);
1878 let task_id = StepId::new();
1879 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
1880
1881 for body in [b"first".as_slice(), b"second".as_slice()] {
1882 worker_artifact(
1883 State(state.clone()),
1884 bearer_headers(&handle),
1885 Query(ArtifactQuery {
1886 name: "a".to_string(),
1887 }),
1888 axum::body::Bytes::copy_from_slice(body),
1889 )
1890 .await
1891 .expect("worker_artifact");
1892 }
1893
1894 let tail = state.engine.output_tail(&task_id, 1).await;
1895 assert_eq!(tail.len(), 2, "tail: {tail:?}");
1896 let values: Vec<&str> = tail
1897 .iter()
1898 .map(|ev| match ev {
1899 OutputEvent::Artifact {
1900 content: ContentRef::Inline { value },
1901 ..
1902 } => value.as_str().expect("string value"),
1903 other => panic!("expected Artifact/Inline event, got {other:?}"),
1904 })
1905 .collect();
1906 assert_eq!(values, vec!["first", "second"]);
1907 }
1908
1909 async fn link_task_to_run(state: &AppState, task_id: &StepId, attempt: u32, run_id: &RunId) {
1917 let tid = task_id.clone();
1918 let rid_str = run_id.to_string();
1919 state
1920 .engine
1921 .with_state("test.link_task_to_run", move |s| {
1922 let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
1923 entry.view.run_id = Some(rid_str);
1924 s.agent_ctx.insert((tid, attempt), entry);
1925 })
1926 .await
1927 .expect("link_task_to_run");
1928 }
1929
1930 #[tokio::test]
1935 async fn submit_and_artifact_against_terminal_run_return_410() {
1936 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1937 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1938 let state = test_state(data_store, run_store.clone());
1939 let task_id = StepId::new();
1940 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
1941
1942 let owner_task = TaskId::new();
1943 let run_id = RunId::new();
1944 let mut rec = run_record(&owner_task, &run_id, vec![]);
1945 rec.status = RunStatus::Failed;
1946 run_store.create(rec).await.expect("run create");
1947 link_task_to_run(&state, &task_id, 1, &run_id).await;
1948
1949 let err = worker_submit(
1950 State(state.clone()),
1951 bearer_headers(&handle),
1952 Query(SubmitQuery { ok: None }),
1953 axum::body::Bytes::from_static(b"LATE OUTPUT"),
1954 )
1955 .await
1956 .expect_err("a submit against a Failed run must be rejected");
1957 assert_eq!(err.status, StatusCode::GONE);
1958 assert!(
1959 err.message.contains(&run_id.to_string()),
1960 "the 410 must name the terminal run: {}",
1961 err.message
1962 );
1963
1964 let err = worker_artifact(
1965 State(state.clone()),
1966 bearer_headers(&handle),
1967 Query(ArtifactQuery {
1968 name: "part.md".to_string(),
1969 }),
1970 axum::body::Bytes::from_static(b"LATE PART"),
1971 )
1972 .await
1973 .expect_err("an artifact staged against a Failed run must be rejected");
1974 assert_eq!(err.status, StatusCode::GONE);
1975
1976 let tail = state.engine.output_tail(&task_id, 1).await;
1978 assert!(tail.is_empty(), "rejected submits must not land: {tail:?}");
1979 }
1980
1981 #[tokio::test]
1985 async fn terminal_run_guard_is_fail_open() {
1986 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1987 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1988 let state = test_state(data_store, run_store.clone());
1989 let task_id = StepId::new();
1990 seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
1991
1992 reject_if_run_terminal(&state, &task_id, 1)
1994 .await
1995 .expect("no linkage must fail open");
1996
1997 let unknown_run = RunId::new();
1999 link_task_to_run(&state, &task_id, 1, &unknown_run).await;
2000 reject_if_run_terminal(&state, &task_id, 1)
2001 .await
2002 .expect("unknown run must fail open");
2003
2004 let owner_task = TaskId::new();
2006 let live_run = RunId::new();
2007 run_store
2008 .create(run_record(&owner_task, &live_run, vec![]))
2009 .await
2010 .expect("run create");
2011 link_task_to_run(&state, &task_id, 1, &live_run).await;
2012 reject_if_run_terminal(&state, &task_id, 1)
2013 .await
2014 .expect("a Running run must pass the guard");
2015 }
2016
2017 fn degradation_body(tool: &str, note: Option<&str>) -> DegradationBody {
2022 DegradationBody {
2023 tool: tool.to_string(),
2024 error: "boom".to_string(),
2025 fallback: "used cached value".to_string(),
2026 note: note.map(str::to_string),
2027 }
2028 }
2029
2030 async fn link_task_to_run_with_agent(
2036 state: &AppState,
2037 task_id: &StepId,
2038 attempt: u32,
2039 run_id: &RunId,
2040 agent: &str,
2041 ) {
2042 let tid = task_id.clone();
2043 let rid_str = run_id.to_string();
2044 let agent = agent.to_string();
2045 state
2046 .engine
2047 .with_state("test.link_task_to_run_with_agent", move |s| {
2048 let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2049 entry.view.run_id = Some(rid_str);
2050 entry.view.agent = agent;
2051 s.agent_ctx.insert((tid, attempt), entry);
2052 })
2053 .await
2054 .expect("link_task_to_run_with_agent");
2055 }
2056
2057 #[tokio::test]
2062 async fn worker_degradation_persists_entry_when_run_tracked() {
2063 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2064 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2065 let state = test_state(data_store, run_store.clone());
2066 let task_id = StepId::new();
2067 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2068
2069 let owner_task = TaskId::new();
2070 let run_id = RunId::new();
2071 run_store
2072 .create(run_record(&owner_task, &run_id, vec![]))
2073 .await
2074 .expect("run create");
2075 link_task_to_run_with_agent(&state, &task_id, 1, &run_id, "planner").await;
2076
2077 let status = worker_degradation(
2078 State(state.clone()),
2079 bearer_headers(&handle),
2080 Json(degradation_body("web_search", Some("rate limited"))),
2081 )
2082 .await
2083 .expect("worker_degradation");
2084 assert_eq!(status, StatusCode::NO_CONTENT);
2085
2086 let rec = run_store.get(&run_id).await.expect("run get");
2087 assert_eq!(
2088 rec.degradations.len(),
2089 1,
2090 "degradations: {:?}",
2091 rec.degradations
2092 );
2093 let entry = &rec.degradations[0];
2094 assert_eq!(entry.tool, "web_search");
2095 assert_eq!(entry.error, "boom");
2096 assert_eq!(entry.fallback, "used cached value");
2097 assert_eq!(entry.note.as_deref(), Some("rate limited"));
2098 assert_eq!(entry.step_ref.as_deref(), Some("planner"));
2099 assert_eq!(entry.attempt, Some(1));
2100 assert!(entry.at > 0, "at must be a real timestamp: {}", entry.at);
2101 }
2102
2103 #[tokio::test]
2105 async fn worker_degradation_appends_in_order() {
2106 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2107 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2108 let state = test_state(data_store, run_store.clone());
2109 let task_id = StepId::new();
2110 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2111
2112 let owner_task = TaskId::new();
2113 let run_id = RunId::new();
2114 run_store
2115 .create(run_record(&owner_task, &run_id, vec![]))
2116 .await
2117 .expect("run create");
2118 link_task_to_run(&state, &task_id, 1, &run_id).await;
2119
2120 for tool in ["first_tool", "second_tool"] {
2121 worker_degradation(
2122 State(state.clone()),
2123 bearer_headers(&handle),
2124 Json(degradation_body(tool, None)),
2125 )
2126 .await
2127 .expect("worker_degradation");
2128 }
2129
2130 let rec = run_store.get(&run_id).await.expect("run get");
2131 let tools: Vec<&str> = rec.degradations.iter().map(|e| e.tool.as_str()).collect();
2132 assert_eq!(tools, vec!["first_tool", "second_tool"]);
2133 }
2134
2135 #[tokio::test]
2139 async fn worker_degradation_silent_ok_when_no_run_tracked() {
2140 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2141 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2142 let state = test_state(data_store, run_store);
2143 let task_id = StepId::new();
2144 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2145
2146 let status = worker_degradation(
2147 State(state.clone()),
2148 bearer_headers(&handle),
2149 Json(degradation_body("some_tool", None)),
2150 )
2151 .await
2152 .expect("worker_degradation must not error on missing run linkage");
2153 assert_eq!(status, StatusCode::NO_CONTENT);
2154 }
2155
2156 #[tokio::test]
2159 async fn worker_degradation_rejects_terminal_run() {
2160 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2161 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2162 let state = test_state(data_store, run_store.clone());
2163 let task_id = StepId::new();
2164 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2165
2166 let owner_task = TaskId::new();
2167 let run_id = RunId::new();
2168 let mut rec = run_record(&owner_task, &run_id, vec![]);
2169 rec.status = RunStatus::Done;
2170 run_store.create(rec).await.expect("run create");
2171 link_task_to_run(&state, &task_id, 1, &run_id).await;
2172
2173 let err = worker_degradation(
2174 State(state.clone()),
2175 bearer_headers(&handle),
2176 Json(degradation_body("some_tool", None)),
2177 )
2178 .await
2179 .expect_err("a degradation against a Done run must be rejected");
2180 assert_eq!(err.status, StatusCode::GONE);
2181
2182 let rec = run_store.get(&run_id).await.expect("run get");
2183 assert!(
2184 rec.degradations.is_empty(),
2185 "rejected degradation must not land: {:?}",
2186 rec.degradations
2187 );
2188 }
2189
2190 async fn seed_work_dir(
2204 state: &AppState,
2205 task_id: &StepId,
2206 attempt: u32,
2207 work_dir: &str,
2208 allow_file_submit: Option<Value>,
2209 ) {
2210 let tid = task_id.clone();
2211 let work_dir = work_dir.to_string();
2212 state
2213 .engine
2214 .with_state("test.seed_work_dir", move |s| {
2215 let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2216 entry.view.work_dir = Some(work_dir);
2217 if let Some(v) = allow_file_submit {
2218 entry
2219 .view
2220 .extra
2221 .insert(FILE_SENTINEL_ALLOW_KEY.to_string(), v);
2222 }
2223 s.agent_ctx.insert((tid, attempt), entry);
2224 })
2225 .await
2226 .expect("seed_work_dir");
2227 }
2228
2229 #[tokio::test]
2233 async fn worker_submit_resolves_file_sentinel_under_work_dir() {
2234 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2235 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2236 let state = test_state(data_store.clone(), run_store);
2237 let task_id = StepId::new();
2238 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2239
2240 let tmp = tempfile::tempdir().expect("tempdir");
2241 let work_dir = tmp.path().to_path_buf();
2242 seed_work_dir(
2243 &state,
2244 &task_id,
2245 1,
2246 work_dir.to_str().expect("work_dir utf-8"),
2247 Some(Value::Bool(true)),
2248 )
2249 .await;
2250
2251 let payload_path = work_dir.join("scout.md");
2252 let payload = "## Context Package (broad)\n\nlarge body content\n";
2253 tokio::fs::write(&payload_path, payload)
2254 .await
2255 .expect("write payload");
2256 let body = format!(
2257 "@file:{}",
2258 payload_path.to_str().expect("payload path utf-8")
2259 );
2260
2261 let status = worker_submit(
2262 State(state.clone()),
2263 bearer_headers(&handle),
2264 Query(SubmitQuery { ok: None }),
2265 axum::body::Bytes::from(body),
2266 )
2267 .await
2268 .expect("worker_submit sentinel");
2269 assert_eq!(status, StatusCode::NO_CONTENT);
2270
2271 let tid = task_id.clone();
2275 let value = state
2276 .engine
2277 .with_state("test.inspect_output_store", move |s| {
2278 s.output_store.get(&(tid.clone(), 1)).and_then(|evs| {
2279 evs.iter().find_map(|ev| match ev {
2280 OutputEvent::Final {
2281 content: ContentRef::Inline { value },
2282 ..
2283 } => Some(value.clone()),
2284 _ => None,
2285 })
2286 })
2287 })
2288 .await
2289 .expect("with_state")
2290 .expect("Final event present");
2291 assert_eq!(value, Value::String(payload.trim_end().to_string()));
2292 }
2293
2294 #[tokio::test]
2297 async fn worker_submit_passes_non_sentinel_body_unchanged() {
2298 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2299 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2300 let state = test_state(data_store.clone(), run_store);
2301 let task_id = StepId::new();
2302 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2303 let status = worker_submit(
2307 State(state.clone()),
2308 bearer_headers(&handle),
2309 Query(SubmitQuery { ok: None }),
2310 axum::body::Bytes::from_static(b"DONE yes=1 maybe=0 no=0"),
2311 )
2312 .await
2313 .expect("worker_submit inline");
2314 assert_eq!(status, StatusCode::NO_CONTENT);
2315
2316 let tid = task_id.clone();
2317 let value = state
2318 .engine
2319 .with_state("test.inspect_output_store", move |s| {
2320 s.output_store.get(&(tid.clone(), 1)).and_then(|evs| {
2321 evs.iter().find_map(|ev| match ev {
2322 OutputEvent::Final {
2323 content: ContentRef::Inline { value },
2324 ..
2325 } => Some(value.clone()),
2326 _ => None,
2327 })
2328 })
2329 })
2330 .await
2331 .expect("with_state")
2332 .expect("Final event present");
2333 assert_eq!(value, Value::String("DONE yes=1 maybe=0 no=0".to_string()));
2334 }
2335
2336 #[tokio::test]
2341 async fn worker_submit_rejects_sentinel_path_outside_work_dir() {
2342 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2343 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2344 let state = test_state(data_store, run_store);
2345 let task_id = StepId::new();
2346 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2347
2348 let allowed = tempfile::tempdir().expect("allowed tempdir");
2349 let outside = tempfile::tempdir().expect("outside tempdir");
2350 seed_work_dir(
2351 &state,
2352 &task_id,
2353 1,
2354 allowed.path().to_str().expect("utf-8"),
2355 Some(Value::Bool(true)),
2356 )
2357 .await;
2358
2359 let outside_file = outside.path().join("leak.md");
2360 tokio::fs::write(&outside_file, b"outside content")
2361 .await
2362 .expect("write outside");
2363 let body = format!(
2364 "@file:{}",
2365 outside_file.to_str().expect("outside path utf-8")
2366 );
2367
2368 let err = worker_submit(
2369 State(state.clone()),
2370 bearer_headers(&handle),
2371 Query(SubmitQuery { ok: None }),
2372 axum::body::Bytes::from(body),
2373 )
2374 .await
2375 .expect_err("outside-work_dir sentinel must be rejected");
2376 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2377 }
2378
2379 #[tokio::test]
2381 async fn worker_submit_rejects_sentinel_missing_file() {
2382 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2383 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2384 let state = test_state(data_store, run_store);
2385 let task_id = StepId::new();
2386 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2387
2388 let tmp = tempfile::tempdir().expect("tempdir");
2389 seed_work_dir(
2390 &state,
2391 &task_id,
2392 1,
2393 tmp.path().to_str().expect("utf-8"),
2394 Some(Value::Bool(true)),
2395 )
2396 .await;
2397 let missing = tmp.path().join("does-not-exist.md");
2398 let body = format!("@file:{}", missing.to_str().expect("utf-8"));
2399
2400 let err = worker_submit(
2401 State(state.clone()),
2402 bearer_headers(&handle),
2403 Query(SubmitQuery { ok: None }),
2404 axum::body::Bytes::from(body),
2405 )
2406 .await
2407 .expect_err("missing-file sentinel must be rejected");
2408 assert_eq!(err.status, StatusCode::NOT_FOUND);
2409 }
2410
2411 #[tokio::test]
2413 async fn worker_submit_rejects_sentinel_relative_path() {
2414 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2415 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2416 let state = test_state(data_store, run_store);
2417 let task_id = StepId::new();
2418 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2419
2420 let err = worker_submit(
2421 State(state.clone()),
2422 bearer_headers(&handle),
2423 Query(SubmitQuery { ok: None }),
2424 axum::body::Bytes::from_static(b"@file:relative/path.md"),
2425 )
2426 .await
2427 .expect_err("relative-path sentinel must be rejected");
2428 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2429 }
2430
2431 #[tokio::test]
2435 async fn worker_submit_rejects_sentinel_without_agent_context_view() {
2436 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2437 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2438 let state = test_state(data_store, run_store);
2439 let task_id = StepId::new();
2440 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2441 let err = worker_submit(
2444 State(state.clone()),
2445 bearer_headers(&handle),
2446 Query(SubmitQuery { ok: None }),
2447 axum::body::Bytes::from_static(b"@file:/tmp/anywhere.md"),
2448 )
2449 .await
2450 .expect_err("missing AgentContextView must reject sentinel");
2451 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2452 }
2453
2454 #[tokio::test]
2458 async fn worker_artifact_resolves_file_sentinel_under_work_dir() {
2459 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2460 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2461 let state = test_state(data_store, run_store);
2462 let task_id = StepId::new();
2463 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2464
2465 let tmp = tempfile::tempdir().expect("tempdir");
2466 seed_work_dir(
2467 &state,
2468 &task_id,
2469 1,
2470 tmp.path().to_str().expect("utf-8"),
2471 Some(Value::Bool(true)),
2472 )
2473 .await;
2474
2475 let payload_path = tmp.path().join("part.md");
2476 let payload = "artifact part body\n";
2477 tokio::fs::write(&payload_path, payload)
2478 .await
2479 .expect("write payload");
2480 let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
2481
2482 let status = worker_artifact(
2483 State(state.clone()),
2484 bearer_headers(&handle),
2485 Query(ArtifactQuery {
2486 name: "scout".to_string(),
2487 }),
2488 axum::body::Bytes::from(body),
2489 )
2490 .await
2491 .expect("worker_artifact sentinel");
2492 assert_eq!(status, StatusCode::NO_CONTENT);
2493 }
2494
2495 #[tokio::test]
2500 async fn worker_submit_rejects_sentinel_without_allow_flag() {
2501 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2502 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2503 let state = test_state(data_store, run_store);
2504 let task_id = StepId::new();
2505 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2506
2507 let tmp = tempfile::tempdir().expect("tempdir");
2508 seed_work_dir(&state, &task_id, 1, tmp.path().to_str().expect("utf-8"), None).await;
2509
2510 let payload_path = tmp.path().join("out.md");
2511 tokio::fs::write(&payload_path, b"resolvable body")
2512 .await
2513 .expect("write payload");
2514 let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
2515
2516 let err = worker_submit(
2517 State(state.clone()),
2518 bearer_headers(&handle),
2519 Query(SubmitQuery { ok: None }),
2520 axum::body::Bytes::from(body),
2521 )
2522 .await
2523 .expect_err("missing opt-in must reject sentinel");
2524 assert_eq!(err.status, StatusCode::BAD_REQUEST);
2525 assert!(
2526 err.message.contains("not allowed"),
2527 "rejection must name the opt-in guard, got: {}",
2528 err.message
2529 );
2530 }
2531
2532 #[tokio::test]
2535 async fn worker_submit_rejects_sentinel_with_non_true_allow_values() {
2536 for allow in [Value::Bool(false), Value::String("true".to_string())] {
2537 let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2538 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2539 let state = test_state(data_store, run_store);
2540 let task_id = StepId::new();
2541 let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2542
2543 let tmp = tempfile::tempdir().expect("tempdir");
2544 seed_work_dir(
2545 &state,
2546 &task_id,
2547 1,
2548 tmp.path().to_str().expect("utf-8"),
2549 Some(allow.clone()),
2550 )
2551 .await;
2552
2553 let payload_path = tmp.path().join("out.md");
2554 tokio::fs::write(&payload_path, b"resolvable body")
2555 .await
2556 .expect("write payload");
2557 let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
2558
2559 let err = worker_submit(
2560 State(state.clone()),
2561 bearer_headers(&handle),
2562 Query(SubmitQuery { ok: None }),
2563 axum::body::Bytes::from(body),
2564 )
2565 .await
2566 .expect_err("non-true opt-in value must reject sentinel");
2567 assert_eq!(err.status, StatusCode::BAD_REQUEST, "value: {allow:?}");
2568 }
2569 }
2570}