1use crate::blueprint::{AgentDef, AgentKind, Blueprint, BlueprintMetadata};
31use crate::core::ctx::Ctx;
32use crate::core::engine::Engine;
33use crate::core::projection_placement::{ProjectionPlacement, ProjectionPlacementError};
34use crate::core::step_naming::{StepNaming, StepNamingError};
35use crate::operator::{Operator, OperatorSpawner, WorkerBinding};
36use crate::types::{CapToken, StepId};
37use crate::worker::adapter::{InProcSpawner, SpawnError, SpawnerAdapter, WorkerFn};
38use crate::worker::process_spawner::{ProcessSpawner, StreamMode};
39use crate::worker::Worker;
40use async_trait::async_trait;
41use mlua_flow_ir::{Expr, Node as FlowNode};
42use serde_json::Value;
43use std::collections::HashMap;
44use std::sync::Arc;
45use thiserror::Error;
46
47#[derive(Debug, Error)]
52pub enum CompileError {
53 #[error("unknown agent kind in SpawnerRegistry: {0:?}")]
56 UnknownKind(AgentKind),
57 #[error("agent '{name}' spec invalid: {msg}")]
60 InvalidSpec {
61 name: String,
63 msg: String,
65 },
66 #[error("flow references agent '{0}' but no AgentDef matches")]
69 UnresolvedRef(String),
70 #[error("duplicate AgentDef name: {0}")]
72 DuplicateAgent(String),
73 #[error("agent '{agent}' operator_ref '{op_ref}' does not match any OperatorDef.name in Blueprint.operators (defined: {defined:?})")]
76 UnresolvedOperatorRef {
77 agent: String,
79 op_ref: String,
81 defined: Vec<String>,
84 },
85 #[error("{where_} names an undefined MetaDef: '{meta_ref}' (defined: {defined:?})")]
89 UnresolvedMetaRef {
90 where_: String,
94 meta_ref: String,
96 defined: Vec<String>,
99 },
100 #[error("StepNaming collision: {0}")]
106 StepNamingCollision(#[from] StepNamingError),
107 #[error("invalid projection_placement: {0}")]
114 InvalidProjectionPlacement(#[from] ProjectionPlacementError),
115 #[error("audits[].agent '{agent}' does not match any AgentDef.name in Blueprint.agents (defined: {defined:?})")]
120 UnresolvedAuditAgent {
121 agent: String,
123 defined: Vec<String>,
126 },
127}
128
129pub trait SpawnerFactory: Send + Sync {
141 fn build(
144 &self,
145 agent_def: &AgentDef,
146 hint: Option<&Value>,
147 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError>;
148}
149
150pub trait SpawnerFactoryKind: SpawnerFactory {
166 const KIND: AgentKind;
169 type Worker: crate::worker::Worker;
176}
177
178#[derive(Clone)]
181pub struct SpawnerRegistry {
182 factories: HashMap<AgentKind, Arc<dyn SpawnerFactory>>,
183}
184
185impl SpawnerRegistry {
186 pub fn new() -> Self {
188 Self {
189 factories: HashMap::new(),
190 }
191 }
192 pub fn register<F: SpawnerFactoryKind + 'static>(&mut self, factory: Arc<F>) -> &mut Self {
201 let f: Arc<dyn SpawnerFactory> = factory;
202 self.factories.insert(F::KIND, f);
203 self
204 }
205}
206
207impl Default for SpawnerRegistry {
208 fn default() -> Self {
209 Self::new()
210 }
211}
212
213pub struct Compiler {
220 registry: SpawnerRegistry,
221 default_spawner: Option<Arc<dyn SpawnerAdapter>>,
222}
223
224pub struct CompiledBlueprint {
228 pub router: Arc<CompiledAgentTable>,
230 pub flow: FlowNode,
232 pub metadata: BlueprintMetadata,
234 pub step_naming: Arc<StepNaming>,
239 pub projection_placement: Arc<ProjectionPlacement>,
245}
246
247impl Compiler {
248 pub fn new(registry: SpawnerRegistry) -> Self {
252 Self {
253 registry,
254 default_spawner: None,
255 }
256 }
257
258 pub fn with_default(mut self, sp: Arc<dyn SpawnerAdapter>) -> Self {
262 self.default_spawner = Some(sp);
263 self
264 }
265
266 pub fn compile(&self, bp: &Blueprint) -> Result<CompiledBlueprint, CompileError> {
271 let mut routes: HashMap<String, Arc<dyn SpawnerAdapter>> = HashMap::new();
272 let mut seen: HashMap<String, ()> = HashMap::new();
273
274 let defined: Vec<String> = bp.operators.iter().map(|o| o.name.clone()).collect();
280 for ad in &bp.agents {
281 if !matches!(ad.kind, AgentKind::Operator) {
282 continue;
283 }
284 let op_ref = ad.spec.get("operator_ref").and_then(|v| v.as_str());
285 if let Some(op_ref) = op_ref {
286 if !defined.iter().any(|n| n == op_ref) {
287 return Err(CompileError::UnresolvedOperatorRef {
288 agent: ad.name.clone(),
289 op_ref: op_ref.to_string(),
290 defined: defined.clone(),
291 });
292 }
293 }
294 }
296
297 let metas_defined: Vec<String> = bp.metas.iter().map(|m| m.name.clone()).collect();
301 for ad in &bp.agents {
302 let meta_ref = ad.meta.as_ref().and_then(|m| m.meta_ref.as_ref());
303 if let Some(meta_ref) = meta_ref {
304 if !metas_defined.iter().any(|n| n == meta_ref) {
305 return Err(CompileError::UnresolvedMetaRef {
306 where_: format!("AgentMeta.meta_ref of agent '{}'", ad.name),
307 meta_ref: meta_ref.clone(),
308 defined: metas_defined.clone(),
309 });
310 }
311 }
312 }
313 let mut static_step_meta_refs: Vec<(String, String)> = Vec::new();
319 collect_step_meta_refs(&bp.flow, &mut static_step_meta_refs);
320 for (where_, meta_ref) in static_step_meta_refs {
321 if !metas_defined.iter().any(|n| n == &meta_ref) {
322 return Err(CompileError::UnresolvedMetaRef {
323 where_,
324 meta_ref,
325 defined: metas_defined.clone(),
326 });
327 }
328 }
329
330 let agents_defined: Vec<String> = bp.agents.iter().map(|a| a.name.clone()).collect();
335 for audit in &bp.audits {
336 if !agents_defined.iter().any(|n| n == &audit.agent) {
337 return Err(CompileError::UnresolvedAuditAgent {
338 agent: audit.agent.clone(),
339 defined: agents_defined.clone(),
340 });
341 }
342 }
343
344 for ad in &bp.agents {
345 if seen.contains_key(&ad.name) {
346 return Err(CompileError::DuplicateAgent(ad.name.clone()));
347 }
348 seen.insert(ad.name.clone(), ());
349
350 let factory = match self.registry.factories.get(&ad.kind) {
351 Some(f) => f.clone(),
352 None => {
353 if bp.strategy.strict_kind {
354 return Err(CompileError::UnknownKind(ad.kind.clone()));
355 } else {
356 tracing::warn!(
357 agent = %ad.name,
358 kind = ?ad.kind,
359 "no spawner factory registered for agent kind; \
360 dropping agent from routing table (strict_kind=false)"
361 );
362 continue;
363 }
364 }
365 };
366 let hint = bp.hints.per_agent.get(&ad.name);
367 let spawner = factory.build(ad, hint)?;
368 routes.insert(ad.name.clone(), spawner);
369 }
370
371 if bp.strategy.strict_refs {
372 verify_refs(&bp.flow, &routes, self.default_spawner.is_some())?;
373 }
374
375 let (step_naming, step_naming_warnings) = StepNaming::from_blueprint(bp)?;
383 for warning in &step_naming_warnings {
384 tracing::warn!(
385 name = %warning.name,
386 first_step_ref = %warning.first_step_ref,
387 second_step_ref = %warning.second_step_ref,
388 "StepNaming: undeclared steps' canonical/alias names collide; \
389 the step whose own ref matches the name keeps it (data-plane priority)"
390 );
391 }
392
393 let projection_placement =
401 ProjectionPlacement::from_spec(bp.projection_placement.as_ref())?;
402
403 let router = Arc::new(CompiledAgentTable {
404 routes,
405 default: self.default_spawner.clone(),
406 });
407 Ok(CompiledBlueprint {
408 router,
409 flow: bp.flow.clone(),
410 metadata: bp.metadata.clone(),
411 step_naming: Arc::new(step_naming),
412 projection_placement: Arc::new(projection_placement),
413 })
414 }
415}
416
417fn verify_refs(
420 node: &FlowNode,
421 routes: &HashMap<String, Arc<dyn SpawnerAdapter>>,
422 has_default: bool,
423) -> Result<(), CompileError> {
424 let mut refs: Vec<String> = Vec::new();
425 collect_refs(node, &mut refs);
426 for r in refs {
427 if !routes.contains_key(&r) && !has_default {
428 return Err(CompileError::UnresolvedRef(r));
429 }
430 }
431 Ok(())
432}
433
434fn collect_refs(node: &FlowNode, out: &mut Vec<String>) {
435 match node {
436 FlowNode::Step { ref_, .. } => out.push(ref_.clone()),
437 FlowNode::Seq { children } => {
438 for c in children {
439 collect_refs(c, out);
440 }
441 }
442 FlowNode::Branch { then_, else_, .. } => {
443 collect_refs(then_, out);
444 collect_refs(else_, out);
445 }
446 FlowNode::Fanout { body, .. } => collect_refs(body, out),
447 FlowNode::Loop { body, .. } => collect_refs(body, out),
448 FlowNode::Try { body, catch, .. } => {
449 collect_refs(body, out);
450 collect_refs(catch, out);
451 }
452 FlowNode::Assign { .. } => {} }
454}
455
456fn collect_step_meta_refs(node: &FlowNode, out: &mut Vec<(String, String)>) {
464 match node {
465 FlowNode::Step { ref_, in_, .. } => {
466 if let Expr::Lit { value } = in_ {
467 if let Some(meta_ref) = static_step_meta_ref(value) {
468 out.push((format!("Step '{ref_}' $step_meta.ref"), meta_ref));
469 }
470 }
471 }
472 FlowNode::Seq { children } => {
473 for c in children {
474 collect_step_meta_refs(c, out);
475 }
476 }
477 FlowNode::Branch { then_, else_, .. } => {
478 collect_step_meta_refs(then_, out);
479 collect_step_meta_refs(else_, out);
480 }
481 FlowNode::Fanout { body, .. } => collect_step_meta_refs(body, out),
482 FlowNode::Loop { body, .. } => collect_step_meta_refs(body, out),
483 FlowNode::Try { body, catch, .. } => {
484 collect_step_meta_refs(body, out);
485 collect_step_meta_refs(catch, out);
486 }
487 FlowNode::Assign { .. } => {} }
489}
490
491fn static_step_meta_ref(value: &Value) -> Option<String> {
498 value
499 .as_object()?
500 .get("$step_meta")?
501 .as_object()?
502 .get("ref")?
503 .as_str()
504 .map(str::to_string)
505}
506
507pub struct CompiledAgentTable {
520 pub(crate) routes: HashMap<String, Arc<dyn SpawnerAdapter>>,
521 pub(crate) default: Option<Arc<dyn SpawnerAdapter>>,
522}
523
524impl CompiledAgentTable {
525 pub fn has_route(&self, agent: &str) -> bool {
528 self.routes.contains_key(agent)
529 }
530 pub fn routed_agents(&self) -> Vec<String> {
532 self.routes.keys().cloned().collect()
533 }
534}
535
536#[async_trait]
537impl SpawnerAdapter for CompiledAgentTable {
538 async fn spawn(
539 &self,
540 engine: &Engine,
541 ctx: &Ctx,
542 task_id: StepId,
543 attempt: u32,
544 token: CapToken,
545 ) -> Result<Box<dyn Worker>, SpawnError> {
546 let sp = self
547 .routes
548 .get(&ctx.agent)
549 .cloned()
550 .or_else(|| self.default.clone())
551 .ok_or_else(|| SpawnError::NotRegistered(ctx.agent.clone()))?;
552 sp.spawn(engine, ctx, task_id, attempt, token).await
553 }
554}
555
556pub struct SubprocessProcessSpawnerFactory;
574
575impl SpawnerFactoryKind for SubprocessProcessSpawnerFactory {
576 const KIND: AgentKind = AgentKind::Subprocess;
577 type Worker = crate::worker::process_spawner::ProcessWorker;
578}
579
580impl SpawnerFactory for SubprocessProcessSpawnerFactory {
581 fn build(
582 &self,
583 agent_def: &AgentDef,
584 _hint: Option<&Value>,
585 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
586 let agent_name = &agent_def.name;
587 let spec = &agent_def.spec;
588 let invalid = |msg: String| CompileError::InvalidSpec {
589 name: agent_name.to_string(),
590 msg,
591 };
592 let program = spec
593 .get("program")
594 .and_then(|v| v.as_str())
595 .ok_or_else(|| invalid("shell spec: 'program' (string) required".into()))?
596 .to_string();
597 let args: Vec<String> = spec
598 .get("args")
599 .and_then(|v| v.as_array())
600 .map(|a| {
601 a.iter()
602 .filter_map(|x| x.as_str().map(|s| s.to_string()))
603 .collect()
604 })
605 .unwrap_or_default();
606 let use_stdin = spec
607 .get("use_stdin")
608 .and_then(|v| v.as_bool())
609 .unwrap_or(true);
610 let stream_mode = match spec.get("stream_mode").and_then(|v| v.as_str()) {
611 Some("ndjson_lines") => Some(StreamMode::NdjsonLines),
612 Some("sse_events") => Some(StreamMode::SseEvents),
613 Some("length_prefixed") => Some(StreamMode::LengthPrefixed),
614 Some(other) => return Err(invalid(format!("unknown stream_mode: {other}"))),
615 None => None,
616 };
617
618 let mut sp = ProcessSpawner {
619 program,
620 args,
621 use_stdin,
622 stream_mode,
623 };
624 if let Some(mode) = sp.stream_mode.clone() {
625 sp = sp.stream_mode(mode);
626 }
627 Ok(Arc::new(sp))
628 }
629}
630
631pub struct LuaInProcessSpawnerFactory {
662 registry: HashMap<String, WorkerFn>,
663 bridges: HashMap<String, HostBridge>,
664}
665
666#[derive(Clone)]
678pub struct HostBridge(
679 Arc<dyn Fn(serde_json::Value) -> Result<serde_json::Value, String> + Send + Sync>,
680);
681
682impl HostBridge {
683 pub fn new<F>(f: F) -> Self
685 where
686 F: Fn(serde_json::Value) -> Result<serde_json::Value, String> + Send + Sync + 'static,
687 {
688 Self(Arc::new(f))
689 }
690
691 pub fn call(&self, arg: serde_json::Value) -> Result<serde_json::Value, String> {
695 (self.0)(arg)
696 }
697}
698
699#[derive(Clone)]
706pub struct LuaScriptSource {
707 pub source: String,
709 pub label: String,
712}
713
714impl LuaScriptSource {
715 pub fn new(source: impl Into<String>, label: impl Into<String>) -> Self {
717 Self {
718 source: source.into(),
719 label: label.into(),
720 }
721 }
722}
723
724impl LuaInProcessSpawnerFactory {
725 pub fn new() -> Self {
727 Self {
728 registry: HashMap::new(),
729 bridges: HashMap::new(),
730 }
731 }
732
733 pub fn with_bridge(mut self, name: impl Into<String>, bridge: HostBridge) -> Self {
740 self.bridges.insert(name.into(), bridge);
741 self
742 }
743
744 pub fn register_lua(mut self, fn_id: impl Into<String>, source: LuaScriptSource) -> Self {
762 let source = Arc::new(source);
763 let bridges = Arc::new(self.bridges.clone());
764 let wrapped: WorkerFn = Arc::new(move |inv| {
765 let source = source.clone();
766 let bridges = bridges.clone();
767 Box::pin(run_lua_worker(source, bridges, inv))
768 });
769 self.registry.insert(fn_id.into(), wrapped);
770 self
771 }
772}
773
774async fn run_lua_worker(
776 source: Arc<LuaScriptSource>,
777 bridges: Arc<HashMap<String, HostBridge>>,
778 inv: crate::worker::adapter::WorkerInvocation,
779) -> Result<crate::worker::adapter::WorkerResult, crate::worker::adapter::WorkerError> {
780 use crate::worker::adapter::WorkerError;
781 use mlua::LuaSerdeExt;
782
783 let label = source.label.clone();
784 let outcome =
785 tokio::task::spawn_blocking(move || -> Result<(serde_json::Value, bool), String> {
786 let lua = mlua::Lua::new();
787 let g = lua.globals();
788
789 g.set("_PROMPT", inv.prompt.clone())
791 .map_err(|e| format!("set _PROMPT: {e}"))?;
792 g.set("_AGENT", inv.agent.clone())
793 .map_err(|e| format!("set _AGENT: {e}"))?;
794 g.set("_TASK_ID", inv.task_id.to_string())
795 .map_err(|e| format!("set _TASK_ID: {e}"))?;
796 g.set("_ATTEMPT", inv.attempt as i64)
797 .map_err(|e| format!("set _ATTEMPT: {e}"))?;
798
799 if let Ok(json_val) = serde_json::from_str::<serde_json::Value>(&inv.prompt) {
801 let lua_val = lua
802 .to_value(&json_val)
803 .map_err(|e| format!("_CTX to_value: {e}"))?;
804 g.set("_CTX", lua_val)
805 .map_err(|e| format!("set _CTX: {e}"))?;
806 }
807
808 if !bridges.is_empty() {
810 let host = lua
811 .create_table()
812 .map_err(|e| format!("create host table: {e}"))?;
813 for (name, bridge) in bridges.iter() {
814 let bridge = bridge.clone();
815 let bname = name.clone();
816 let f = lua
817 .create_function(move |lua, arg: mlua::Value| {
818 let json_arg: serde_json::Value = lua.from_value(arg).map_err(|e| {
819 mlua::Error::external(format!("bridge {bname} arg → json: {e}"))
820 })?;
821 let result_json =
822 bridge.call(json_arg).map_err(mlua::Error::external)?;
823 lua.to_value(&result_json).map_err(|e| {
824 mlua::Error::external(format!("bridge {bname} ret → lua: {e}"))
825 })
826 })
827 .map_err(|e| format!("create_function {name}: {e}"))?;
828 host.set(name.as_str(), f)
829 .map_err(|e| format!("host.{name} set: {e}"))?;
830 }
831 g.set("host", host).map_err(|e| format!("set host: {e}"))?;
832 }
833
834 let result: mlua::Value = lua
836 .load(&source.source)
837 .set_name(&source.label)
838 .eval()
839 .map_err(|e| format!("lua eval [{}]: {e}", source.label))?;
840
841 let json_result: serde_json::Value = lua
843 .from_value(result)
844 .map_err(|e| format!("lua → json [{}]: {e}", source.label))?;
845
846 let (value, ok) = match &json_result {
847 serde_json::Value::Object(map)
848 if map.contains_key("value") || map.contains_key("ok") =>
849 {
850 let ok = map.get("ok").and_then(|v| v.as_bool()).unwrap_or(true);
851 let value = map.get("value").cloned().unwrap_or(json_result.clone());
852 (value, ok)
853 }
854 _ => (json_result, true),
855 };
856 Ok((value, ok))
857 })
858 .await
859 .map_err(|e| WorkerError::Failed(format!("spawn_blocking join [{label}]: {e}")))?
860 .map_err(WorkerError::Failed)?;
861
862 Ok(crate::worker::adapter::WorkerResult {
863 value: outcome.0,
864 ok: outcome.1,
865 })
866}
867
868impl Default for LuaInProcessSpawnerFactory {
869 fn default() -> Self {
870 Self::new()
871 }
872}
873
874impl SpawnerFactoryKind for LuaInProcessSpawnerFactory {
875 const KIND: AgentKind = AgentKind::Lua;
876 type Worker = LuaWorker;
877}
878
879impl SpawnerFactory for LuaInProcessSpawnerFactory {
880 fn build(
881 &self,
882 agent_def: &AgentDef,
883 _hint: Option<&Value>,
884 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
885 if let Some(source) = agent_def.spec.get("source").and_then(|v| v.as_str()) {
891 let label = agent_def
892 .spec
893 .get("label")
894 .and_then(|v| v.as_str())
895 .map(str::to_string)
896 .unwrap_or_else(|| format!("{}.lua", agent_def.name));
897 let script = Arc::new(LuaScriptSource::new(source.to_string(), label));
898 let bridges = Arc::new(self.bridges.clone());
899 let wrapped: WorkerFn = Arc::new(move |inv| {
900 let source = script.clone();
901 let bridges = bridges.clone();
902 Box::pin(run_lua_worker(source, bridges, inv))
903 });
904 let mut sp: InProcSpawner<LuaWorker> = InProcSpawner::<LuaWorker>::typed();
905 sp.registry.insert(agent_def.name.to_string(), wrapped);
906 return Ok(Arc::new(sp));
907 }
908 build_inproc_from_registry::<LuaWorker>(&self.registry, agent_def, "lua")
909 }
910}
911
912pub struct RustFnInProcessSpawnerFactory {
926 registry: HashMap<String, WorkerFn>,
927}
928
929impl RustFnInProcessSpawnerFactory {
930 pub fn new() -> Self {
932 Self {
933 registry: HashMap::new(),
934 }
935 }
936
937 pub fn register_fn<F, Fut>(mut self, fn_id: impl Into<String>, f: F) -> Self
940 where
941 F: Fn(crate::worker::adapter::WorkerInvocation) -> Fut + Send + Sync + 'static,
942 Fut: std::future::Future<
943 Output = Result<
944 crate::worker::adapter::WorkerResult,
945 crate::worker::adapter::WorkerError,
946 >,
947 > + Send
948 + 'static,
949 {
950 let f = Arc::new(f);
951 let wrapped: WorkerFn = Arc::new(move |inv| {
952 let f = f.clone();
953 Box::pin(f(inv))
954 });
955 self.registry.insert(fn_id.into(), wrapped);
956 self
957 }
958}
959
960impl Default for RustFnInProcessSpawnerFactory {
961 fn default() -> Self {
962 Self::new()
963 }
964}
965
966impl SpawnerFactoryKind for RustFnInProcessSpawnerFactory {
967 const KIND: AgentKind = AgentKind::RustFn;
968 type Worker = RustFnWorker;
969}
970
971impl SpawnerFactory for RustFnInProcessSpawnerFactory {
972 fn build(
973 &self,
974 agent_def: &AgentDef,
975 _hint: Option<&Value>,
976 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
977 build_inproc_from_registry::<RustFnWorker>(&self.registry, agent_def, "rust_fn")
978 }
979}
980
981fn build_inproc_from_registry<W>(
987 registry: &HashMap<String, WorkerFn>,
988 agent_def: &AgentDef,
989 kind_label: &str,
990) -> Result<Arc<dyn SpawnerAdapter>, CompileError>
991where
992 W: crate::worker::Worker + From<crate::worker::WorkerJoinHandler> + Send + Sync + 'static,
993{
994 let agent_name = &agent_def.name;
995 let spec = &agent_def.spec;
996 let invalid = |msg: String| CompileError::InvalidSpec {
997 name: agent_name.to_string(),
998 msg,
999 };
1000 let fn_id = spec
1001 .get("fn_id")
1002 .and_then(|v| v.as_str())
1003 .ok_or_else(|| invalid(format!("{kind_label} spec: 'fn_id' (string) required")))?;
1004 let f = registry
1005 .get(fn_id)
1006 .cloned()
1007 .ok_or_else(|| invalid(format!("fn_id '{fn_id}' not registered in factory")))?;
1008 let mut sp: InProcSpawner<W> = InProcSpawner::<W>::typed();
1009 sp.registry.insert(agent_name.to_string(), f);
1013 Ok(Arc::new(sp))
1014}
1015
1016pub struct LuaWorker {
1021 pub handler: crate::worker::WorkerJoinHandler,
1023}
1024
1025impl From<crate::worker::WorkerJoinHandler> for LuaWorker {
1026 fn from(handler: crate::worker::WorkerJoinHandler) -> Self {
1027 Self { handler }
1028 }
1029}
1030
1031#[async_trait::async_trait]
1032impl crate::worker::Worker for LuaWorker {
1033 fn id(&self) -> &crate::types::WorkerId {
1034 &self.handler.worker_id
1035 }
1036 fn cancel_token(&self) -> tokio_util::sync::CancellationToken {
1037 self.handler.cancel.clone()
1038 }
1039 async fn join(self: Box<Self>) -> Result<(), crate::worker::adapter::WorkerError> {
1040 self.handler.await_completion().await
1041 }
1042}
1043
1044pub struct RustFnWorker {
1049 pub handler: crate::worker::WorkerJoinHandler,
1051}
1052
1053impl From<crate::worker::WorkerJoinHandler> for RustFnWorker {
1054 fn from(handler: crate::worker::WorkerJoinHandler) -> Self {
1055 Self { handler }
1056 }
1057}
1058
1059#[async_trait::async_trait]
1060impl crate::worker::Worker for RustFnWorker {
1061 fn id(&self) -> &crate::types::WorkerId {
1062 &self.handler.worker_id
1063 }
1064 fn cancel_token(&self) -> tokio_util::sync::CancellationToken {
1065 self.handler.cancel.clone()
1066 }
1067 async fn join(self: Box<Self>) -> Result<(), crate::worker::adapter::WorkerError> {
1068 self.handler.await_completion().await
1069 }
1070}
1071
1072pub struct OperatorSpawnerFactory {
1125 operators: Arc<std::sync::RwLock<HashMap<String, Arc<dyn Operator>>>>,
1126}
1127
1128impl OperatorSpawnerFactory {
1129 pub fn new() -> Self {
1131 Self {
1132 operators: Arc::new(std::sync::RwLock::new(HashMap::new())),
1133 }
1134 }
1135
1136 pub fn register_operator(&self, id: impl Into<String>, op: Arc<dyn Operator>) -> &Self {
1142 self.operators
1143 .write()
1144 .expect("OperatorSpawnerFactory.operators RwLock poisoned")
1145 .insert(id.into(), op);
1146 self
1147 }
1148
1149 pub fn unregister_operator(&self, id: &str) -> &Self {
1152 self.operators
1153 .write()
1154 .expect("OperatorSpawnerFactory.operators RwLock poisoned")
1155 .remove(id);
1156 self
1157 }
1158}
1159
1160impl Default for OperatorSpawnerFactory {
1161 fn default() -> Self {
1162 Self::new()
1163 }
1164}
1165
1166impl SpawnerFactoryKind for OperatorSpawnerFactory {
1167 const KIND: AgentKind = AgentKind::Operator;
1168 type Worker = crate::operator::OperatorWorker;
1169}
1170
1171impl SpawnerFactory for OperatorSpawnerFactory {
1172 fn build(
1173 &self,
1174 agent_def: &AgentDef,
1175 _hint: Option<&Value>,
1176 ) -> Result<Arc<dyn SpawnerAdapter>, CompileError> {
1177 let agent_name = &agent_def.name;
1178 let spec = &agent_def.spec;
1179 let system_prompt = agent_def.profile.as_ref().map(|p| p.system_prompt.clone());
1185 let invalid = |msg: String| CompileError::InvalidSpec {
1186 name: agent_name.to_string(),
1187 msg,
1188 };
1189 let op_ref = spec
1190 .get("operator_ref")
1191 .and_then(|v| v.as_str())
1192 .ok_or_else(|| invalid("operator spec: 'operator_ref' (string) required".into()))?;
1193 let operators = self
1194 .operators
1195 .read()
1196 .expect("OperatorSpawnerFactory.operators RwLock poisoned");
1197 let op = operators.get(op_ref).cloned().ok_or_else(|| {
1198 let mut names: Vec<String> = operators.keys().cloned().collect();
1199 names.sort();
1200 let names_list = if names.is_empty() {
1201 "<none>".to_string()
1202 } else {
1203 names.join(", ")
1204 };
1205 invalid(format!(
1206 "operator_ref '{op_ref}' not registered in factory. \
1207 Registered sids: [{names_list}]. \
1208 Hint: call mse_operator_join(roles=[...]) to mint the sid first."
1209 ))
1210 })?;
1211 drop(operators);
1212
1213 let worker_binding = agent_def
1220 .profile
1221 .as_ref()
1222 .and_then(|p| p.worker_binding.as_ref())
1223 .map(|variant| WorkerBinding {
1224 variant: variant.clone(),
1225 tools: agent_def
1226 .profile
1227 .as_ref()
1228 .map(|p| p.tools.clone())
1229 .unwrap_or_default(),
1230 });
1231 if op.requires_worker_binding() && worker_binding.is_none() {
1232 return Err(invalid(
1237 "profile.worker_binding is required for this operator backend. \
1238 Fix by either: \
1239 (a) if authoring the Blueprint JSON directly, add \
1240 `agents[N].profile.worker_binding: \"<subagent-type>\"` \
1241 to the JSON literal; or \
1242 (b) if using an $agent_md file ref, add \
1243 `worker_binding: <subagent-type>` to the agent .md frontmatter."
1244 .into(),
1245 ));
1246 }
1247 Ok(Arc::new(OperatorSpawner::new(
1248 op,
1249 system_prompt,
1250 worker_binding,
1251 )))
1252 }
1253}
1254
1255#[cfg(test)]
1256mod operator_spawner_factory_worker_binding_tests {
1257 use super::*;
1258 use crate::blueprint::AgentProfile;
1259 use crate::core::ctx::Ctx;
1260 use crate::types::CapToken;
1261 use crate::worker::adapter::{WorkerError, WorkerResult};
1262
1263 struct StubOperator {
1268 requires_binding: bool,
1269 }
1270
1271 #[async_trait]
1272 impl Operator for StubOperator {
1273 async fn execute(
1274 &self,
1275 _ctx: &Ctx,
1276 _system: Option<String>,
1277 _prompt: Value,
1278 _worker: Option<WorkerBinding>,
1279 _worker_token: CapToken,
1280 ) -> Result<WorkerResult, WorkerError> {
1281 Ok(WorkerResult {
1282 value: Value::Null,
1283 ok: true,
1284 })
1285 }
1286
1287 fn requires_worker_binding(&self) -> bool {
1288 self.requires_binding
1289 }
1290 }
1291
1292 fn agent_def_with(profile: Option<AgentProfile>) -> AgentDef {
1293 AgentDef {
1294 name: "test-agent".to_string(),
1295 kind: AgentKind::Operator,
1296 spec: serde_json::json!({ "operator_ref": "op1" }),
1297 profile,
1298 meta: None,
1299 }
1300 }
1301
1302 #[test]
1303 fn build_fails_loud_when_binding_required_but_absent() {
1304 let factory = OperatorSpawnerFactory::new();
1305 factory.register_operator(
1306 "op1",
1307 Arc::new(StubOperator {
1308 requires_binding: true,
1309 }) as Arc<dyn Operator>,
1310 );
1311 let def = agent_def_with(Some(AgentProfile::default()));
1312 match factory.build(&def, None) {
1313 Err(CompileError::InvalidSpec { name, msg }) => {
1314 assert_eq!(name, "test-agent");
1315 assert!(
1316 msg.contains("worker_binding is required"),
1317 "unexpected message: {msg}"
1318 );
1319 assert!(
1323 msg.contains("agents[N].profile.worker_binding"),
1324 "message missing JSON-direct hint (issue #9): {msg}"
1325 );
1326 assert!(
1327 msg.contains("agent .md frontmatter"),
1328 "message missing $agent_md hint: {msg}"
1329 );
1330 }
1331 Err(other) => panic!("expected InvalidSpec, got: {other:?}"),
1332 Ok(_) => panic!("expected compile-time failure, got Ok"),
1333 }
1334 }
1335
1336 #[test]
1337 fn build_succeeds_when_binding_required_and_present() {
1338 let factory = OperatorSpawnerFactory::new();
1339 factory.register_operator(
1340 "op1",
1341 Arc::new(StubOperator {
1342 requires_binding: true,
1343 }) as Arc<dyn Operator>,
1344 );
1345 let profile = AgentProfile {
1346 worker_binding: Some("mse-worker-coder".to_string()),
1347 tools: vec!["Read".to_string(), "Edit".to_string()],
1348 ..Default::default()
1349 };
1350 let def = agent_def_with(Some(profile));
1351 assert!(
1352 factory.build(&def, None).is_ok(),
1353 "expected Ok when worker_binding is declared"
1354 );
1355 }
1356
1357 #[test]
1358 fn build_succeeds_when_binding_not_required_and_absent() {
1359 let factory = OperatorSpawnerFactory::new();
1360 factory.register_operator(
1361 "op1",
1362 Arc::new(StubOperator {
1363 requires_binding: false,
1364 }) as Arc<dyn Operator>,
1365 );
1366 let def = agent_def_with(Some(AgentProfile::default()));
1367 assert!(
1368 factory.build(&def, None).is_ok(),
1369 "backends that don't require a binding must not be gated by its absence"
1370 );
1371 }
1372}
1373
1374#[cfg(test)]
1382mod lua_inline_source_tests {
1383 use super::*;
1384 use crate::types::{CapToken, Role, StepId};
1385
1386 fn agent(name: &str, spec: Value) -> AgentDef {
1387 AgentDef {
1388 name: name.to_string(),
1389 kind: AgentKind::Lua,
1390 spec,
1391 profile: None,
1392 meta: None,
1393 }
1394 }
1395
1396 fn test_invocation(prompt: &str) -> crate::worker::adapter::WorkerInvocation {
1397 crate::worker::adapter::WorkerInvocation {
1398 token: CapToken {
1399 agent_id: "a".into(),
1400 role: Role::Worker,
1401 scopes: vec!["*".into()],
1402 issued_at: 0,
1403 expire_at: u64::MAX / 2,
1404 max_uses: None,
1405 nonce: "test-nonce".into(),
1406 sig_hex: "".into(),
1407 },
1408 task_id: StepId::parse("ST-test").expect("StepId parse"),
1409 attempt: 1,
1410 agent: "g".into(),
1411 prompt: prompt.into(),
1412 sink: None,
1413 cancel_token: None,
1414 }
1415 }
1416
1417 #[test]
1418 fn build_accepts_inline_source_without_pre_registration() {
1419 let factory = LuaInProcessSpawnerFactory::new();
1420 let def = agent(
1421 "g",
1422 serde_json::json!({ "source": "return { value = 42, ok = true }" }),
1423 );
1424 assert!(
1425 factory.build(&def, None).is_ok(),
1426 "inline spec.source must build without a pre-registered fn_id"
1427 );
1428 }
1429
1430 #[test]
1431 fn build_rejects_when_neither_source_nor_fn_id_is_present() {
1432 let factory = LuaInProcessSpawnerFactory::new();
1433 let def = agent("g", serde_json::json!({}));
1434 match factory.build(&def, None) {
1435 Err(CompileError::InvalidSpec { msg, .. }) => {
1436 assert!(
1437 msg.contains("fn_id"),
1438 "empty spec must still surface the fn_id-required message: {msg}"
1439 );
1440 }
1441 Err(other) => panic!("expected InvalidSpec, got a different CompileError: {other}"),
1442 Ok(_) => panic!("expected InvalidSpec, got Ok(SpawnerAdapter)"),
1445 }
1446 }
1447
1448 #[tokio::test]
1452 async fn inline_source_evaluates_and_marshals_result() {
1453 let source =
1454 LuaScriptSource::new("return { value = _PROMPT .. '!', ok = true }", "smoke.lua");
1455 let out = run_lua_worker(
1456 std::sync::Arc::new(source),
1457 std::sync::Arc::new(HashMap::new()),
1458 test_invocation("hello"),
1459 )
1460 .await
1461 .expect("lua worker ok");
1462 assert_eq!(out.value, serde_json::json!("hello!"));
1463 assert!(out.ok);
1464 }
1465
1466 #[tokio::test]
1467 async fn inline_source_can_signal_agent_level_failure() {
1468 let source = LuaScriptSource::new("return { value = 'nope', ok = false }", "gate.lua");
1471 let out = run_lua_worker(
1472 std::sync::Arc::new(source),
1473 std::sync::Arc::new(HashMap::new()),
1474 test_invocation("input"),
1475 )
1476 .await
1477 .expect("lua worker ok");
1478 assert_eq!(out.value, serde_json::json!("nope"));
1479 assert!(!out.ok);
1480 }
1481}
1482
1483#[cfg(test)]
1486mod meta_ref_validation_tests {
1487 use super::*;
1488 use crate::blueprint::{AgentMeta, MetaDef};
1489 use crate::worker::adapter::WorkerResult;
1490
1491 fn registry_with_echo() -> SpawnerRegistry {
1492 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1493 Ok(WorkerResult {
1494 value: Value::String(inv.prompt),
1495 ok: true,
1496 })
1497 });
1498 let mut reg = SpawnerRegistry::new();
1499 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
1500 reg
1501 }
1502
1503 fn rustfn_agent(name: &str) -> AgentDef {
1504 AgentDef {
1505 name: name.to_string(),
1506 kind: AgentKind::RustFn,
1507 spec: serde_json::json!({ "fn_id": "echo" }),
1508 profile: None,
1509 meta: None,
1510 }
1511 }
1512
1513 fn simple_flow(agent_ref: &str, in_: Expr) -> FlowNode {
1514 FlowNode::Step {
1515 ref_: agent_ref.to_string(),
1516 in_,
1517 out: Expr::Path {
1518 at: "$.output".into(),
1519 },
1520 }
1521 }
1522
1523 fn minimal_bp(agents: Vec<AgentDef>, metas: Vec<MetaDef>, flow: FlowNode) -> Blueprint {
1524 Blueprint {
1525 schema_version: crate::blueprint::current_schema_version(),
1526 id: "meta-ref-ut".into(),
1527 flow,
1528 agents,
1529 operators: vec![],
1530 metas,
1531 hints: Default::default(),
1532 strategy: Default::default(),
1533 metadata: BlueprintMetadata::default(),
1534 spawner_hints: Default::default(),
1535 default_agent_kind: AgentKind::Operator,
1536 default_operator_kind: None,
1537 default_init_ctx: None,
1538 default_agent_ctx: None,
1539 default_context_policy: None,
1540 projection_placement: None,
1541 audits: vec![],
1542 degradation_policy: None,
1543 }
1544 }
1545
1546 #[test]
1547 fn valid_meta_ref_compiles() {
1548 let mut agent = rustfn_agent("worker");
1549 agent.meta = Some(AgentMeta {
1550 meta_ref: Some("shared".to_string()),
1551 ..Default::default()
1552 });
1553 let bp = minimal_bp(
1554 vec![agent],
1555 vec![MetaDef {
1556 name: "shared".into(),
1557 ctx: serde_json::json!({ "k": "v" }),
1558 }],
1559 simple_flow(
1560 "worker",
1561 Expr::Path {
1562 at: "$.input".into(),
1563 },
1564 ),
1565 );
1566 let compiler = Compiler::new(registry_with_echo());
1567 assert!(
1568 compiler.compile(&bp).is_ok(),
1569 "a resolvable AgentMeta.meta_ref must compile"
1570 );
1571 }
1572
1573 #[test]
1574 fn unknown_agent_meta_ref_is_unresolved_meta_ref() {
1575 let mut agent = rustfn_agent("worker");
1576 agent.meta = Some(AgentMeta {
1577 meta_ref: Some("missing".to_string()),
1578 ..Default::default()
1579 });
1580 let bp = minimal_bp(
1581 vec![agent],
1582 vec![],
1583 simple_flow(
1584 "worker",
1585 Expr::Path {
1586 at: "$.input".into(),
1587 },
1588 ),
1589 );
1590 let compiler = Compiler::new(registry_with_echo());
1591 match compiler.compile(&bp) {
1592 Err(CompileError::UnresolvedMetaRef {
1593 where_,
1594 meta_ref,
1595 defined,
1596 }) => {
1597 assert!(
1598 where_.contains("worker"),
1599 "where_ must name the agent: {where_}"
1600 );
1601 assert_eq!(meta_ref, "missing");
1602 assert!(defined.is_empty());
1603 }
1604 Err(other) => {
1605 panic!("expected UnresolvedMetaRef, got a different CompileError: {other}")
1606 }
1607 Ok(_) => panic!("expected compile-time failure, got Ok"),
1608 }
1609 }
1610
1611 #[test]
1612 fn unknown_static_step_meta_ref_in_lit_is_unresolved_meta_ref() {
1613 let agent = rustfn_agent("worker");
1614 let in_ = Expr::Lit {
1615 value: serde_json::json!({ "$step_meta": { "ref": "missing" }, "$in": "go" }),
1616 };
1617 let bp = minimal_bp(vec![agent], vec![], simple_flow("worker", in_));
1618 let compiler = Compiler::new(registry_with_echo());
1619 match compiler.compile(&bp) {
1620 Err(CompileError::UnresolvedMetaRef {
1621 where_, meta_ref, ..
1622 }) => {
1623 assert!(
1624 where_.contains("worker"),
1625 "where_ must name the offending step: {where_}"
1626 );
1627 assert_eq!(meta_ref, "missing");
1628 }
1629 Err(other) => {
1630 panic!("expected UnresolvedMetaRef, got a different CompileError: {other}")
1631 }
1632 Ok(_) => panic!("expected compile-time failure, got Ok"),
1633 }
1634 }
1635
1636 #[test]
1637 fn path_op_input_with_no_static_envelope_compiles_fine() {
1638 let agent = rustfn_agent("worker");
1639 let bp = minimal_bp(
1640 vec![agent],
1641 vec![],
1642 simple_flow(
1643 "worker",
1644 Expr::Path {
1645 at: "$.input".into(),
1646 },
1647 ),
1648 );
1649 let compiler = Compiler::new(registry_with_echo());
1650 assert!(
1651 compiler.compile(&bp).is_ok(),
1652 "a non-Lit Step.in must not trigger the best-effort static $step_meta check"
1653 );
1654 }
1655}
1656
1657#[cfg(test)]
1659mod audit_agent_validation_tests {
1660 use super::*;
1661 use crate::worker::adapter::WorkerResult;
1662 use mlua_swarm_schema::{AuditDef, AuditMode};
1663
1664 fn registry_with_echo() -> SpawnerRegistry {
1665 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1666 Ok(WorkerResult {
1667 value: Value::String(inv.prompt),
1668 ok: true,
1669 })
1670 });
1671 let mut reg = SpawnerRegistry::new();
1672 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
1673 reg
1674 }
1675
1676 fn rustfn_agent(name: &str) -> AgentDef {
1677 AgentDef {
1678 name: name.to_string(),
1679 kind: AgentKind::RustFn,
1680 spec: serde_json::json!({ "fn_id": "echo" }),
1681 profile: None,
1682 meta: None,
1683 }
1684 }
1685
1686 fn minimal_bp(agents: Vec<AgentDef>, audits: Vec<AuditDef>) -> Blueprint {
1687 Blueprint {
1688 schema_version: crate::blueprint::current_schema_version(),
1689 id: "audit-ref-ut".into(),
1690 flow: FlowNode::Step {
1691 ref_: "worker".to_string(),
1692 in_: Expr::Path {
1693 at: "$.input".into(),
1694 },
1695 out: Expr::Path {
1696 at: "$.output".into(),
1697 },
1698 },
1699 agents,
1700 operators: vec![],
1701 metas: vec![],
1702 hints: Default::default(),
1703 strategy: Default::default(),
1704 metadata: BlueprintMetadata::default(),
1705 spawner_hints: Default::default(),
1706 default_agent_kind: AgentKind::Operator,
1707 default_operator_kind: None,
1708 default_init_ctx: None,
1709 default_agent_ctx: None,
1710 default_context_policy: None,
1711 projection_placement: None,
1712 audits,
1713 degradation_policy: None,
1714 }
1715 }
1716
1717 #[test]
1718 fn unresolved_audit_agent_is_a_loud_compile_error() {
1719 let bp = minimal_bp(
1720 vec![rustfn_agent("worker")],
1721 vec![AuditDef {
1722 agent: "missing-auditor".to_string(),
1723 steps: None,
1724 mode: AuditMode::default(),
1725 }],
1726 );
1727 let compiler = Compiler::new(registry_with_echo());
1728 match compiler.compile(&bp) {
1729 Err(CompileError::UnresolvedAuditAgent { agent, defined }) => {
1730 assert_eq!(agent, "missing-auditor");
1731 assert_eq!(defined, vec!["worker".to_string()]);
1732 }
1733 Err(other) => {
1734 panic!("expected UnresolvedAuditAgent, got a different CompileError: {other}")
1735 }
1736 Ok(_) => panic!("expected compile-time failure, got Ok"),
1737 }
1738 }
1739
1740 #[test]
1741 fn resolved_audit_agent_compiles_fine() {
1742 let bp = minimal_bp(
1743 vec![rustfn_agent("worker"), rustfn_agent("auditor")],
1744 vec![AuditDef {
1745 agent: "auditor".to_string(),
1746 steps: None,
1747 mode: AuditMode::default(),
1748 }],
1749 );
1750 let compiler = Compiler::new(registry_with_echo());
1751 assert!(
1752 compiler.compile(&bp).is_ok(),
1753 "an audits[].agent that names a declared AgentDef must compile"
1754 );
1755 }
1756}
1757
1758#[cfg(test)]
1761mod projection_placement_compile_tests {
1762 use super::*;
1763 use crate::core::projection_placement::{ProjectionPlacement, RootPreference};
1764 use crate::worker::adapter::WorkerResult;
1765 use mlua_swarm_schema::ProjectionPlacementSpec;
1766
1767 fn registry_with_echo() -> SpawnerRegistry {
1768 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1769 Ok(WorkerResult {
1770 value: Value::String(inv.prompt),
1771 ok: true,
1772 })
1773 });
1774 let mut reg = SpawnerRegistry::new();
1775 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
1776 reg
1777 }
1778
1779 fn minimal_bp(projection_placement: Option<ProjectionPlacementSpec>) -> Blueprint {
1780 Blueprint {
1781 schema_version: crate::blueprint::current_schema_version(),
1782 id: "projection-placement-ut".into(),
1783 flow: FlowNode::Step {
1784 ref_: "worker".to_string(),
1785 in_: Expr::Path {
1786 at: "$.input".into(),
1787 },
1788 out: Expr::Path {
1789 at: "$.output".into(),
1790 },
1791 },
1792 agents: vec![AgentDef {
1793 name: "worker".to_string(),
1794 kind: AgentKind::RustFn,
1795 spec: serde_json::json!({ "fn_id": "echo" }),
1796 profile: None,
1797 meta: None,
1798 }],
1799 operators: vec![],
1800 metas: vec![],
1801 hints: Default::default(),
1802 strategy: Default::default(),
1803 metadata: BlueprintMetadata::default(),
1804 spawner_hints: Default::default(),
1805 default_agent_kind: AgentKind::Operator,
1806 default_operator_kind: None,
1807 default_init_ctx: None,
1808 default_agent_ctx: None,
1809 default_context_policy: None,
1810 projection_placement,
1811 audits: vec![],
1812 degradation_policy: None,
1813 }
1814 }
1815
1816 #[test]
1817 fn undeclared_projection_placement_compiles_to_byte_compat_default() {
1818 let bp = minimal_bp(None);
1819 let compiled = Compiler::new(registry_with_echo())
1820 .compile(&bp)
1821 .expect("undeclared projection_placement compiles");
1822 assert_eq!(
1823 *compiled.projection_placement,
1824 ProjectionPlacement::default()
1825 );
1826 }
1827
1828 #[test]
1829 fn declared_valid_projection_placement_compiles_to_matching_resolver() {
1830 let bp = minimal_bp(Some(ProjectionPlacementSpec {
1831 root: Some("project_root".to_string()),
1832 dir_template: Some("custom/{task_id}/out".to_string()),
1833 }));
1834 let compiled = Compiler::new(registry_with_echo())
1835 .compile(&bp)
1836 .expect("valid projection_placement compiles");
1837 assert_eq!(
1838 compiled.projection_placement.root_preference,
1839 RootPreference::ProjectRoot
1840 );
1841 assert_eq!(
1842 compiled.projection_placement.dir_template,
1843 "custom/{task_id}/out"
1844 );
1845 }
1846
1847 #[test]
1848 fn declared_invalid_dir_template_rejects_compile() {
1849 let bp = minimal_bp(Some(ProjectionPlacementSpec {
1850 root: None,
1851 dir_template: Some("workspace/tasks/ctx".to_string()), }));
1853 match Compiler::new(registry_with_echo()).compile(&bp) {
1854 Err(CompileError::InvalidProjectionPlacement(_)) => {}
1855 Err(other) => {
1856 panic!("expected InvalidProjectionPlacement, got a different CompileError: {other}")
1857 }
1858 Ok(_) => {
1859 panic!("expected compile-time rejection for a missing {{task_id}} placeholder")
1860 }
1861 }
1862 }
1863
1864 #[test]
1865 fn declared_invalid_root_literal_rejects_compile() {
1866 let bp = minimal_bp(Some(ProjectionPlacementSpec {
1867 root: Some("nope".to_string()),
1868 dir_template: None,
1869 }));
1870 match Compiler::new(registry_with_echo()).compile(&bp) {
1871 Err(CompileError::InvalidProjectionPlacement(_)) => {}
1872 Err(other) => {
1873 panic!("expected InvalidProjectionPlacement, got a different CompileError: {other}")
1874 }
1875 Ok(_) => panic!("expected compile-time rejection for an invalid root literal"),
1876 }
1877 }
1878}