1use axum::{
110 extract::{Path, Query, State},
111 http::{header, HeaderMap, HeaderValue, StatusCode},
112 response::IntoResponse,
113 Json,
114};
115use mlua_swarm::core::engine::Engine;
116use mlua_swarm::core::projection::{
117 ProjectionAdapter, ProjectionError, ProjectionKey, ProjectionRef,
118};
119use mlua_swarm::core::projection_placement::ProjectionPlacement;
120use mlua_swarm::core::step_naming::StepNaming;
121use mlua_swarm::store::output::{ContentRef, OutputEvent, OutputStore, OutputStoreError};
122use mlua_swarm::store::run::{RunRecord, RunStore};
123use mlua_swarm::{RunId, StepId, TaskId};
124use serde::{Deserialize, Serialize};
125use serde_json::Value;
126use sha2::Digest as _;
127use std::sync::Arc;
128
129use crate::tasks::map_task_store_err;
130use crate::{ApiError, AppState};
131
132pub struct McpQueryAdapter {
140 data_store: Arc<dyn OutputStore>,
141 run_store: Arc<dyn RunStore>,
142 engine: Engine,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
149#[serde(rename_all = "snake_case")]
150pub enum ProjectionSource {
151 DataPlane,
154 ResultRef,
157}
158
159#[derive(Debug, Clone)]
165pub(crate) struct ResolvedStep {
166 pub(crate) name: String,
169 pub(crate) value: Value,
171 pub(crate) source: ProjectionSource,
173}
174
175fn final_value(event: &OutputEvent) -> Option<Value> {
181 match event {
182 OutputEvent::Final { content, .. } => Some(content_to_value(content)),
183 _ => None,
184 }
185}
186
187fn content_to_value(content: &ContentRef) -> Value {
192 match content {
193 ContentRef::Inline { value } => value.clone(),
194 ContentRef::FileRef {
195 path,
196 mime,
197 size_hint,
198 } => serde_json::json!({
199 "file_ref": path.to_string_lossy(),
200 "mime": mime,
201 "size_hint": size_hint,
202 }),
203 }
204}
205
206fn find_step_id_for_canonical(
215 run: &RunRecord,
216 naming: Option<&StepNaming>,
217 canonical: &str,
218) -> Option<StepId> {
219 run.step_entries
220 .iter()
221 .rev()
222 .find(|entry| {
223 let Some(step_ref) = entry.step_ref.as_deref() else {
224 return false;
225 };
226 match naming {
227 Some(n) => n.canonical_of_producer(step_ref) == Some(canonical),
228 None => step_ref == canonical,
229 }
230 })
231 .map(|entry| entry.step_id.clone())
232}
233
234fn candidate_names<'a>(
243 naming: Option<&'a StepNaming>,
244 canonical: &'a str,
245 raw_step: &'a str,
246) -> Vec<&'a str> {
247 let mut names = vec![canonical];
248 if let Some(entry) = naming.and_then(|n| n.entries().find(|e| e.canonical == canonical)) {
249 for alias in &entry.aliases {
250 if alias != canonical {
251 names.push(alias.as_str());
252 }
253 }
254 }
255 if !names.contains(&raw_step) {
256 names.push(raw_step);
257 }
258 names
259}
260
261impl McpQueryAdapter {
262 pub fn new(
267 data_store: Arc<dyn OutputStore>,
268 run_store: Arc<dyn RunStore>,
269 engine: Engine,
270 ) -> Self {
271 Self {
272 data_store,
273 run_store,
274 engine,
275 }
276 }
277
278 async fn step_naming_for_run(&self, run: &RunRecord) -> Option<Arc<StepNaming>> {
294 resolve_step_naming_for_run(&self.engine, run).await
295 }
296
297 pub(crate) async fn resolve_step_name(&self, run: &RunRecord, raw: &str) -> String {
305 match self.step_naming_for_run(run).await {
306 Some(naming) => naming.resolve(raw).unwrap_or(raw).to_string(),
307 None => raw.to_string(),
308 }
309 }
310
311 async fn resolve_run(
319 &self,
320 task_id: &TaskId,
321 run_id: Option<&str>,
322 ) -> Result<RunRecord, ProjectionError> {
323 match run_id {
324 Some(rid) => {
325 let run_id = RunId::parse(rid.to_string())
326 .map_err(|e| ProjectionError::InvalidKey(format!("run_id: {e}")))?;
327 let run = self.run_store.get(&run_id).await.map_err(|_| {
328 ProjectionError::NotFound(ProjectionKey {
329 task_id: task_id.to_string(),
330 run_id: Some(rid.to_string()),
331 step: None,
332 path: None,
333 })
334 })?;
335 if &run.task_id != task_id {
336 return Err(ProjectionError::NotFound(ProjectionKey {
337 task_id: task_id.to_string(),
338 run_id: Some(rid.to_string()),
339 step: None,
340 path: None,
341 }));
342 }
343 Ok(run)
344 }
345 None => {
346 let mut runs = self.run_store.list_by_task(task_id).await.map_err(|_| {
347 ProjectionError::NotFound(ProjectionKey {
348 task_id: task_id.to_string(),
349 run_id: None,
350 step: None,
351 path: None,
352 })
353 })?;
354 runs.pop().ok_or_else(|| {
355 ProjectionError::NotFound(ProjectionKey {
356 task_id: task_id.to_string(),
357 run_id: None,
358 step: None,
359 path: None,
360 })
361 })
362 }
363 }
364 }
365
366 async fn resolve_async(
386 &self,
387 key: &ProjectionKey,
388 ) -> Result<(RunRecord, Value), ProjectionError> {
389 let task_id = TaskId::parse(key.task_id.clone())
390 .map_err(|e| ProjectionError::InvalidKey(format!("task_id: {e}")))?;
391 let run = self.resolve_run(&task_id, key.run_id.as_deref()).await?;
392
393 let Some(raw_step) = &key.step else {
394 let ctx_data = run.result_ref.clone().unwrap_or(Value::Null);
397 let value = key
398 .resolve(&ctx_data)
399 .cloned()
400 .ok_or_else(|| ProjectionError::NotFound(key.clone()))?;
401 return Ok((run, value));
402 };
403
404 let naming = self.step_naming_for_run(&run).await;
405 let canonical = naming
406 .as_deref()
407 .and_then(|n| n.resolve(raw_step))
408 .unwrap_or(raw_step.as_str())
409 .to_string();
410
411 if let Some(step_id) = find_step_id_for_canonical(&run, naming.as_deref(), &canonical) {
413 match self
414 .data_store
415 .get_latest_by_name_in_run(step_id.as_str(), 1, &canonical)
416 .await
417 {
418 Ok(record) => {
419 if let Some(value) = final_value(&record.event) {
420 let narrowed = match &key.path {
421 None => Some(value),
422 Some(_) => {
423 let path_only = ProjectionKey {
429 task_id: key.task_id.clone(),
430 run_id: key.run_id.clone(),
431 step: None,
432 path: key.path.clone(),
433 };
434 path_only.resolve(&value).cloned()
435 }
436 };
437 if let Some(value) = narrowed {
438 return Ok((run, value));
439 }
440 }
441 }
442 Err(OutputStoreError::NotFound(_)) => {
443 }
446 Err(other) => {
447 return Err(ProjectionError::Io(std::io::Error::other(format!(
448 "OutputStore::get_latest_by_name_in_run: {other}"
449 ))));
450 }
451 }
452 }
453
454 let ctx_data = run.result_ref.clone().unwrap_or(Value::Null);
459 for candidate in candidate_names(naming.as_deref(), &canonical, raw_step) {
460 let candidate_key = ProjectionKey {
461 task_id: key.task_id.clone(),
462 run_id: key.run_id.clone(),
463 step: Some(candidate.to_string()),
464 path: key.path.clone(),
465 };
466 if let Some(value) = candidate_key.resolve(&ctx_data) {
467 return Ok((run, value.clone()));
468 }
469 }
470 Err(ProjectionError::NotFound(key.clone()))
471 }
472
473 pub(crate) async fn list_steps(
479 &self,
480 task_id: &TaskId,
481 run_id: Option<&str>,
482 ) -> Result<(RunRecord, Vec<ResolvedStep>), ProjectionError> {
483 let run = self.resolve_run(task_id, run_id).await?;
484 let steps = self.enumerate_steps(&run).await;
485 Ok((run, steps))
486 }
487
488 pub(crate) async fn list_steps_by_run_id(
496 &self,
497 run_id: &RunId,
498 ) -> Result<(RunRecord, Vec<ResolvedStep>), ProjectionError> {
499 let run = self.run_store.get(run_id).await.map_err(|_| {
500 ProjectionError::NotFound(ProjectionKey {
501 task_id: String::new(),
502 run_id: Some(run_id.to_string()),
503 step: None,
504 path: None,
505 })
506 })?;
507 let steps = self.enumerate_steps(&run).await;
508 Ok((run, steps))
509 }
510
511 async fn enumerate_steps(&self, run: &RunRecord) -> Vec<ResolvedStep> {
518 match self.step_naming_for_run(run).await {
519 Some(naming) => self.enumerate_steps_via_table(run, &naming).await,
520 None => self.enumerate_steps_legacy_union(run).await,
521 }
522 }
523
524 async fn enumerate_steps_via_table(
543 &self,
544 run: &RunRecord,
545 naming: &StepNaming,
546 ) -> Vec<ResolvedStep> {
547 let mut resolved: std::collections::BTreeMap<String, ResolvedStep> =
548 std::collections::BTreeMap::new();
549
550 for entry in &run.step_entries {
551 let Some(step_ref) = entry.step_ref.as_deref() else {
552 continue;
553 };
554 let canonical = naming
555 .canonical_of_producer(step_ref)
556 .unwrap_or(step_ref)
557 .to_string();
558 if let Ok(record) = self
559 .data_store
560 .get_latest_by_name_in_run(entry.step_id.as_str(), 1, &canonical)
561 .await
562 {
563 if let Some(value) = final_value(&record.event) {
564 resolved.insert(
565 canonical.clone(),
566 ResolvedStep {
567 name: canonical,
568 value,
569 source: ProjectionSource::DataPlane,
570 },
571 );
572 }
573 }
574 }
575
576 if let Some(Value::Object(map)) = &run.result_ref {
577 for entry in naming.entries() {
578 if resolved.contains_key(&entry.canonical) {
579 continue;
580 }
581 let hit = entry
582 .aliases
583 .iter()
584 .find_map(|alias| map.get(alias))
585 .or_else(|| map.get(&entry.canonical));
586 if let Some(value) = hit {
587 resolved.insert(
588 entry.canonical.clone(),
589 ResolvedStep {
590 name: entry.canonical.clone(),
591 value: value.clone(),
592 source: ProjectionSource::ResultRef,
593 },
594 );
595 }
596 }
597 }
598
599 resolved.into_values().collect()
600 }
601
602 async fn enumerate_steps_legacy_union(&self, run: &RunRecord) -> Vec<ResolvedStep> {
613 let mut out = Vec::new();
614 let mut attempted = std::collections::HashSet::new();
615 let mut resolved_names = std::collections::HashSet::new();
616
617 for entry in &run.step_entries {
618 let Some(name) = &entry.step_ref else {
619 continue;
620 };
621 if !attempted.insert(name.clone()) {
622 continue;
623 }
624 if let Ok(record) = self.data_store.get_latest_by_name(name).await {
625 if let Some(value) = final_value(&record.event) {
626 out.push(ResolvedStep {
627 name: name.clone(),
628 value,
629 source: ProjectionSource::DataPlane,
630 });
631 resolved_names.insert(name.clone());
632 }
633 }
634 }
635
636 if let Some(Value::Object(map)) = &run.result_ref {
637 for (name, value) in map {
638 if resolved_names.contains(name) {
639 continue;
640 }
641 out.push(ResolvedStep {
642 name: name.clone(),
643 value: value.clone(),
644 source: ProjectionSource::ResultRef,
645 });
646 }
647 }
648
649 out
650 }
651}
652
653impl ProjectionAdapter for McpQueryAdapter {
654 fn name(&self) -> &'static str {
655 "mcp-query"
656 }
657
658 fn project(
667 &self,
668 key: &ProjectionKey,
669 ctx_data: &Value,
670 ) -> Result<ProjectionRef, ProjectionError> {
671 if key.task_id.is_empty() {
672 return Err(ProjectionError::InvalidKey(
673 "task_id must not be empty".to_string(),
674 ));
675 }
676 key.resolve(ctx_data)
677 .ok_or_else(|| ProjectionError::NotFound(key.clone()))?;
678 Ok(ProjectionRef::Query {
679 endpoint: format!(
680 "/v1/tasks/{}/runs/{}/steps/{}/content",
681 key.task_id,
682 key.run_id.as_deref().unwrap_or("latest"),
683 key.step.as_deref().unwrap_or("_ctx")
684 ),
685 key: key.clone(),
686 })
687 }
688
689 fn fetch(&self, key: &ProjectionKey) -> Result<Value, ProjectionError> {
690 let handle = tokio::runtime::Handle::try_current().map_err(|e| {
695 ProjectionError::Io(std::io::Error::other(format!(
696 "McpQueryAdapter::fetch requires a Tokio runtime: {e}"
697 )))
698 })?;
699 let (_run, value) =
700 tokio::task::block_in_place(|| handle.block_on(self.resolve_async(key)))?;
701 Ok(value)
702 }
703
704 fn pointer_line(&self, r: &ProjectionRef) -> String {
705 match r {
706 ProjectionRef::Query { endpoint, key } => {
707 format!("projection(mcp-query): {endpoint} task_id={}", key.task_id)
708 }
709 ProjectionRef::File { path } => format!("projection(file): {path}"),
710 }
711 }
712}
713
714#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
720pub struct StepList {
721 pub task_id: String,
723 pub run_id: String,
726 pub steps: Vec<StepSummary>,
730}
731
732#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
736pub struct StepSummary {
737 pub name: String,
739 pub size_bytes: u64,
742 pub content_type: String,
747 pub sha256: String,
750 pub source: ProjectionSource,
752 #[serde(default, skip_serializing_if = "Option::is_none")]
760 pub file_path: Option<String>,
761 pub content_url: String,
766 pub preview: String,
769 pub truncated: bool,
773}
774
775#[derive(Debug, Deserialize, Default, schemars::JsonSchema)]
780pub struct StepPathQuery {
781 #[serde(default)]
784 pub path: Option<String>,
785}
786
787fn narrow_step_value(value: &Value, path: Option<&str>) -> Option<Value> {
791 match path {
792 None => Some(value.clone()),
793 Some(p) => {
794 let path_only = ProjectionKey {
795 task_id: String::new(),
796 run_id: None,
797 step: None,
798 path: Some(p.to_string()),
799 };
800 path_only.resolve(value).cloned()
801 }
802 }
803}
804
805fn materialized_file_path(
812 placement: &ProjectionPlacement,
813 root: &str,
814 step_id: &StepId,
815 name: &str,
816) -> std::path::PathBuf {
817 placement.target_path(root, step_id.as_ref(), name)
818}
819
820async fn resolve_materialized_file(
840 state: &AppState,
841 run: &RunRecord,
842 name: &str,
843) -> Option<(std::path::PathBuf, Vec<u8>)> {
844 let naming = resolve_step_naming_for_run(&state.engine, run).await;
845 let step_id = find_step_id_for_canonical(run, naming.as_deref(), name)?;
846 let view = state.engine.agent_context_for(&step_id, 1).await?;
847 let placement = state
848 .engine
849 .projection_placement_for(&step_id)
850 .await
851 .unwrap_or_default();
852 let root = placement.resolve_root(&view)?;
853 let path = materialized_file_path(&placement, &root, &step_id, name);
854 let bytes = std::fs::read(&path).ok()?;
855 Some((path, bytes))
856}
857
858async fn resolve_step_naming_for_run(engine: &Engine, run: &RunRecord) -> Option<Arc<StepNaming>> {
863 for entry in &run.step_entries {
864 if let Some(naming) = engine.step_naming_for(&entry.step_id).await {
865 return Some(naming);
866 }
867 }
868 None
869}
870
871async fn render_step_body(
878 state: &AppState,
879 run: &RunRecord,
880 step: &ResolvedStep,
881 path: Option<&str>,
882) -> Option<(Vec<u8>, &'static str, Option<String>)> {
883 if path.is_none() {
884 if let Some((file_path, bytes)) = resolve_materialized_file(state, run, &step.name).await {
885 return Some((
886 bytes,
887 "text/markdown; charset=utf-8",
888 Some(file_path.to_string_lossy().into_owned()),
889 ));
890 }
891 }
892 let narrowed = narrow_step_value(&step.value, path)?;
893 let body = serde_json::to_vec_pretty(&narrowed).ok()?;
894 Some((body, "application/json", None))
895}
896
897fn build_preview(body: &[u8]) -> (String, bool) {
904 const MAX_PREVIEW_BYTES: usize = 512;
905 if body.len() <= MAX_PREVIEW_BYTES {
906 return (String::from_utf8_lossy(body).into_owned(), false);
907 }
908 let preview = match std::str::from_utf8(body) {
909 Ok(s) => {
910 let mut end = MAX_PREVIEW_BYTES;
911 while end > 0 && !s.is_char_boundary(end) {
912 end -= 1;
913 }
914 s[..end].to_string()
915 }
916 Err(_) => String::from_utf8_lossy(&body[..MAX_PREVIEW_BYTES]).into_owned(),
917 };
918 (format!("{preview}…"), true)
919}
920
921fn build_content_url(
927 base_url: &Option<Arc<str>>,
928 task_id: &TaskId,
929 run_id: &RunId,
930 name: &str,
931 path: Option<&str>,
932) -> String {
933 let mut url = format!("/v1/tasks/{task_id}/runs/{run_id}/steps/{name}/content");
934 if let Some(p) = path {
935 url.push_str("?path=");
936 url.push_str(p);
937 }
938 match base_url {
939 Some(base) => format!("{}{}", base.trim_end_matches('/'), url),
940 None => url,
941 }
942}
943
944async fn build_step_summary(
947 state: &AppState,
948 run: &RunRecord,
949 step: &ResolvedStep,
950 path: Option<&str>,
951) -> Option<StepSummary> {
952 let (body, content_type, file_path) = render_step_body(state, run, step, path).await?;
953 let sha256 = hex::encode(sha2::Sha256::digest(&body));
954 let size_bytes = body.len() as u64;
955 let (preview, truncated) = build_preview(&body);
956 let content_url = build_content_url(&state.base_url, &run.task_id, &run.id, &step.name, path);
957 Some(StepSummary {
958 name: step.name.clone(),
959 size_bytes,
960 content_type: content_type.to_string(),
961 sha256,
962 source: step.source,
963 file_path,
964 content_url,
965 preview,
966 truncated,
967 })
968}
969
970pub(crate) async fn resolve_step_pointer_fields(
981 state: &AppState,
982 run: &RunRecord,
983 step: &ResolvedStep,
984) -> Option<(u64, Option<String>, String, String)> {
985 let (body, _content_type, file_path) = render_step_body(state, run, step, None).await?;
986 let sha256 = hex::encode(sha2::Sha256::digest(&body));
987 let size_bytes = body.len() as u64;
988 let content_url = build_content_url(&state.base_url, &run.task_id, &run.id, &step.name, None);
989 Some((size_bytes, file_path, content_url, sha256))
990}
991
992async fn resolve_run_and_steps(
1001 state: &AppState,
1002 id: &str,
1003 run: &str,
1004) -> Result<(McpQueryAdapter, RunRecord, Vec<ResolvedStep>), ApiError> {
1005 let task_id = TaskId::parse(id.to_string())
1006 .map_err(|e| ApiError::bad_request(format!("invalid task id: {e}")))?;
1007 state
1008 .task_store
1009 .get(&task_id)
1010 .await
1011 .map_err(map_task_store_err)?;
1012 let adapter = McpQueryAdapter::new(
1013 state.data_store.clone(),
1014 state.run_store.clone(),
1015 state.engine.clone(),
1016 );
1017 let run_sel = if run == "latest" { None } else { Some(run) };
1018 let (run_record, steps) = adapter
1019 .list_steps(&task_id, run_sel)
1020 .await
1021 .map_err(map_projection_err)?;
1022 Ok((adapter, run_record, steps))
1023}
1024
1025pub async fn steps_list(
1028 State(state): State<AppState>,
1029 Path((id, run)): Path<(String, String)>,
1030) -> Result<Json<StepList>, ApiError> {
1031 let (_adapter, run_record, steps) = resolve_run_and_steps(&state, &id, &run).await?;
1032 let mut summaries = Vec::with_capacity(steps.len());
1033 for step in &steps {
1034 if let Some(summary) = build_step_summary(&state, &run_record, step, None).await {
1035 summaries.push(summary);
1036 }
1037 }
1038 Ok(Json(StepList {
1039 task_id: run_record.task_id.to_string(),
1040 run_id: run_record.id.to_string(),
1041 steps: summaries,
1042 }))
1043}
1044
1045pub async fn step_get(
1051 State(state): State<AppState>,
1052 Path((id, run, step)): Path<(String, String, String)>,
1053 Query(q): Query<StepPathQuery>,
1054) -> Result<Json<StepSummary>, ApiError> {
1055 let (adapter, run_record, steps) = resolve_run_and_steps(&state, &id, &run).await?;
1056 let canonical = adapter.resolve_step_name(&run_record, &step).await;
1057 let resolved = steps
1058 .into_iter()
1059 .find(|s| s.name == canonical)
1060 .ok_or_else(|| ApiError::not_found(format!("step not found: {step}")))?;
1061 let summary = build_step_summary(&state, &run_record, &resolved, q.path.as_deref())
1062 .await
1063 .ok_or_else(|| ApiError::not_found(format!("path not found: {:?}", q.path)))?;
1064 Ok(Json(summary))
1065}
1066
1067pub async fn step_content(
1073 State(state): State<AppState>,
1074 Path((id, run, step)): Path<(String, String, String)>,
1075 Query(q): Query<StepPathQuery>,
1076) -> Result<impl IntoResponse, ApiError> {
1077 let (adapter, run_record, steps) = resolve_run_and_steps(&state, &id, &run).await?;
1078 let canonical = adapter.resolve_step_name(&run_record, &step).await;
1079 let resolved = steps
1080 .into_iter()
1081 .find(|s| s.name == canonical)
1082 .ok_or_else(|| ApiError::not_found(format!("step not found: {step}")))?;
1083 let (body, content_type, _file_path) =
1084 render_step_body(&state, &run_record, &resolved, q.path.as_deref())
1085 .await
1086 .ok_or_else(|| ApiError::not_found(format!("path not found: {:?}", q.path)))?;
1087 let sha256 = hex::encode(sha2::Sha256::digest(&body));
1088 let mut headers = HeaderMap::new();
1089 headers.insert(
1090 header::CONTENT_TYPE,
1091 HeaderValue::from_str(content_type).expect("content_type is a static ASCII literal"),
1092 );
1093 headers.insert(
1094 header::ETAG,
1095 HeaderValue::from_str(&format!("\"sha256:{sha256}\""))
1096 .expect("hex digest is ASCII-safe for a header value"),
1097 );
1098 Ok((StatusCode::OK, headers, body))
1099}
1100
1101fn map_projection_err(e: ProjectionError) -> ApiError {
1102 match e {
1103 ProjectionError::NotFound(key) => {
1104 ApiError::not_found(format!("projection not found for key {key:?}"))
1105 }
1106 ProjectionError::InvalidKey(msg) => ApiError::bad_request(msg),
1107 other => ApiError::engine(other),
1108 }
1109}
1110
1111#[cfg(test)]
1116mod tests {
1117 use super::*;
1118 use crate::TaskLaunchRequest;
1119 use axum::http::StatusCode;
1120 use mlua_swarm::application::BlueprintRef;
1121 use mlua_swarm::blueprint::{
1122 current_schema_version, AgentDef, AgentKind, AgentMeta, Blueprint, BlueprintMetadata,
1123 CompilerHints, CompilerStrategy, ProjectionPlacementSpec,
1124 };
1125 use mlua_swarm::core::config::EngineCfg;
1126 use mlua_swarm::core::engine::Engine;
1127 use mlua_swarm::store::output::InMemoryOutputStore;
1128 use mlua_swarm::store::run::InMemoryRunStore;
1129 use mlua_swarm::store::task::InMemoryTaskStore;
1130 use serde_json::json;
1131 use std::collections::HashMap;
1132 use tokio::sync::Mutex;
1133
1134 fn greeting_blueprint() -> Blueprint {
1142 Blueprint {
1143 schema_version: current_schema_version(),
1144 id: "projection-test-greeting-bp".into(),
1145 flow: serde_json::from_value(json!({
1146 "kind": "step",
1147 "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
1148 "in": {"op": "path", "at": "$.greeting"},
1149 "out": {"op": "path", "at": "$.out"},
1150 }))
1151 .expect("flow parse"),
1152 agents: vec![AgentDef {
1153 name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
1154 kind: AgentKind::RustFn,
1155 spec: json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
1156 profile: None,
1157 meta: None,
1158 }],
1159 operators: vec![],
1160 metas: vec![],
1161 hints: CompilerHints::default(),
1162 strategy: CompilerStrategy::default(),
1163 metadata: BlueprintMetadata::default(),
1164 spawner_hints: Default::default(),
1165 default_agent_kind: AgentKind::Operator,
1166 default_operator_kind: None,
1167 default_init_ctx: None,
1168 default_agent_ctx: None,
1169 default_context_policy: None,
1170 projection_placement: None,
1171 }
1172 }
1173
1174 fn test_state() -> AppState {
1175 let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
1176 let compiler = mlua_swarm::Compiler::new(crate::default_registry());
1177 let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
1178 let data_store: Arc<dyn mlua_swarm::store::output::OutputStore> =
1179 Arc::new(InMemoryOutputStore::new());
1180 engine.set_output_store(data_store.clone());
1186 AppState {
1187 engine,
1188 sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
1189 task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
1190 ws_operator_factory: None,
1191 data_store,
1192 operator_sessions: Arc::new(Mutex::new(HashMap::new())),
1193 roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
1194 task_store: Arc::new(InMemoryTaskStore::new()),
1195 run_store: Arc::new(InMemoryRunStore::new()),
1196 base_url: None,
1197 }
1198 }
1199
1200 fn greeting_task_req(greeting: &str) -> TaskLaunchRequest {
1201 TaskLaunchRequest {
1202 blueprint: BlueprintRef::Inline {
1203 value: Box::new(greeting_blueprint()),
1204 },
1205 init_ctx: json!({ "greeting": greeting }),
1206 project_root: None,
1207 work_dir: None,
1208 task_metadata: None,
1209 ttl_secs: None,
1210 operator: None,
1211 operator_sid: None,
1212 goal: Some("projection test goal".to_string()),
1213 }
1214 }
1215
1216 fn declared_projection_name_blueprint(projection_name: &str) -> Blueprint {
1222 Blueprint {
1223 schema_version: current_schema_version(),
1224 id: "projection-test-declared-name-bp".into(),
1225 flow: serde_json::from_value(json!({
1226 "kind": "step",
1227 "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
1228 "in": {"op": "path", "at": "$.greeting"},
1229 "out": {"op": "path", "at": "$.out"},
1230 }))
1231 .expect("flow parse"),
1232 agents: vec![AgentDef {
1233 name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
1234 kind: AgentKind::RustFn,
1235 spec: json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
1236 profile: None,
1237 meta: Some(AgentMeta {
1238 projection_name: Some(projection_name.to_string()),
1239 ..Default::default()
1240 }),
1241 }],
1242 operators: vec![],
1243 metas: vec![],
1244 hints: CompilerHints::default(),
1245 strategy: CompilerStrategy::default(),
1246 metadata: BlueprintMetadata::default(),
1247 spawner_hints: Default::default(),
1248 default_agent_kind: AgentKind::Operator,
1249 default_operator_kind: None,
1250 default_init_ctx: None,
1251 default_agent_ctx: None,
1252 default_context_policy: None,
1253 projection_placement: None,
1254 }
1255 }
1256
1257 fn declared_task_req(greeting: &str, projection_name: &str) -> TaskLaunchRequest {
1258 TaskLaunchRequest {
1259 blueprint: BlueprintRef::Inline {
1260 value: Box::new(declared_projection_name_blueprint(projection_name)),
1261 },
1262 init_ctx: json!({ "greeting": greeting }),
1263 project_root: None,
1264 work_dir: None,
1265 task_metadata: None,
1266 ttl_secs: None,
1267 operator: None,
1268 operator_sid: None,
1269 goal: Some("projection test goal (declared name)".to_string()),
1270 }
1271 }
1272
1273 #[tokio::test]
1283 async fn steps_list_undeclared_step_resolves_to_single_canonical_entry() {
1284 let state = test_state();
1285 let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req("hello")))
1286 .await
1287 .expect("tasks_start")
1288 .0;
1289
1290 let resp = steps_list(
1291 State(state.clone()),
1292 Path((posted.task_id.to_string(), "latest".to_string())),
1293 )
1294 .await
1295 .expect("steps_list")
1296 .0;
1297
1298 assert_eq!(resp.task_id, posted.task_id.to_string());
1299 assert_eq!(resp.run_id, posted.run_id.to_string());
1300 let identity_name = mlua_swarm::worker::baseline::AG_IDENTITY;
1301 assert_eq!(resp.steps.len(), 1, "steps: {:?}", resp.steps);
1302 let entry = &resp.steps[0];
1303 assert_eq!(entry.name, identity_name);
1304 assert_eq!(entry.source, ProjectionSource::DataPlane);
1305 }
1306
1307 #[tokio::test]
1312 async fn step_get_resolves_alias_name_to_canonical_entry() {
1313 let state = test_state();
1314 let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req("hi")))
1315 .await
1316 .expect("tasks_start")
1317 .0;
1318
1319 let identity_name = mlua_swarm::worker::baseline::AG_IDENTITY;
1320 let via_ref = step_get(
1321 State(state.clone()),
1322 Path((
1323 posted.task_id.to_string(),
1324 "latest".to_string(),
1325 identity_name.to_string(),
1326 )),
1327 Query(StepPathQuery::default()),
1328 )
1329 .await
1330 .expect("step_get via own ref name")
1331 .0;
1332 let via_alias = step_get(
1333 State(state.clone()),
1334 Path((
1335 posted.task_id.to_string(),
1336 "latest".to_string(),
1337 "out".to_string(),
1338 )),
1339 Query(StepPathQuery::default()),
1340 )
1341 .await
1342 .expect("step_get via out-top alias")
1343 .0;
1344
1345 assert_eq!(via_ref.name, identity_name);
1346 assert_eq!(
1347 via_alias.name, identity_name,
1348 "alias lookup must report the canonical name"
1349 );
1350 assert_eq!(
1351 via_ref.sha256, via_alias.sha256,
1352 "same OUTPUT regardless of which name was queried"
1353 );
1354 }
1355
1356 #[tokio::test]
1361 async fn declared_projection_name_e2e_resolves_via_canonical_and_alias() {
1362 let state = test_state();
1363 let posted = crate::tasks_start(
1364 State(state.clone()),
1365 Json(declared_task_req("hi", "plan-out")),
1366 )
1367 .await
1368 .expect("tasks_start")
1369 .0;
1370
1371 let list = steps_list(
1372 State(state.clone()),
1373 Path((posted.task_id.to_string(), "latest".to_string())),
1374 )
1375 .await
1376 .expect("steps_list")
1377 .0;
1378 assert_eq!(list.steps.len(), 1, "steps: {:?}", list.steps);
1379 assert_eq!(list.steps[0].name, "plan-out");
1380 assert_eq!(list.steps[0].source, ProjectionSource::DataPlane);
1381
1382 let by_canonical = step_get(
1383 State(state.clone()),
1384 Path((
1385 posted.task_id.to_string(),
1386 "latest".to_string(),
1387 "plan-out".to_string(),
1388 )),
1389 Query(StepPathQuery::default()),
1390 )
1391 .await
1392 .expect("step_get canonical")
1393 .0;
1394 assert_eq!(by_canonical.name, "plan-out");
1395
1396 let identity_name = mlua_swarm::worker::baseline::AG_IDENTITY;
1397 let by_ref_alias = step_get(
1398 State(state.clone()),
1399 Path((
1400 posted.task_id.to_string(),
1401 "latest".to_string(),
1402 identity_name.to_string(),
1403 )),
1404 Query(StepPathQuery::default()),
1405 )
1406 .await
1407 .expect("step_get ref alias")
1408 .0;
1409 assert_eq!(by_ref_alias.name, "plan-out");
1410 assert_eq!(by_ref_alias.sha256, by_canonical.sha256);
1411
1412 let by_out_alias = step_get(
1413 State(state.clone()),
1414 Path((
1415 posted.task_id.to_string(),
1416 "latest".to_string(),
1417 "out".to_string(),
1418 )),
1419 Query(StepPathQuery::default()),
1420 )
1421 .await
1422 .expect("step_get out-top alias")
1423 .0;
1424 assert_eq!(by_out_alias.name, "plan-out");
1425 assert_eq!(by_out_alias.sha256, by_canonical.sha256);
1426 }
1427
1428 #[tokio::test]
1435 async fn declared_projection_name_materialized_file_stem_is_canonical() {
1436 let dir = tempfile::TempDir::new().unwrap();
1437 let state = test_state();
1438 let mut req = declared_task_req("materialized-declared", "plan-out");
1439 req.work_dir = Some(dir.path().to_string_lossy().into_owned());
1440 let posted = crate::tasks_start(State(state.clone()), Json(req))
1441 .await
1442 .expect("tasks_start")
1443 .0;
1444
1445 let summary = step_get(
1446 State(state.clone()),
1447 Path((
1448 posted.task_id.to_string(),
1449 "latest".to_string(),
1450 "plan-out".to_string(),
1451 )),
1452 Query(StepPathQuery::default()),
1453 )
1454 .await
1455 .expect("step_get")
1456 .0;
1457
1458 let file_path = summary.file_path.expect("materialized file_path present");
1459 assert!(
1460 file_path.ends_with("plan-out.md"),
1461 "materialized file stem must be the canonical name: {file_path}"
1462 );
1463 }
1464
1465 #[tokio::test]
1477 async fn declared_projection_placement_e2e_write_and_read_back_converge() {
1478 let project_root_dir = tempfile::TempDir::new().unwrap();
1479 let state = test_state();
1480 let mut bp = declared_projection_name_blueprint("plan-out");
1481 bp.projection_placement = Some(ProjectionPlacementSpec {
1482 root: Some("project_root".to_string()),
1483 dir_template: Some("custom/{task_id}/out".to_string()),
1484 });
1485 let req = TaskLaunchRequest {
1486 blueprint: BlueprintRef::Inline {
1487 value: Box::new(bp),
1488 },
1489 init_ctx: json!({ "greeting": "materialized-custom-placement" }),
1490 project_root: Some(project_root_dir.path().to_string_lossy().into_owned()),
1491 work_dir: None,
1492 task_metadata: None,
1493 ttl_secs: None,
1494 operator: None,
1495 operator_sid: None,
1496 goal: Some("projection placement test goal".to_string()),
1497 };
1498 let posted = crate::tasks_start(State(state.clone()), Json(req))
1499 .await
1500 .expect("tasks_start")
1501 .0;
1502
1503 let summary = step_get(
1504 State(state.clone()),
1505 Path((
1506 posted.task_id.to_string(),
1507 "latest".to_string(),
1508 "plan-out".to_string(),
1509 )),
1510 Query(StepPathQuery::default()),
1511 )
1512 .await
1513 .expect("step_get")
1514 .0;
1515
1516 let file_path = summary.file_path.expect("materialized file_path present");
1525 let path = std::path::Path::new(&file_path);
1526 assert!(
1527 path.starts_with(project_root_dir.path()),
1528 "file must be rooted at project_root (root_preference=ProjectRoot): {file_path}"
1529 );
1530 assert!(
1531 file_path.ends_with("out/plan-out.md"),
1532 "file must follow the custom dir_template's tail: {file_path}"
1533 );
1534 assert!(
1535 file_path.contains("/custom/"),
1536 "file must follow the custom dir_template's prefix segment: {file_path}"
1537 );
1538 assert!(
1539 path.exists(),
1540 "the write side must have materialized the file the read-back reports: {file_path}"
1541 );
1542 }
1543
1544 #[tokio::test]
1551 async fn declared_projection_name_colliding_with_another_steps_ref_is_rejected_at_register_time(
1552 ) {
1553 use mlua_flow_ir::{Expr, Node as FlowNode};
1554 use mlua_swarm::worker::adapter::WorkerResult;
1555 use mlua_swarm::{RustFnInProcessSpawnerFactory, SpawnerRegistry};
1556
1557 let factory = RustFnInProcessSpawnerFactory::new()
1558 .register_fn("step-a", |inv| async move {
1559 Ok(WorkerResult {
1560 value: json!(inv.prompt),
1561 ok: true,
1562 })
1563 })
1564 .register_fn("step-b", |inv| async move {
1565 Ok(WorkerResult {
1566 value: json!(inv.prompt),
1567 ok: true,
1568 })
1569 });
1570 let mut reg = SpawnerRegistry::new();
1571 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
1572
1573 let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
1574 let data_store: Arc<dyn mlua_swarm::store::output::OutputStore> =
1575 Arc::new(InMemoryOutputStore::new());
1576 engine.set_output_store(data_store.clone());
1577 let compiler = mlua_swarm::Compiler::new(reg);
1578 let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
1579 let state = AppState {
1580 engine,
1581 sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
1582 task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
1583 ws_operator_factory: None,
1584 data_store,
1585 operator_sessions: Arc::new(Mutex::new(HashMap::new())),
1586 roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
1587 task_store: Arc::new(InMemoryTaskStore::new()),
1588 run_store: Arc::new(InMemoryRunStore::new()),
1589 base_url: None,
1590 };
1591
1592 let flow = FlowNode::Seq {
1593 children: vec![
1594 FlowNode::Step {
1595 ref_: "step-a".to_string(),
1596 in_: Expr::Path {
1597 at: "$.greeting".to_string(),
1598 },
1599 out: Expr::Path {
1600 at: "$.a_out".to_string(),
1601 },
1602 },
1603 FlowNode::Step {
1604 ref_: "step-b".to_string(),
1605 in_: Expr::Path {
1606 at: "$.greeting".to_string(),
1607 },
1608 out: Expr::Path {
1609 at: "$.b_out".to_string(),
1610 },
1611 },
1612 ],
1613 };
1614 let blueprint = Blueprint {
1615 schema_version: current_schema_version(),
1616 id: "projection-test-collision-bp".into(),
1617 flow,
1618 agents: vec![
1619 AgentDef {
1620 name: "step-a".into(),
1621 kind: AgentKind::RustFn,
1622 spec: json!({"fn_id": "step-a"}),
1623 profile: None,
1624 meta: Some(AgentMeta {
1627 projection_name: Some("step-b".to_string()),
1628 ..Default::default()
1629 }),
1630 },
1631 AgentDef {
1632 name: "step-b".into(),
1633 kind: AgentKind::RustFn,
1634 spec: json!({"fn_id": "step-b"}),
1635 profile: None,
1636 meta: None,
1637 },
1638 ],
1639 operators: vec![],
1640 metas: vec![],
1641 hints: CompilerHints::default(),
1642 strategy: CompilerStrategy::default(),
1643 metadata: BlueprintMetadata::default(),
1644 spawner_hints: Default::default(),
1645 default_agent_kind: AgentKind::Operator,
1646 default_operator_kind: None,
1647 default_init_ctx: None,
1648 default_agent_ctx: None,
1649 default_context_policy: None,
1650 projection_placement: None,
1651 };
1652
1653 let req = TaskLaunchRequest {
1654 blueprint: BlueprintRef::Inline {
1655 value: Box::new(blueprint),
1656 },
1657 init_ctx: json!({ "greeting": "hi" }),
1658 project_root: None,
1659 work_dir: None,
1660 task_metadata: None,
1661 ttl_secs: None,
1662 operator: None,
1663 operator_sid: None,
1664 goal: None,
1665 };
1666
1667 let result = crate::tasks_start(State(state), Json(req)).await;
1671 let err = match result {
1672 Err(e) => e,
1673 Ok(_) => {
1674 panic!("declared projection_name colliding with another step's own ref must reject")
1675 }
1676 };
1677 assert_eq!(err.status, StatusCode::BAD_REQUEST);
1678 }
1679
1680 #[tokio::test]
1689 async fn steps_list_run_scoped_lookup_does_not_bleed_across_tasks_sharing_a_producer_name() {
1690 let state = test_state();
1691 let first = crate::tasks_start(State(state.clone()), Json(greeting_task_req("first-task")))
1692 .await
1693 .expect("first tasks_start")
1694 .0;
1695 let second =
1696 crate::tasks_start(State(state.clone()), Json(greeting_task_req("second-task")))
1697 .await
1698 .expect("second tasks_start")
1699 .0;
1700
1701 let first_steps = steps_list(
1702 State(state.clone()),
1703 Path((first.task_id.to_string(), "latest".to_string())),
1704 )
1705 .await
1706 .expect("first steps_list")
1707 .0;
1708 let second_steps = steps_list(
1709 State(state.clone()),
1710 Path((second.task_id.to_string(), "latest".to_string())),
1711 )
1712 .await
1713 .expect("second steps_list")
1714 .0;
1715
1716 let identity_name = mlua_swarm::worker::baseline::AG_IDENTITY;
1717 let first_entry = first_steps
1718 .steps
1719 .iter()
1720 .find(|s| s.name == identity_name)
1721 .expect("first entry present");
1722 let second_entry = second_steps
1723 .steps
1724 .iter()
1725 .find(|s| s.name == identity_name)
1726 .expect("second entry present");
1727 assert_eq!(first_entry.source, ProjectionSource::DataPlane);
1728 assert_eq!(second_entry.source, ProjectionSource::DataPlane);
1729 assert_ne!(
1730 first_entry.sha256, second_entry.sha256,
1731 "each Task's own greeting must resolve, not the globally-latest submission"
1732 );
1733 }
1734
1735 #[tokio::test]
1738 async fn steps_list_latest_resolves_newest_run_explicit_pin_still_works() {
1739 let state = test_state();
1740 let first = crate::tasks_start(State(state.clone()), Json(greeting_task_req("first")))
1741 .await
1742 .expect("tasks_start")
1743 .0;
1744 let (status, rekicked) = crate::tasks::task_rekick(
1745 State(state.clone()),
1746 Path(first.task_id.to_string()),
1747 Some(Json(crate::tasks::RunKickRequest {
1748 init_ctx_override: Some(json!({ "greeting": "second" })),
1749 task_input_override: None,
1750 })),
1751 )
1752 .await
1753 .expect("task_rekick");
1754 assert_eq!(status, StatusCode::CREATED);
1755
1756 let latest = steps_list(
1757 State(state.clone()),
1758 Path((first.task_id.to_string(), "latest".to_string())),
1759 )
1760 .await
1761 .expect("steps_list latest")
1762 .0;
1763 assert_eq!(latest.run_id, rekicked.0.run_id.to_string());
1764
1765 let pinned = steps_list(
1766 State(state.clone()),
1767 Path((first.task_id.to_string(), first.run_id.to_string())),
1768 )
1769 .await
1770 .expect("steps_list pinned")
1771 .0;
1772 assert_eq!(pinned.run_id, first.run_id.to_string());
1773 }
1774
1775 #[tokio::test]
1778 async fn step_get_preview_is_utf8_boundary_safe_and_truncated_flag_is_correct() {
1779 let state = test_state();
1780 let long_value = "あ".repeat(300); let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req(&long_value)))
1785 .await
1786 .expect("tasks_start")
1787 .0;
1788
1789 let identity_name = mlua_swarm::worker::baseline::AG_IDENTITY;
1790 let summary = step_get(
1791 State(state.clone()),
1792 Path((
1793 posted.task_id.to_string(),
1794 "latest".to_string(),
1795 identity_name.to_string(),
1796 )),
1797 Query(StepPathQuery::default()),
1798 )
1799 .await
1800 .expect("step_get")
1801 .0;
1802
1803 assert!(
1804 summary.preview.len() <= 512 + "…".len(),
1805 "preview must stay near the 512-byte cap: {} bytes",
1806 summary.preview.len()
1807 );
1808 assert!(
1809 summary.truncated,
1810 "a 900-byte body must be reported truncated"
1811 );
1812 assert!(
1813 summary.preview.ends_with('…'),
1814 "truncated preview must end with an ellipsis: {}",
1815 summary.preview
1816 );
1817 assert!(summary.preview.chars().all(|c| c != '\u{FFFD}'));
1823 }
1824
1825 #[tokio::test]
1828 async fn step_content_in_memory_fallback_is_json_with_matching_etag() {
1829 let state = test_state();
1830 let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req("hi")))
1831 .await
1832 .expect("tasks_start")
1833 .0;
1834
1835 let resp = step_content(
1836 State(state.clone()),
1837 Path((
1838 posted.task_id.to_string(),
1839 "latest".to_string(),
1840 "out".to_string(),
1841 )),
1842 Query(StepPathQuery::default()),
1843 )
1844 .await
1845 .expect("step_content")
1846 .into_response();
1847
1848 assert_eq!(resp.status(), StatusCode::OK);
1849 let content_type = resp
1850 .headers()
1851 .get(header::CONTENT_TYPE)
1852 .expect("content-type header")
1853 .to_str()
1854 .expect("ascii");
1855 assert_eq!(content_type, "application/json");
1856 let etag = resp
1857 .headers()
1858 .get(header::ETAG)
1859 .expect("etag header")
1860 .to_str()
1861 .expect("ascii")
1862 .to_string();
1863 let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1864 .await
1865 .expect("body bytes");
1866 let expected_sha = hex::encode(sha2::Sha256::digest(&body_bytes));
1867 assert_eq!(etag, format!("\"sha256:{expected_sha}\""));
1868 let parsed: Value = serde_json::from_slice(&body_bytes).expect("valid json body");
1869 assert_eq!(parsed["echoed"], json!("hi"));
1870 }
1871
1872 #[tokio::test]
1877 async fn step_content_materialized_file_is_served_as_markdown() {
1878 let dir = tempfile::TempDir::new().unwrap();
1879 let state = test_state();
1880 let mut req = greeting_task_req("materialized");
1881 req.work_dir = Some(dir.path().to_string_lossy().into_owned());
1882 let posted = crate::tasks_start(State(state.clone()), Json(req))
1883 .await
1884 .expect("tasks_start")
1885 .0;
1886
1887 let identity_name = mlua_swarm::worker::baseline::AG_IDENTITY;
1888 let resp = step_content(
1889 State(state.clone()),
1890 Path((
1891 posted.task_id.to_string(),
1892 "latest".to_string(),
1893 identity_name.to_string(),
1894 )),
1895 Query(StepPathQuery::default()),
1896 )
1897 .await
1898 .expect("step_content")
1899 .into_response();
1900
1901 assert_eq!(resp.status(), StatusCode::OK);
1902 let content_type = resp
1903 .headers()
1904 .get(header::CONTENT_TYPE)
1905 .expect("content-type header")
1906 .to_str()
1907 .expect("ascii");
1908 assert_eq!(content_type, "text/markdown; charset=utf-8");
1909 let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1910 .await
1911 .expect("body bytes");
1912 let body_str = String::from_utf8(body_bytes.to_vec()).expect("utf8 body");
1913 assert!(
1914 body_str.contains("```json"),
1915 "materialized file must carry the fenced json block: {body_str}"
1916 );
1917 }
1918
1919 #[tokio::test]
1922 async fn step_content_path_narrow_returns_json_fragment() {
1923 let state = test_state();
1924 let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req("narrowed")))
1925 .await
1926 .expect("tasks_start")
1927 .0;
1928
1929 let resp = step_content(
1930 State(state.clone()),
1931 Path((
1932 posted.task_id.to_string(),
1933 "latest".to_string(),
1934 "out".to_string(),
1935 )),
1936 Query(StepPathQuery {
1937 path: Some("echoed".to_string()),
1938 }),
1939 )
1940 .await
1941 .expect("step_content narrowed")
1942 .into_response();
1943
1944 assert_eq!(resp.status(), StatusCode::OK);
1945 let content_type = resp
1946 .headers()
1947 .get(header::CONTENT_TYPE)
1948 .expect("content-type header")
1949 .to_str()
1950 .expect("ascii");
1951 assert_eq!(content_type, "application/json");
1952 let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1953 .await
1954 .expect("body bytes");
1955 let parsed: Value = serde_json::from_slice(&body_bytes).expect("valid json body");
1956 assert_eq!(parsed, json!("narrowed"));
1957 }
1958
1959 #[tokio::test]
1962 async fn steps_list_unknown_task_returns_404() {
1963 let state = test_state();
1964 let err = steps_list(
1965 State(state),
1966 Path(("T-does-not-exist".to_string(), "latest".to_string())),
1967 )
1968 .await
1969 .expect_err("unknown task must 404");
1970 assert_eq!(err.status, StatusCode::NOT_FOUND);
1971 }
1972
1973 #[tokio::test]
1974 async fn steps_list_unknown_run_returns_404() {
1975 let state = test_state();
1976 let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req("hi")))
1977 .await
1978 .expect("tasks_start")
1979 .0;
1980 let err = steps_list(
1981 State(state),
1982 Path((posted.task_id.to_string(), "R-does-not-exist".to_string())),
1983 )
1984 .await
1985 .expect_err("unknown run must 404");
1986 assert_eq!(err.status, StatusCode::NOT_FOUND);
1987 }
1988
1989 #[tokio::test]
1990 async fn step_get_unknown_step_returns_404() {
1991 let state = test_state();
1992 let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req("hi")))
1993 .await
1994 .expect("tasks_start")
1995 .0;
1996 let err = step_get(
1997 State(state),
1998 Path((
1999 posted.task_id.to_string(),
2000 "latest".to_string(),
2001 "does-not-exist".to_string(),
2002 )),
2003 Query(StepPathQuery::default()),
2004 )
2005 .await
2006 .expect_err("unknown step must 404");
2007 assert_eq!(err.status, StatusCode::NOT_FOUND);
2008 }
2009
2010 #[tokio::test]
2013 async fn old_ctx_route_returns_404_not_found_by_router() {
2014 let engine = Engine::new(EngineCfg::default());
2015 let router = mlua_swarm_server_router_for_test(engine);
2016 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2017 .await
2018 .expect("bind ephemeral port");
2019 let addr = listener.local_addr().expect("local addr");
2020 tokio::spawn(async move {
2021 let _ = axum::serve(listener, router).await;
2022 });
2023 let client = reqwest::Client::new();
2024 let resp = client
2025 .get(format!("http://{addr}/v1/tasks/T-anything/ctx"))
2026 .send()
2027 .await
2028 .expect("request");
2029 assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
2030 }
2031
2032 fn mlua_swarm_server_router_for_test(engine: Engine) -> axum::Router {
2036 crate::build_router(engine)
2037 }
2038
2039 #[test]
2042 fn mcp_query_adapter_project_builds_query_ref() {
2043 let adapter = McpQueryAdapter::new(
2044 Arc::new(InMemoryOutputStore::new()),
2045 Arc::new(InMemoryRunStore::new()),
2046 Engine::new(EngineCfg::default()),
2047 );
2048 let key = ProjectionKey {
2049 task_id: "T-abc".to_string(),
2050 run_id: None,
2051 step: Some("planner".to_string()),
2052 path: None,
2053 };
2054 let ctx_data = json!({"planner": {"plan": "do it"}});
2055 let reference = adapter.project(&key, &ctx_data).expect("project");
2056 match &reference {
2057 ProjectionRef::Query { endpoint, key: k } => {
2058 assert!(endpoint.contains("/steps/planner/content"));
2059 assert_eq!(k, &key);
2060 }
2061 other => panic!("expected Query ref, got {other:?}"),
2062 }
2063 let line = adapter.pointer_line(&reference);
2064 assert!(line.contains("T-abc"));
2065 }
2066
2067 #[test]
2068 fn mcp_query_adapter_project_rejects_key_not_present_in_ctx_data() {
2069 let adapter = McpQueryAdapter::new(
2070 Arc::new(InMemoryOutputStore::new()),
2071 Arc::new(InMemoryRunStore::new()),
2072 Engine::new(EngineCfg::default()),
2073 );
2074 let key = ProjectionKey {
2075 task_id: "T-abc".to_string(),
2076 run_id: None,
2077 step: Some("missing".to_string()),
2078 path: None,
2079 };
2080 let err = adapter.project(&key, &json!({"planner": {}})).unwrap_err();
2081 assert!(matches!(err, ProjectionError::NotFound(_)));
2082 }
2083
2084 #[tokio::test(flavor = "multi_thread")]
2085 async fn mcp_query_adapter_fetch_bridges_to_resolve_async() {
2086 let state = test_state();
2087 let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req("bridged")))
2088 .await
2089 .expect("tasks_start")
2090 .0;
2091
2092 let adapter = McpQueryAdapter::new(
2093 state.data_store.clone(),
2094 state.run_store.clone(),
2095 state.engine.clone(),
2096 );
2097 let key = ProjectionKey {
2098 task_id: posted.task_id.to_string(),
2099 run_id: None,
2100 step: Some("out".to_string()),
2101 path: Some("echoed".to_string()),
2102 };
2103 let value = adapter.fetch(&key).expect("fetch");
2111 assert_eq!(value, json!("bridged"));
2112 }
2113
2114 #[tokio::test]
2127 async fn resolve_async_path_narrows_within_data_plane_final_content() {
2128 let state = test_state();
2129 let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req("hi")))
2130 .await
2131 .expect("tasks_start")
2132 .0;
2133
2134 let adapter = McpQueryAdapter::new(
2135 state.data_store.clone(),
2136 state.run_store.clone(),
2137 state.engine.clone(),
2138 );
2139 let key = ProjectionKey {
2140 task_id: posted.task_id.to_string(),
2141 run_id: None,
2142 step: Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string()),
2143 path: Some("echoed".to_string()),
2144 };
2145 let (_run, value) = adapter.resolve_async(&key).await.expect("resolve_async");
2146 assert_eq!(value, json!("hi"));
2147 }
2148
2149 #[tokio::test(flavor = "multi_thread")]
2160 async fn steps_list_returns_in_flight_step_output_before_run_completes() {
2161 use mlua_flow_ir::{Expr, Node as FlowNode};
2162 use mlua_swarm::worker::adapter::WorkerResult;
2163 use mlua_swarm::{RustFnInProcessSpawnerFactory, SpawnerRegistry};
2164
2165 let started = Arc::new(tokio::sync::Notify::new());
2166 let gate = Arc::new(tokio::sync::Notify::new());
2167 let started_bg = started.clone();
2168 let gate_bg = gate.clone();
2169
2170 let factory = RustFnInProcessSpawnerFactory::new()
2171 .register_fn("step1", |inv| async move {
2172 Ok(WorkerResult {
2173 value: json!({ "step1_out": inv.prompt }),
2174 ok: true,
2175 })
2176 })
2177 .register_fn("step2", move |_inv| {
2178 let started = started_bg.clone();
2179 let gate = gate_bg.clone();
2180 async move {
2181 started.notify_one();
2182 gate.notified().await;
2183 Ok(WorkerResult {
2184 value: json!("step2 done"),
2185 ok: true,
2186 })
2187 }
2188 });
2189 let mut reg = SpawnerRegistry::new();
2190 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
2191
2192 let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
2193 let data_store: Arc<dyn mlua_swarm::store::output::OutputStore> =
2194 Arc::new(InMemoryOutputStore::new());
2195 engine.set_output_store(data_store.clone());
2196 let compiler = mlua_swarm::Compiler::new(reg);
2197 let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
2198 let state = AppState {
2199 engine,
2200 sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
2201 task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
2202 ws_operator_factory: None,
2203 data_store,
2204 operator_sessions: Arc::new(Mutex::new(HashMap::new())),
2205 roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
2206 task_store: Arc::new(InMemoryTaskStore::new()),
2207 run_store: Arc::new(InMemoryRunStore::new()),
2208 base_url: None,
2209 };
2210
2211 let flow = FlowNode::Seq {
2212 children: vec![
2213 FlowNode::Step {
2214 ref_: "step1".to_string(),
2215 in_: Expr::Path {
2216 at: "$.greeting".to_string(),
2217 },
2218 out: Expr::Path {
2219 at: "$.step1".to_string(),
2220 },
2221 },
2222 FlowNode::Step {
2223 ref_: "step2".to_string(),
2224 in_: Expr::Path {
2225 at: "$.step1".to_string(),
2226 },
2227 out: Expr::Path {
2228 at: "$.step2".to_string(),
2229 },
2230 },
2231 ],
2232 };
2233 let blueprint = Blueprint {
2234 schema_version: current_schema_version(),
2235 id: "projection-test-in-flight-bp".into(),
2236 flow,
2237 agents: vec![
2238 AgentDef {
2239 name: "step1".into(),
2240 kind: AgentKind::RustFn,
2241 spec: json!({"fn_id": "step1"}),
2242 profile: None,
2243 meta: None,
2244 },
2245 AgentDef {
2246 name: "step2".into(),
2247 kind: AgentKind::RustFn,
2248 spec: json!({"fn_id": "step2"}),
2249 profile: None,
2250 meta: None,
2251 },
2252 ],
2253 operators: vec![],
2254 metas: vec![],
2255 hints: CompilerHints::default(),
2256 strategy: CompilerStrategy::default(),
2257 metadata: BlueprintMetadata::default(),
2258 spawner_hints: Default::default(),
2259 default_agent_kind: AgentKind::Operator,
2260 default_operator_kind: None,
2261 default_init_ctx: None,
2262 default_agent_ctx: None,
2263 default_context_policy: None,
2264 projection_placement: None,
2265 };
2266
2267 let req = TaskLaunchRequest {
2268 blueprint: BlueprintRef::Inline {
2269 value: Box::new(blueprint),
2270 },
2271 init_ctx: json!({ "greeting": "hi" }),
2272 project_root: None,
2273 work_dir: None,
2274 task_metadata: None,
2275 ttl_secs: None,
2276 operator: None,
2277 operator_sid: None,
2278 goal: None,
2279 };
2280
2281 let state_bg = state.clone();
2282 let launch_handle =
2283 tokio::spawn(async move { crate::tasks_start(State(state_bg), Json(req)).await });
2284
2285 started.notified().await;
2289
2290 let in_flight_tasks = state.task_store.list().await.expect("task_store list");
2291 assert_eq!(in_flight_tasks.len(), 1, "exactly one Task minted");
2292 let task_id = in_flight_tasks[0].id.clone();
2293
2294 let resp = steps_list(
2295 State(state.clone()),
2296 Path((task_id.to_string(), "latest".to_string())),
2297 )
2298 .await
2299 .expect("steps_list while step2 is still in flight");
2300 let step1_entry = resp
2301 .steps
2302 .iter()
2303 .find(|s| s.name == "step1")
2304 .expect("step1 must already be visible");
2305 assert_eq!(step1_entry.source, ProjectionSource::DataPlane);
2306
2307 gate.notify_one();
2310 let posted = launch_handle.await.expect("join").expect("tasks_start").0;
2311 assert_eq!(posted.final_ctx["step2"], json!("step2 done"));
2312 }
2313}