1use axum::{
82 extract::{Path, Query, State},
83 http::{header, HeaderMap, HeaderValue, StatusCode},
84 response::IntoResponse,
85 Json,
86};
87use mlua_swarm::core::projection::{
88 ProjectionAdapter, ProjectionError, ProjectionKey, ProjectionRef,
89};
90use mlua_swarm::store::output::{ContentRef, OutputEvent, OutputStore, OutputStoreError};
91use mlua_swarm::store::run::{RunRecord, RunStore};
92use mlua_swarm::{RunId, StepId, TaskId};
93use serde::{Deserialize, Serialize};
94use serde_json::Value;
95use sha2::Digest as _;
96use std::sync::Arc;
97
98use crate::tasks::map_task_store_err;
99use crate::{ApiError, AppState};
100
101pub struct McpQueryAdapter {
105 data_store: Arc<dyn OutputStore>,
106 run_store: Arc<dyn RunStore>,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
113#[serde(rename_all = "snake_case")]
114pub enum ProjectionSource {
115 DataPlane,
118 ResultRef,
121}
122
123#[derive(Debug, Clone)]
129pub(crate) struct ResolvedStep {
130 pub(crate) name: String,
133 pub(crate) value: Value,
135 pub(crate) source: ProjectionSource,
137}
138
139fn final_value(event: &OutputEvent) -> Option<Value> {
145 match event {
146 OutputEvent::Final { content, .. } => Some(content_to_value(content)),
147 _ => None,
148 }
149}
150
151fn content_to_value(content: &ContentRef) -> Value {
156 match content {
157 ContentRef::Inline { value } => value.clone(),
158 ContentRef::FileRef {
159 path,
160 mime,
161 size_hint,
162 } => serde_json::json!({
163 "file_ref": path.to_string_lossy(),
164 "mime": mime,
165 "size_hint": size_hint,
166 }),
167 }
168}
169
170impl McpQueryAdapter {
171 pub fn new(data_store: Arc<dyn OutputStore>, run_store: Arc<dyn RunStore>) -> Self {
174 Self {
175 data_store,
176 run_store,
177 }
178 }
179
180 async fn resolve_run(
188 &self,
189 task_id: &TaskId,
190 run_id: Option<&str>,
191 ) -> Result<RunRecord, ProjectionError> {
192 match run_id {
193 Some(rid) => {
194 let run_id = RunId::parse(rid.to_string())
195 .map_err(|e| ProjectionError::InvalidKey(format!("run_id: {e}")))?;
196 let run = self.run_store.get(&run_id).await.map_err(|_| {
197 ProjectionError::NotFound(ProjectionKey {
198 task_id: task_id.to_string(),
199 run_id: Some(rid.to_string()),
200 step: None,
201 path: None,
202 })
203 })?;
204 if &run.task_id != task_id {
205 return Err(ProjectionError::NotFound(ProjectionKey {
206 task_id: task_id.to_string(),
207 run_id: Some(rid.to_string()),
208 step: None,
209 path: None,
210 }));
211 }
212 Ok(run)
213 }
214 None => {
215 let mut runs = self.run_store.list_by_task(task_id).await.map_err(|_| {
216 ProjectionError::NotFound(ProjectionKey {
217 task_id: task_id.to_string(),
218 run_id: None,
219 step: None,
220 path: None,
221 })
222 })?;
223 runs.pop().ok_or_else(|| {
224 ProjectionError::NotFound(ProjectionKey {
225 task_id: task_id.to_string(),
226 run_id: None,
227 step: None,
228 path: None,
229 })
230 })
231 }
232 }
233 }
234
235 async fn resolve_async(
243 &self,
244 key: &ProjectionKey,
245 ) -> Result<(RunRecord, Value), ProjectionError> {
246 let task_id = TaskId::parse(key.task_id.clone())
247 .map_err(|e| ProjectionError::InvalidKey(format!("task_id: {e}")))?;
248 let run = self.resolve_run(&task_id, key.run_id.as_deref()).await?;
249
250 if key.run_id.is_none() {
254 if let Some(step) = &key.step {
255 match self.data_store.get_latest_by_name(step).await {
256 Ok(record) => {
257 if let Some(value) = final_value(&record.event) {
258 let narrowed = match &key.path {
259 None => Some(value),
260 Some(_) => {
261 let path_only = ProjectionKey {
267 task_id: key.task_id.clone(),
268 run_id: key.run_id.clone(),
269 step: None,
270 path: key.path.clone(),
271 };
272 path_only.resolve(&value).cloned()
273 }
274 };
275 if let Some(value) = narrowed {
276 return Ok((run, value));
277 }
278 }
279 }
280 Err(OutputStoreError::NotFound(_)) => {
281 }
284 Err(other) => {
285 return Err(ProjectionError::Io(std::io::Error::other(format!(
286 "OutputStore::get_latest_by_name: {other}"
287 ))));
288 }
289 }
290 }
291 }
292
293 let ctx_data = run.result_ref.clone().unwrap_or(Value::Null);
295 let value = key
296 .resolve(&ctx_data)
297 .cloned()
298 .ok_or_else(|| ProjectionError::NotFound(key.clone()))?;
299 Ok((run, value))
300 }
301
302 pub(crate) async fn list_steps(
308 &self,
309 task_id: &TaskId,
310 run_id: Option<&str>,
311 ) -> Result<(RunRecord, Vec<ResolvedStep>), ProjectionError> {
312 let run = self.resolve_run(task_id, run_id).await?;
313 let steps = self.enumerate_steps(&run).await;
314 Ok((run, steps))
315 }
316
317 pub(crate) async fn list_steps_by_run_id(
325 &self,
326 run_id: &RunId,
327 ) -> Result<(RunRecord, Vec<ResolvedStep>), ProjectionError> {
328 let run = self.run_store.get(run_id).await.map_err(|_| {
329 ProjectionError::NotFound(ProjectionKey {
330 task_id: String::new(),
331 run_id: Some(run_id.to_string()),
332 step: None,
333 path: None,
334 })
335 })?;
336 let steps = self.enumerate_steps(&run).await;
337 Ok((run, steps))
338 }
339
340 async fn enumerate_steps(&self, run: &RunRecord) -> Vec<ResolvedStep> {
346 let mut out = Vec::new();
347 let mut attempted = std::collections::HashSet::new();
348 let mut resolved_names = std::collections::HashSet::new();
349
350 for entry in &run.step_entries {
351 let Some(name) = &entry.step_ref else {
352 continue;
353 };
354 if !attempted.insert(name.clone()) {
355 continue;
356 }
357 if let Ok(record) = self.data_store.get_latest_by_name(name).await {
358 if let Some(value) = final_value(&record.event) {
359 out.push(ResolvedStep {
360 name: name.clone(),
361 value,
362 source: ProjectionSource::DataPlane,
363 });
364 resolved_names.insert(name.clone());
365 }
366 }
367 }
368
369 if let Some(Value::Object(map)) = &run.result_ref {
370 for (name, value) in map {
371 if resolved_names.contains(name) {
372 continue;
373 }
374 out.push(ResolvedStep {
375 name: name.clone(),
376 value: value.clone(),
377 source: ProjectionSource::ResultRef,
378 });
379 }
380 }
381
382 out
383 }
384}
385
386impl ProjectionAdapter for McpQueryAdapter {
387 fn name(&self) -> &'static str {
388 "mcp-query"
389 }
390
391 fn project(
400 &self,
401 key: &ProjectionKey,
402 ctx_data: &Value,
403 ) -> Result<ProjectionRef, ProjectionError> {
404 if key.task_id.is_empty() {
405 return Err(ProjectionError::InvalidKey(
406 "task_id must not be empty".to_string(),
407 ));
408 }
409 key.resolve(ctx_data)
410 .ok_or_else(|| ProjectionError::NotFound(key.clone()))?;
411 Ok(ProjectionRef::Query {
412 endpoint: format!(
413 "/v1/tasks/{}/runs/{}/steps/{}/content",
414 key.task_id,
415 key.run_id.as_deref().unwrap_or("latest"),
416 key.step.as_deref().unwrap_or("_ctx")
417 ),
418 key: key.clone(),
419 })
420 }
421
422 fn fetch(&self, key: &ProjectionKey) -> Result<Value, ProjectionError> {
423 let handle = tokio::runtime::Handle::try_current().map_err(|e| {
428 ProjectionError::Io(std::io::Error::other(format!(
429 "McpQueryAdapter::fetch requires a Tokio runtime: {e}"
430 )))
431 })?;
432 let (_run, value) =
433 tokio::task::block_in_place(|| handle.block_on(self.resolve_async(key)))?;
434 Ok(value)
435 }
436
437 fn pointer_line(&self, r: &ProjectionRef) -> String {
438 match r {
439 ProjectionRef::Query { endpoint, key } => {
440 format!("projection(mcp-query): {endpoint} task_id={}", key.task_id)
441 }
442 ProjectionRef::File { path } => format!("projection(file): {path}"),
443 }
444 }
445}
446
447#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
453pub struct StepList {
454 pub task_id: String,
456 pub run_id: String,
459 pub steps: Vec<StepSummary>,
463}
464
465#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
469pub struct StepSummary {
470 pub name: String,
472 pub size_bytes: u64,
475 pub content_type: String,
480 pub sha256: String,
483 pub source: ProjectionSource,
485 #[serde(default, skip_serializing_if = "Option::is_none")]
492 pub file_path: Option<String>,
493 pub content_url: String,
498 pub preview: String,
501 pub truncated: bool,
505}
506
507#[derive(Debug, Deserialize, Default, schemars::JsonSchema)]
512pub struct StepPathQuery {
513 #[serde(default)]
516 pub path: Option<String>,
517}
518
519fn narrow_step_value(value: &Value, path: Option<&str>) -> Option<Value> {
523 match path {
524 None => Some(value.clone()),
525 Some(p) => {
526 let path_only = ProjectionKey {
527 task_id: String::new(),
528 run_id: None,
529 step: None,
530 path: Some(p.to_string()),
531 };
532 path_only.resolve(value).cloned()
533 }
534 }
535}
536
537fn materialized_file_path(root: &str, step_id: &StepId, name: &str) -> std::path::PathBuf {
544 std::path::Path::new(root)
545 .join("workspace")
546 .join("tasks")
547 .join(step_id.to_string())
548 .join("ctx")
549 .join(format!("{name}.md"))
550}
551
552async fn resolve_materialized_file(
566 state: &AppState,
567 run: &RunRecord,
568 name: &str,
569) -> Option<(std::path::PathBuf, Vec<u8>)> {
570 let step_id = run
571 .step_entries
572 .iter()
573 .rev()
574 .find(|e| e.step_ref.as_deref() == Some(name))
575 .map(|e| e.step_id.clone())?;
576 let view = state.engine.agent_context_for(&step_id, 1).await?;
577 let root = view.work_dir.clone().or(view.project_root.clone())?;
578 let path = materialized_file_path(&root, &step_id, name);
579 let bytes = std::fs::read(&path).ok()?;
580 Some((path, bytes))
581}
582
583async fn render_step_body(
590 state: &AppState,
591 run: &RunRecord,
592 step: &ResolvedStep,
593 path: Option<&str>,
594) -> Option<(Vec<u8>, &'static str, Option<String>)> {
595 if path.is_none() {
596 if let Some((file_path, bytes)) = resolve_materialized_file(state, run, &step.name).await {
597 return Some((
598 bytes,
599 "text/markdown; charset=utf-8",
600 Some(file_path.to_string_lossy().into_owned()),
601 ));
602 }
603 }
604 let narrowed = narrow_step_value(&step.value, path)?;
605 let body = serde_json::to_vec_pretty(&narrowed).ok()?;
606 Some((body, "application/json", None))
607}
608
609fn build_preview(body: &[u8]) -> (String, bool) {
616 const MAX_PREVIEW_BYTES: usize = 512;
617 if body.len() <= MAX_PREVIEW_BYTES {
618 return (String::from_utf8_lossy(body).into_owned(), false);
619 }
620 let preview = match std::str::from_utf8(body) {
621 Ok(s) => {
622 let mut end = MAX_PREVIEW_BYTES;
623 while end > 0 && !s.is_char_boundary(end) {
624 end -= 1;
625 }
626 s[..end].to_string()
627 }
628 Err(_) => String::from_utf8_lossy(&body[..MAX_PREVIEW_BYTES]).into_owned(),
629 };
630 (format!("{preview}…"), true)
631}
632
633fn build_content_url(
639 base_url: &Option<Arc<str>>,
640 task_id: &TaskId,
641 run_id: &RunId,
642 name: &str,
643 path: Option<&str>,
644) -> String {
645 let mut url = format!("/v1/tasks/{task_id}/runs/{run_id}/steps/{name}/content");
646 if let Some(p) = path {
647 url.push_str("?path=");
648 url.push_str(p);
649 }
650 match base_url {
651 Some(base) => format!("{}{}", base.trim_end_matches('/'), url),
652 None => url,
653 }
654}
655
656async fn build_step_summary(
659 state: &AppState,
660 run: &RunRecord,
661 step: &ResolvedStep,
662 path: Option<&str>,
663) -> Option<StepSummary> {
664 let (body, content_type, file_path) = render_step_body(state, run, step, path).await?;
665 let sha256 = hex::encode(sha2::Sha256::digest(&body));
666 let size_bytes = body.len() as u64;
667 let (preview, truncated) = build_preview(&body);
668 let content_url = build_content_url(&state.base_url, &run.task_id, &run.id, &step.name, path);
669 Some(StepSummary {
670 name: step.name.clone(),
671 size_bytes,
672 content_type: content_type.to_string(),
673 sha256,
674 source: step.source,
675 file_path,
676 content_url,
677 preview,
678 truncated,
679 })
680}
681
682pub(crate) async fn resolve_step_pointer_fields(
693 state: &AppState,
694 run: &RunRecord,
695 step: &ResolvedStep,
696) -> Option<(u64, Option<String>, String, String)> {
697 let (body, _content_type, file_path) = render_step_body(state, run, step, None).await?;
698 let sha256 = hex::encode(sha2::Sha256::digest(&body));
699 let size_bytes = body.len() as u64;
700 let content_url = build_content_url(&state.base_url, &run.task_id, &run.id, &step.name, None);
701 Some((size_bytes, file_path, content_url, sha256))
702}
703
704async fn resolve_run_and_steps(
710 state: &AppState,
711 id: &str,
712 run: &str,
713) -> Result<(RunRecord, Vec<ResolvedStep>), ApiError> {
714 let task_id = TaskId::parse(id.to_string())
715 .map_err(|e| ApiError::bad_request(format!("invalid task id: {e}")))?;
716 state
717 .task_store
718 .get(&task_id)
719 .await
720 .map_err(map_task_store_err)?;
721 let adapter = McpQueryAdapter::new(state.data_store.clone(), state.run_store.clone());
722 let run_sel = if run == "latest" { None } else { Some(run) };
723 adapter
724 .list_steps(&task_id, run_sel)
725 .await
726 .map_err(map_projection_err)
727}
728
729pub async fn steps_list(
732 State(state): State<AppState>,
733 Path((id, run)): Path<(String, String)>,
734) -> Result<Json<StepList>, ApiError> {
735 let (run_record, steps) = resolve_run_and_steps(&state, &id, &run).await?;
736 let mut summaries = Vec::with_capacity(steps.len());
737 for step in &steps {
738 if let Some(summary) = build_step_summary(&state, &run_record, step, None).await {
739 summaries.push(summary);
740 }
741 }
742 Ok(Json(StepList {
743 task_id: run_record.task_id.to_string(),
744 run_id: run_record.id.to_string(),
745 steps: summaries,
746 }))
747}
748
749pub async fn step_get(
752 State(state): State<AppState>,
753 Path((id, run, step)): Path<(String, String, String)>,
754 Query(q): Query<StepPathQuery>,
755) -> Result<Json<StepSummary>, ApiError> {
756 let (run_record, steps) = resolve_run_and_steps(&state, &id, &run).await?;
757 let resolved = steps
758 .into_iter()
759 .find(|s| s.name == step)
760 .ok_or_else(|| ApiError::not_found(format!("step not found: {step}")))?;
761 let summary = build_step_summary(&state, &run_record, &resolved, q.path.as_deref())
762 .await
763 .ok_or_else(|| ApiError::not_found(format!("path not found: {:?}", q.path)))?;
764 Ok(Json(summary))
765}
766
767pub async fn step_content(
772 State(state): State<AppState>,
773 Path((id, run, step)): Path<(String, String, String)>,
774 Query(q): Query<StepPathQuery>,
775) -> Result<impl IntoResponse, ApiError> {
776 let (run_record, steps) = resolve_run_and_steps(&state, &id, &run).await?;
777 let resolved = steps
778 .into_iter()
779 .find(|s| s.name == step)
780 .ok_or_else(|| ApiError::not_found(format!("step not found: {step}")))?;
781 let (body, content_type, _file_path) =
782 render_step_body(&state, &run_record, &resolved, q.path.as_deref())
783 .await
784 .ok_or_else(|| ApiError::not_found(format!("path not found: {:?}", q.path)))?;
785 let sha256 = hex::encode(sha2::Sha256::digest(&body));
786 let mut headers = HeaderMap::new();
787 headers.insert(
788 header::CONTENT_TYPE,
789 HeaderValue::from_str(content_type).expect("content_type is a static ASCII literal"),
790 );
791 headers.insert(
792 header::ETAG,
793 HeaderValue::from_str(&format!("\"sha256:{sha256}\""))
794 .expect("hex digest is ASCII-safe for a header value"),
795 );
796 Ok((StatusCode::OK, headers, body))
797}
798
799fn map_projection_err(e: ProjectionError) -> ApiError {
800 match e {
801 ProjectionError::NotFound(key) => {
802 ApiError::not_found(format!("projection not found for key {key:?}"))
803 }
804 ProjectionError::InvalidKey(msg) => ApiError::bad_request(msg),
805 other => ApiError::engine(other),
806 }
807}
808
809#[cfg(test)]
814mod tests {
815 use super::*;
816 use crate::TaskLaunchRequest;
817 use axum::http::StatusCode;
818 use mlua_swarm::application::BlueprintRef;
819 use mlua_swarm::blueprint::{
820 current_schema_version, AgentDef, AgentKind, Blueprint, BlueprintMetadata, CompilerHints,
821 CompilerStrategy,
822 };
823 use mlua_swarm::core::config::EngineCfg;
824 use mlua_swarm::core::engine::Engine;
825 use mlua_swarm::store::output::InMemoryOutputStore;
826 use mlua_swarm::store::run::InMemoryRunStore;
827 use mlua_swarm::store::task::InMemoryTaskStore;
828 use serde_json::json;
829 use std::collections::HashMap;
830 use tokio::sync::Mutex;
831
832 fn greeting_blueprint() -> Blueprint {
840 Blueprint {
841 schema_version: current_schema_version(),
842 id: "projection-test-greeting-bp".into(),
843 flow: serde_json::from_value(json!({
844 "kind": "step",
845 "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
846 "in": {"op": "path", "at": "$.greeting"},
847 "out": {"op": "path", "at": "$.out"},
848 }))
849 .expect("flow parse"),
850 agents: vec![AgentDef {
851 name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
852 kind: AgentKind::RustFn,
853 spec: json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
854 profile: None,
855 meta: None,
856 }],
857 operators: vec![],
858 metas: vec![],
859 hints: CompilerHints::default(),
860 strategy: CompilerStrategy::default(),
861 metadata: BlueprintMetadata::default(),
862 spawner_hints: Default::default(),
863 default_agent_kind: AgentKind::Operator,
864 default_operator_kind: None,
865 default_init_ctx: None,
866 default_agent_ctx: None,
867 default_context_policy: None,
868 }
869 }
870
871 fn test_state() -> AppState {
872 let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
873 let compiler = mlua_swarm::Compiler::new(crate::default_registry());
874 let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
875 let data_store: Arc<dyn mlua_swarm::store::output::OutputStore> =
876 Arc::new(InMemoryOutputStore::new());
877 engine.set_output_store(data_store.clone());
883 AppState {
884 engine,
885 sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
886 task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
887 ws_operator_factory: None,
888 data_store,
889 operator_sessions: Arc::new(Mutex::new(HashMap::new())),
890 roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
891 task_store: Arc::new(InMemoryTaskStore::new()),
892 run_store: Arc::new(InMemoryRunStore::new()),
893 base_url: None,
894 }
895 }
896
897 fn greeting_task_req(greeting: &str) -> TaskLaunchRequest {
898 TaskLaunchRequest {
899 blueprint: BlueprintRef::Inline {
900 value: Box::new(greeting_blueprint()),
901 },
902 init_ctx: json!({ "greeting": greeting }),
903 project_root: None,
904 work_dir: None,
905 task_metadata: None,
906 ttl_secs: None,
907 operator: None,
908 operator_sid: None,
909 goal: Some("projection test goal".to_string()),
910 }
911 }
912
913 #[tokio::test]
916 async fn steps_list_returns_data_plane_and_result_ref_union() {
917 let state = test_state();
918 let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req("hello")))
919 .await
920 .expect("tasks_start")
921 .0;
922
923 let resp = steps_list(
924 State(state.clone()),
925 Path((posted.task_id.to_string(), "latest".to_string())),
926 )
927 .await
928 .expect("steps_list")
929 .0;
930
931 assert_eq!(resp.task_id, posted.task_id.to_string());
932 assert_eq!(resp.run_id, posted.run_id.to_string());
933 let identity_name = mlua_swarm::worker::baseline::AG_IDENTITY;
937 let identity_entry = resp
938 .steps
939 .iter()
940 .find(|s| s.name == identity_name)
941 .unwrap_or_else(|| panic!("missing {identity_name} in {:?}", resp.steps));
942 assert_eq!(identity_entry.source, ProjectionSource::DataPlane);
943 let out_entry = resp
944 .steps
945 .iter()
946 .find(|s| s.name == "out")
947 .unwrap_or_else(|| panic!("missing \"out\" in {:?}", resp.steps));
948 assert_eq!(out_entry.source, ProjectionSource::ResultRef);
949 }
950
951 #[tokio::test]
954 async fn steps_list_latest_resolves_newest_run_explicit_pin_still_works() {
955 let state = test_state();
956 let first = crate::tasks_start(State(state.clone()), Json(greeting_task_req("first")))
957 .await
958 .expect("tasks_start")
959 .0;
960 let (status, rekicked) = crate::tasks::task_rekick(
961 State(state.clone()),
962 Path(first.task_id.to_string()),
963 Some(Json(crate::tasks::RunKickRequest {
964 init_ctx_override: Some(json!({ "greeting": "second" })),
965 task_input_override: None,
966 })),
967 )
968 .await
969 .expect("task_rekick");
970 assert_eq!(status, StatusCode::CREATED);
971
972 let latest = steps_list(
973 State(state.clone()),
974 Path((first.task_id.to_string(), "latest".to_string())),
975 )
976 .await
977 .expect("steps_list latest")
978 .0;
979 assert_eq!(latest.run_id, rekicked.0.run_id.to_string());
980
981 let pinned = steps_list(
982 State(state.clone()),
983 Path((first.task_id.to_string(), first.run_id.to_string())),
984 )
985 .await
986 .expect("steps_list pinned")
987 .0;
988 assert_eq!(pinned.run_id, first.run_id.to_string());
989 }
990
991 #[tokio::test]
994 async fn step_get_preview_is_utf8_boundary_safe_and_truncated_flag_is_correct() {
995 let state = test_state();
996 let long_value = "あ".repeat(300); let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req(&long_value)))
1001 .await
1002 .expect("tasks_start")
1003 .0;
1004
1005 let identity_name = mlua_swarm::worker::baseline::AG_IDENTITY;
1006 let summary = step_get(
1007 State(state.clone()),
1008 Path((
1009 posted.task_id.to_string(),
1010 "latest".to_string(),
1011 identity_name.to_string(),
1012 )),
1013 Query(StepPathQuery::default()),
1014 )
1015 .await
1016 .expect("step_get")
1017 .0;
1018
1019 assert!(
1020 summary.preview.len() <= 512 + "…".len(),
1021 "preview must stay near the 512-byte cap: {} bytes",
1022 summary.preview.len()
1023 );
1024 assert!(
1025 summary.truncated,
1026 "a 900-byte body must be reported truncated"
1027 );
1028 assert!(
1029 summary.preview.ends_with('…'),
1030 "truncated preview must end with an ellipsis: {}",
1031 summary.preview
1032 );
1033 assert!(summary.preview.chars().all(|c| c != '\u{FFFD}'));
1039 }
1040
1041 #[tokio::test]
1044 async fn step_content_in_memory_fallback_is_json_with_matching_etag() {
1045 let state = test_state();
1046 let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req("hi")))
1047 .await
1048 .expect("tasks_start")
1049 .0;
1050
1051 let resp = step_content(
1052 State(state.clone()),
1053 Path((
1054 posted.task_id.to_string(),
1055 "latest".to_string(),
1056 "out".to_string(),
1057 )),
1058 Query(StepPathQuery::default()),
1059 )
1060 .await
1061 .expect("step_content")
1062 .into_response();
1063
1064 assert_eq!(resp.status(), StatusCode::OK);
1065 let content_type = resp
1066 .headers()
1067 .get(header::CONTENT_TYPE)
1068 .expect("content-type header")
1069 .to_str()
1070 .expect("ascii");
1071 assert_eq!(content_type, "application/json");
1072 let etag = resp
1073 .headers()
1074 .get(header::ETAG)
1075 .expect("etag header")
1076 .to_str()
1077 .expect("ascii")
1078 .to_string();
1079 let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1080 .await
1081 .expect("body bytes");
1082 let expected_sha = hex::encode(sha2::Sha256::digest(&body_bytes));
1083 assert_eq!(etag, format!("\"sha256:{expected_sha}\""));
1084 let parsed: Value = serde_json::from_slice(&body_bytes).expect("valid json body");
1085 assert_eq!(parsed["echoed"], json!("hi"));
1086 }
1087
1088 #[tokio::test]
1093 async fn step_content_materialized_file_is_served_as_markdown() {
1094 let dir = tempfile::TempDir::new().unwrap();
1095 let state = test_state();
1096 let mut req = greeting_task_req("materialized");
1097 req.work_dir = Some(dir.path().to_string_lossy().into_owned());
1098 let posted = crate::tasks_start(State(state.clone()), Json(req))
1099 .await
1100 .expect("tasks_start")
1101 .0;
1102
1103 let identity_name = mlua_swarm::worker::baseline::AG_IDENTITY;
1104 let resp = step_content(
1105 State(state.clone()),
1106 Path((
1107 posted.task_id.to_string(),
1108 "latest".to_string(),
1109 identity_name.to_string(),
1110 )),
1111 Query(StepPathQuery::default()),
1112 )
1113 .await
1114 .expect("step_content")
1115 .into_response();
1116
1117 assert_eq!(resp.status(), StatusCode::OK);
1118 let content_type = resp
1119 .headers()
1120 .get(header::CONTENT_TYPE)
1121 .expect("content-type header")
1122 .to_str()
1123 .expect("ascii");
1124 assert_eq!(content_type, "text/markdown; charset=utf-8");
1125 let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1126 .await
1127 .expect("body bytes");
1128 let body_str = String::from_utf8(body_bytes.to_vec()).expect("utf8 body");
1129 assert!(
1130 body_str.contains("```json"),
1131 "materialized file must carry the fenced json block: {body_str}"
1132 );
1133 }
1134
1135 #[tokio::test]
1138 async fn step_content_path_narrow_returns_json_fragment() {
1139 let state = test_state();
1140 let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req("narrowed")))
1141 .await
1142 .expect("tasks_start")
1143 .0;
1144
1145 let resp = step_content(
1146 State(state.clone()),
1147 Path((
1148 posted.task_id.to_string(),
1149 "latest".to_string(),
1150 "out".to_string(),
1151 )),
1152 Query(StepPathQuery {
1153 path: Some("echoed".to_string()),
1154 }),
1155 )
1156 .await
1157 .expect("step_content narrowed")
1158 .into_response();
1159
1160 assert_eq!(resp.status(), StatusCode::OK);
1161 let content_type = resp
1162 .headers()
1163 .get(header::CONTENT_TYPE)
1164 .expect("content-type header")
1165 .to_str()
1166 .expect("ascii");
1167 assert_eq!(content_type, "application/json");
1168 let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1169 .await
1170 .expect("body bytes");
1171 let parsed: Value = serde_json::from_slice(&body_bytes).expect("valid json body");
1172 assert_eq!(parsed, json!("narrowed"));
1173 }
1174
1175 #[tokio::test]
1178 async fn steps_list_unknown_task_returns_404() {
1179 let state = test_state();
1180 let err = steps_list(
1181 State(state),
1182 Path(("T-does-not-exist".to_string(), "latest".to_string())),
1183 )
1184 .await
1185 .expect_err("unknown task must 404");
1186 assert_eq!(err.status, StatusCode::NOT_FOUND);
1187 }
1188
1189 #[tokio::test]
1190 async fn steps_list_unknown_run_returns_404() {
1191 let state = test_state();
1192 let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req("hi")))
1193 .await
1194 .expect("tasks_start")
1195 .0;
1196 let err = steps_list(
1197 State(state),
1198 Path((posted.task_id.to_string(), "R-does-not-exist".to_string())),
1199 )
1200 .await
1201 .expect_err("unknown run must 404");
1202 assert_eq!(err.status, StatusCode::NOT_FOUND);
1203 }
1204
1205 #[tokio::test]
1206 async fn step_get_unknown_step_returns_404() {
1207 let state = test_state();
1208 let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req("hi")))
1209 .await
1210 .expect("tasks_start")
1211 .0;
1212 let err = step_get(
1213 State(state),
1214 Path((
1215 posted.task_id.to_string(),
1216 "latest".to_string(),
1217 "does-not-exist".to_string(),
1218 )),
1219 Query(StepPathQuery::default()),
1220 )
1221 .await
1222 .expect_err("unknown step must 404");
1223 assert_eq!(err.status, StatusCode::NOT_FOUND);
1224 }
1225
1226 #[tokio::test]
1229 async fn old_ctx_route_returns_404_not_found_by_router() {
1230 let engine = Engine::new(EngineCfg::default());
1231 let router = mlua_swarm_server_router_for_test(engine);
1232 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1233 .await
1234 .expect("bind ephemeral port");
1235 let addr = listener.local_addr().expect("local addr");
1236 tokio::spawn(async move {
1237 let _ = axum::serve(listener, router).await;
1238 });
1239 let client = reqwest::Client::new();
1240 let resp = client
1241 .get(format!("http://{addr}/v1/tasks/T-anything/ctx"))
1242 .send()
1243 .await
1244 .expect("request");
1245 assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
1246 }
1247
1248 fn mlua_swarm_server_router_for_test(engine: Engine) -> axum::Router {
1252 crate::build_router(engine)
1253 }
1254
1255 #[test]
1258 fn mcp_query_adapter_project_builds_query_ref() {
1259 let adapter = McpQueryAdapter::new(
1260 Arc::new(InMemoryOutputStore::new()),
1261 Arc::new(InMemoryRunStore::new()),
1262 );
1263 let key = ProjectionKey {
1264 task_id: "T-abc".to_string(),
1265 run_id: None,
1266 step: Some("planner".to_string()),
1267 path: None,
1268 };
1269 let ctx_data = json!({"planner": {"plan": "do it"}});
1270 let reference = adapter.project(&key, &ctx_data).expect("project");
1271 match &reference {
1272 ProjectionRef::Query { endpoint, key: k } => {
1273 assert!(endpoint.contains("/steps/planner/content"));
1274 assert_eq!(k, &key);
1275 }
1276 other => panic!("expected Query ref, got {other:?}"),
1277 }
1278 let line = adapter.pointer_line(&reference);
1279 assert!(line.contains("T-abc"));
1280 }
1281
1282 #[test]
1283 fn mcp_query_adapter_project_rejects_key_not_present_in_ctx_data() {
1284 let adapter = McpQueryAdapter::new(
1285 Arc::new(InMemoryOutputStore::new()),
1286 Arc::new(InMemoryRunStore::new()),
1287 );
1288 let key = ProjectionKey {
1289 task_id: "T-abc".to_string(),
1290 run_id: None,
1291 step: Some("missing".to_string()),
1292 path: None,
1293 };
1294 let err = adapter.project(&key, &json!({"planner": {}})).unwrap_err();
1295 assert!(matches!(err, ProjectionError::NotFound(_)));
1296 }
1297
1298 #[tokio::test(flavor = "multi_thread")]
1299 async fn mcp_query_adapter_fetch_bridges_to_resolve_async() {
1300 let state = test_state();
1301 let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req("bridged")))
1302 .await
1303 .expect("tasks_start")
1304 .0;
1305
1306 let adapter = McpQueryAdapter::new(state.data_store.clone(), state.run_store.clone());
1307 let key = ProjectionKey {
1308 task_id: posted.task_id.to_string(),
1309 run_id: None,
1310 step: Some("out".to_string()),
1311 path: Some("echoed".to_string()),
1312 };
1313 let value = adapter.fetch(&key).expect("fetch");
1321 assert_eq!(value, json!("bridged"));
1322 }
1323
1324 #[tokio::test]
1335 async fn resolve_async_path_narrows_within_data_plane_final_content() {
1336 let state = test_state();
1337 let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req("hi")))
1338 .await
1339 .expect("tasks_start")
1340 .0;
1341
1342 let adapter = McpQueryAdapter::new(state.data_store.clone(), state.run_store.clone());
1343 let key = ProjectionKey {
1344 task_id: posted.task_id.to_string(),
1345 run_id: None,
1346 step: Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string()),
1347 path: Some("echoed".to_string()),
1348 };
1349 let (_run, value) = adapter.resolve_async(&key).await.expect("resolve_async");
1350 assert_eq!(value, json!("hi"));
1351 }
1352
1353 #[tokio::test(flavor = "multi_thread")]
1364 async fn steps_list_returns_in_flight_step_output_before_run_completes() {
1365 use mlua_flow_ir::{Expr, Node as FlowNode};
1366 use mlua_swarm::worker::adapter::WorkerResult;
1367 use mlua_swarm::{RustFnInProcessSpawnerFactory, SpawnerRegistry};
1368
1369 let started = Arc::new(tokio::sync::Notify::new());
1370 let gate = Arc::new(tokio::sync::Notify::new());
1371 let started_bg = started.clone();
1372 let gate_bg = gate.clone();
1373
1374 let factory = RustFnInProcessSpawnerFactory::new()
1375 .register_fn("step1", |inv| async move {
1376 Ok(WorkerResult {
1377 value: json!({ "step1_out": inv.prompt }),
1378 ok: true,
1379 })
1380 })
1381 .register_fn("step2", move |_inv| {
1382 let started = started_bg.clone();
1383 let gate = gate_bg.clone();
1384 async move {
1385 started.notify_one();
1386 gate.notified().await;
1387 Ok(WorkerResult {
1388 value: json!("step2 done"),
1389 ok: true,
1390 })
1391 }
1392 });
1393 let mut reg = SpawnerRegistry::new();
1394 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
1395
1396 let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
1397 let data_store: Arc<dyn mlua_swarm::store::output::OutputStore> =
1398 Arc::new(InMemoryOutputStore::new());
1399 engine.set_output_store(data_store.clone());
1400 let compiler = mlua_swarm::Compiler::new(reg);
1401 let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
1402 let state = AppState {
1403 engine,
1404 sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
1405 task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
1406 ws_operator_factory: None,
1407 data_store,
1408 operator_sessions: Arc::new(Mutex::new(HashMap::new())),
1409 roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
1410 task_store: Arc::new(InMemoryTaskStore::new()),
1411 run_store: Arc::new(InMemoryRunStore::new()),
1412 base_url: None,
1413 };
1414
1415 let flow = FlowNode::Seq {
1416 children: vec![
1417 FlowNode::Step {
1418 ref_: "step1".to_string(),
1419 in_: Expr::Path {
1420 at: "$.greeting".to_string(),
1421 },
1422 out: Expr::Path {
1423 at: "$.step1".to_string(),
1424 },
1425 },
1426 FlowNode::Step {
1427 ref_: "step2".to_string(),
1428 in_: Expr::Path {
1429 at: "$.step1".to_string(),
1430 },
1431 out: Expr::Path {
1432 at: "$.step2".to_string(),
1433 },
1434 },
1435 ],
1436 };
1437 let blueprint = Blueprint {
1438 schema_version: current_schema_version(),
1439 id: "projection-test-in-flight-bp".into(),
1440 flow,
1441 agents: vec![
1442 AgentDef {
1443 name: "step1".into(),
1444 kind: AgentKind::RustFn,
1445 spec: json!({"fn_id": "step1"}),
1446 profile: None,
1447 meta: None,
1448 },
1449 AgentDef {
1450 name: "step2".into(),
1451 kind: AgentKind::RustFn,
1452 spec: json!({"fn_id": "step2"}),
1453 profile: None,
1454 meta: None,
1455 },
1456 ],
1457 operators: vec![],
1458 metas: vec![],
1459 hints: CompilerHints::default(),
1460 strategy: CompilerStrategy::default(),
1461 metadata: BlueprintMetadata::default(),
1462 spawner_hints: Default::default(),
1463 default_agent_kind: AgentKind::Operator,
1464 default_operator_kind: None,
1465 default_init_ctx: None,
1466 default_agent_ctx: None,
1467 default_context_policy: None,
1468 };
1469
1470 let req = TaskLaunchRequest {
1471 blueprint: BlueprintRef::Inline {
1472 value: Box::new(blueprint),
1473 },
1474 init_ctx: json!({ "greeting": "hi" }),
1475 project_root: None,
1476 work_dir: None,
1477 task_metadata: None,
1478 ttl_secs: None,
1479 operator: None,
1480 operator_sid: None,
1481 goal: None,
1482 };
1483
1484 let state_bg = state.clone();
1485 let launch_handle =
1486 tokio::spawn(async move { crate::tasks_start(State(state_bg), Json(req)).await });
1487
1488 started.notified().await;
1492
1493 let in_flight_tasks = state.task_store.list().await.expect("task_store list");
1494 assert_eq!(in_flight_tasks.len(), 1, "exactly one Task minted");
1495 let task_id = in_flight_tasks[0].id.clone();
1496
1497 let resp = steps_list(
1498 State(state.clone()),
1499 Path((task_id.to_string(), "latest".to_string())),
1500 )
1501 .await
1502 .expect("steps_list while step2 is still in flight");
1503 let step1_entry = resp
1504 .steps
1505 .iter()
1506 .find(|s| s.name == "step1")
1507 .expect("step1 must already be visible");
1508 assert_eq!(step1_entry.source, ProjectionSource::DataPlane);
1509
1510 gate.notify_one();
1513 let posted = launch_handle.await.expect("join").expect("tasks_start").0;
1514 assert_eq!(posted.final_ctx["step2"], json!("step2 done"));
1515 }
1516}