Skip to main content

mlua_swarm/service/
task_launch.rs

1//! `TaskLaunchService` — the domain service that runs a Blueprint flow
2//! to completion through the engine.
3//!
4//! Responsibilities:
5//! 1. Compile the Blueprint and link it into a `SpawnerAdapter` (via
6//!    `service::linker::link`, wrapped by `EngineDispatcher::with_spawner`).
7//! 2. Acquire an Operator session (via `engine.attach`).
8//! 3. Run flow.ir's `eval_async_externs` through an `EngineDispatcher`
9//!    (threading the service-held `call_extern` registry) and return
10//!    the final `ctx`.
11//! 4. If any step fails (dispatcher error), the eval errors and
12//!    the failure propagates as-is.
13//!
14//! Callers on the Application layer never touch the engine directly —
15//! `bind`, `start_task`, and `eval_async` all stay inside the Service.
16//!
17//! A single-task-spawn API (calling `start_task` directly) is
18//! deliberately absent here: a single spawn can be modeled as a
19//! one-Step flow, and we do not want two interfaces for the same
20//! shape.
21
22use crate::blueprint::compiler::{CompileError, Compiler};
23use crate::blueprint::{Blueprint, EngineDispatcher};
24use crate::core::ctx::OperatorKind;
25use crate::core::engine::Engine;
26use crate::core::errors::EngineError;
27use crate::middleware::project_name_alias::ProjectNameAliasMiddleware;
28use crate::middleware::worker_binding::WorkerBindingMiddleware;
29use crate::middleware::SpawnerStack;
30use crate::operator::WorkerBinding;
31use crate::service::linker;
32use crate::store::run::RunContext;
33use crate::types::{CapToken, Role};
34use mlua_flow_ir::{Externs, NoExterns};
35use serde_json::Value;
36use std::collections::HashMap;
37use std::sync::Arc;
38use std::time::Duration;
39use thiserror::Error;
40
41/// Derive the "BP Agent-level" tier of the `OperatorKind` cascade from a
42/// Blueprint: for every `AgentDef` whose `spec.operator_ref` resolves to an
43/// `OperatorDef` with a `Some` `kind`, map `AgentDef.name -> OperatorKind`.
44///
45/// Deliberately **not** filtered by `AgentDef.kind == AgentKind::Operator`:
46/// the `OperatorKind` cascade is a middleware-level cross-cutting concern
47/// (spawn_hook / senior_bridge / operator-delegate gating via `Ctx.operator`),
48/// orthogonal to the Worker IMPL axis that `AgentKind` expresses (see the
49/// crate root doc, "Operator is delivered as a cross-cutting overlay through
50/// `Ctx` plus middleware"). A `RustFn` / `Lua` / `Subprocess` agent can
51/// equally declare `spec.operator_ref` to opt into a BP-declared
52/// `OperatorKind` without changing its Worker IMPL. Agents without an
53/// `operator_ref`, an unresolved `operator_ref`, or an `OperatorDef.kind =
54/// None` are simply absent from the map (= that tier falls through for
55/// them). This is a separate, independent consumer of `Blueprint.operators`
56/// from the design-time `operator_ref` validation in
57/// `blueprint::compiler::Compiler::compile` (issue: `OperatorDef`
58/// first-class treatment), which only checks the reference resolves for
59/// `AgentKind::Operator` agents and is unaffected by this function.
60/// Build the `agent name → WorkerBinding` map from
61/// `Blueprint.agents[].profile.worker_binding` — the launch-time sibling of
62/// the compile-time resolution in `OperatorSpawnerFactory::build`. Consumed
63/// by `WorkerBindingMiddleware` so the delegate axis
64/// (`OperatorDelegateMiddleware`) can resolve the binding via `ctx.agent`
65/// like every other agent-keyed table (`CompiledAgentTable.routes` idiom).
66/// Agents without a declared binding are simply absent (no silent default).
67fn derive_worker_bindings(blueprint: &Blueprint) -> HashMap<String, WorkerBinding> {
68    blueprint
69        .agents
70        .iter()
71        .filter_map(|ad| {
72            let profile = ad.profile.as_ref()?;
73            let variant = profile.worker_binding.as_ref()?;
74            Some((
75                ad.name.clone(),
76                WorkerBinding {
77                    variant: variant.clone(),
78                    tools: profile.tools.clone(),
79                },
80            ))
81        })
82        .collect()
83}
84
85fn derive_bp_agent_kinds(blueprint: &Blueprint) -> HashMap<String, OperatorKind> {
86    let mut out = HashMap::new();
87    if blueprint.operators.is_empty() {
88        return out;
89    }
90    for agent in &blueprint.agents {
91        let Some(op_ref) = agent.spec.get("operator_ref").and_then(|v| v.as_str()) else {
92            continue;
93        };
94        let Some(op_def) = blueprint.operators.iter().find(|o| o.name == op_ref) else {
95            continue;
96        };
97        if let Some(kind) = op_def.kind {
98            out.insert(agent.name.clone(), OperatorKind::from(kind));
99        }
100    }
101    out
102}
103
104/// Failure modes of [`TaskLaunchService::launch`].
105#[derive(Debug, Error)]
106pub enum TaskLaunchError {
107    /// `Compiler::compile` rejected the Blueprint.
108    #[error("compile: {0}")]
109    Compile(#[from] CompileError),
110    /// `Engine::attach_with_ids` failed.
111    #[error("engine: {0}")]
112    Engine(#[from] EngineError),
113    /// A `Step` inside `flow.ir`'s `eval_async` produced a dispatcher
114    /// error, or a sub-flow raised.
115    #[error("flow eval: {0}")]
116    FlowEval(String),
117}
118
119/// Input to [`TaskLaunchService::launch`].
120#[derive(Debug, Clone)]
121pub struct TaskLaunchInput {
122    /// The Blueprint to compile, link, and run.
123    pub blueprint: Blueprint,
124    /// Caller-supplied id for the Operator that owns this run.
125    pub operator_id: String,
126    /// The Operator's role for this run.
127    pub role: Role,
128    /// How long the attached session is allowed to live.
129    pub ttl: Duration,
130    /// "Runtime Global" tier of the `OperatorKind` cascade. `Some(_)` is
131    /// always an explicit request — including `Some(OperatorKind::Automate)`
132    /// — that outranks the BP-level tiers (`OperatorDef.kind` /
133    /// `Blueprint.default_operator_kind`); `None` leaves it unspecified so
134    /// those tiers / the final default decide. Under `MainAi` or
135    /// `Composite`, `MainAIMiddleware`'s `spawn_hook` before/after
136    /// callbacks become effective. See
137    /// `crate::core::ctx::collapse_operator_kind`.
138    pub operator_kind: Option<OperatorKind>,
139    /// `SeniorBridge` registry ID. `None` — no bridge; `Some(id)` —
140    /// attach a bridge previously registered via
141    /// `engine.register_senior_bridge`.
142    pub bridge_id: Option<String>,
143    /// `SpawnHook` registry ID. Same shape as above, via
144    /// `engine.register_spawn_hook`.
145    pub hook_id: Option<String>,
146    /// Operator registry ID — used on the path that hands the whole
147    /// spawn off to an external Operator. Name previously registered
148    /// with `engine.register_operator`; resolved by
149    /// `OperatorDelegateMiddleware`, which — for `kind = MainAi` or
150    /// `Composite` — bypasses `inner.spawn` and calls
151    /// `operator.execute`.
152    pub operator_backend_id: Option<String>,
153    /// "Runtime Agent-level" tier (highest priority) of the `OperatorKind`
154    /// cascade — per-agent override, keyed by `AgentDef.name`. Empty by
155    /// default (no override for any agent). See
156    /// `crate::core::ctx::collapse_operator_kind` for the full tier list.
157    pub operator_kind_overrides: HashMap<String, OperatorKind>,
158    /// The initial `ctx` (JSON `Value`) that flow.ir's `eval_async`
159    /// starts from. Every `Step.in` `$.<path>` reference reads from
160    /// here.
161    pub init_ctx: Value,
162    /// Issue #13 run_id propagation: when `Some`, every step this launch
163    /// dispatches is traced into `RunRecord.step_entries` and exposes its
164    /// `run_id` via `Ctx.meta.runtime["run_id"]` (see
165    /// `EngineDispatcher::with_run`). `None` (the default via
166    /// [`Self::automate`]) preserves the pre-existing behavior — no run
167    /// tracing.
168    pub run_ctx: Option<RunContext>,
169}
170
171impl TaskLaunchInput {
172    /// Helper for existing callers on the default path — no hooks and no
173    /// per-agent `OperatorKind` overrides. Leaves the "Runtime Global" tier
174    /// unspecified (`None`), so the BP-level tiers / final default
175    /// (`OperatorKind::Automate`) decide — this preserves today's
176    /// behaviour for every existing caller without silently forcing
177    /// `Automate` as an explicit override that would outrank a BP-declared
178    /// `MainAi`/`Composite` kind. `run_ctx` defaults to `None` (no run
179    /// tracing); construct the struct literal directly to set it.
180    pub fn automate(
181        blueprint: Blueprint,
182        operator_id: impl Into<String>,
183        role: Role,
184        ttl: Duration,
185        init_ctx: Value,
186    ) -> Self {
187        Self {
188            blueprint,
189            operator_id: operator_id.into(),
190            role,
191            ttl,
192            operator_kind: None,
193            bridge_id: None,
194            hook_id: None,
195            operator_backend_id: None,
196            operator_kind_overrides: HashMap::new(),
197            init_ctx,
198            run_ctx: None,
199        }
200    }
201}
202
203/// Result of a successful [`TaskLaunchService::launch`] call.
204#[derive(Debug, Clone)]
205pub struct TaskLaunchOutput {
206    /// The capability token for the attached session.
207    pub token: CapToken,
208    /// The final `ctx` after the flow ran — every `Step.out` has
209    /// been written. Application-layer callers pull the outcome out
210    /// of this `Value` and fold it into a domain status.
211    pub final_ctx: Value,
212}
213
214/// Domain service that compiles, links, and runs a Blueprint's flow to
215/// completion through the [`Engine`]. See the module doc for the full
216/// responsibility list.
217pub struct TaskLaunchService {
218    engine: Engine,
219    compiler: Compiler,
220    /// `call_extern` registry threaded into flow eval. Defaults to
221    /// [`NoExterns`] (= every `call_extern` in a Blueprint raises
222    /// `ExternError`); hosts opt in via [`Self::with_externs`] with an
223    /// `ExternMap` of pure value-shape functions.
224    externs: Arc<dyn Externs + Send + Sync>,
225}
226
227impl TaskLaunchService {
228    /// Build a service bound to one `Engine` and one `Compiler`.
229    pub fn new(engine: Engine, compiler: Compiler) -> Self {
230        Self {
231            engine,
232            compiler,
233            externs: Arc::new(NoExterns),
234        }
235    }
236
237    /// Replace the `call_extern` registry (builder style). Entries MUST be
238    /// pure functions — no side effects, no flow control; effectful work
239    /// belongs to `Step` / agents, not externs (flow-ir canonical contract).
240    pub fn with_externs(mut self, externs: Arc<dyn Externs + Send + Sync>) -> Self {
241        self.externs = externs;
242        self
243    }
244
245    /// The bound `Engine`.
246    pub fn engine(&self) -> &Engine {
247        &self.engine
248    }
249
250    /// The bound `Compiler`.
251    pub fn compiler(&self) -> &Compiler {
252        &self.compiler
253    }
254
255    /// Run the Blueprint's flow to completion and return the final
256    /// `ctx`.
257    ///
258    /// Failure paths:
259    ///
260    /// - `compiler.compile` failure → `TaskLaunchError::Compile`.
261    /// - `engine.attach` failure → `TaskLaunchError::Engine`.
262    /// - A `Step` inside `flow eval` producing a dispatcher error, or
263    ///   a sub-flow raising, → `TaskLaunchError::FlowEval`. There is
264    ///   no silent partial-success completion; failures always
265    ///   propagate.
266    pub async fn launch(
267        &self,
268        input: TaskLaunchInput,
269    ) -> Result<TaskLaunchOutput, TaskLaunchError> {
270        // After the stateless-executor refactor, the
271        // caller (Service) does compile + link +
272        // `EngineDispatcher::with_spawner` itself; the engine no longer
273        // holds any global spawner state to touch. The link path (base
274        // `SpawnerAdapter` +
275        // `LayerRegistry` resolution + `SpawnerStack` wrapping) is
276        // concentrated inside `service::linker::link` — Service
277        // scatter is intentionally prevented.
278        let compiled = self.compiler.compile(&input.blueprint)?;
279        let spawner = linker::link(
280            compiled.router.clone(),
281            &input.blueprint.spawner_hints.layers,
282            &self.engine,
283        );
284        // When `Blueprint.metadata.project_name_alias` is Some, layer a
285        // `ProjectNameAliasMiddleware` on top of the stack that injects the
286        // alias into `Ctx.meta.runtime.project_name_alias` just before spawn.
287        // Downstream operators (for example, the server crate's
288        // `Operator.execute`) read `ctx.meta.runtime.get("project_name_alias")`
289        // and expand it into the Spawn directive prompt body.
290        let spawner = if let Some(alias) = input.blueprint.metadata.project_name_alias.as_deref() {
291            SpawnerStack::new(spawner)
292                .layer(ProjectNameAliasMiddleware::new(alias))
293                .build()
294        } else {
295            spawner
296        };
297        // Layer the Blueprint-baked worker bindings (same ctx.meta.runtime
298        // inject shape as the alias layer above) so the delegate axis can
299        // resolve per-agent variants — see `derive_worker_bindings`.
300        let worker_bindings = derive_worker_bindings(&input.blueprint);
301        let spawner = if worker_bindings.is_empty() {
302            spawner
303        } else {
304            SpawnerStack::new(spawner)
305                .layer(WorkerBindingMiddleware::new(worker_bindings))
306                .build()
307        };
308
309        // "BP Agent-level" (`OperatorDef.kind` via `operator_ref`) + "BP
310        // Global" (`Blueprint.default_operator_kind`) tiers of the
311        // `OperatorKind` cascade, baked here (the only point that has both
312        // the resolved Blueprint and the launch-time overrides in scope).
313        let bp_agent_kinds = derive_bp_agent_kinds(&input.blueprint);
314        let bp_global_kind = input
315            .blueprint
316            .default_operator_kind
317            .map(OperatorKind::from);
318
319        let token = self
320            .engine
321            .attach_with_ids(
322                input.operator_id,
323                input.role,
324                input.ttl,
325                input.operator_kind,
326                input.bridge_id,
327                input.hook_id,
328                input.operator_backend_id,
329                input.operator_kind_overrides,
330                bp_agent_kinds,
331                bp_global_kind,
332            )
333            .await?;
334        let dispatcher =
335            EngineDispatcher::with_spawner(self.engine.clone(), token.clone(), spawner);
336        let dispatcher = match input.run_ctx {
337            Some(run_ctx) => dispatcher.with_run(run_ctx),
338            None => dispatcher,
339        };
340        let final_ctx = mlua_flow_ir::eval_async_externs(
341            &input.blueprint.flow,
342            input.init_ctx,
343            &dispatcher,
344            &*self.externs,
345        )
346        .await
347        .map_err(|e| TaskLaunchError::FlowEval(e.to_string()))?;
348        Ok(TaskLaunchOutput { token, final_ctx })
349    }
350}
351
352// ──────────────────────────────────────────────────────────────────────────
353// UT
354// ──────────────────────────────────────────────────────────────────────────
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use crate::blueprint::compiler::{RustFnInProcessSpawnerFactory, SpawnerRegistry};
360    use crate::blueprint::{
361        current_schema_version, AgentDef, AgentKind, AgentMeta, BlueprintMetadata, CompilerHints,
362        CompilerStrategy,
363    };
364    use crate::core::config::EngineCfg;
365    use crate::worker::adapter::{WorkerError, WorkerResult};
366    use mlua_flow_ir::{Expr, JoinMode, Node as FlowNode};
367    use serde_json::json;
368    use std::sync::Arc;
369
370    fn path(s: &str) -> Expr {
371        Expr::Path { at: s.to_string() }
372    }
373    fn step(ref_: &str, in_: Expr, out: Expr) -> FlowNode {
374        FlowNode::Step {
375            ref_: ref_.to_string(),
376            in_,
377            out,
378        }
379    }
380
381    fn agent(name: &str, fn_id: &str) -> AgentDef {
382        AgentDef {
383            name: name.to_string(),
384            kind: AgentKind::RustFn,
385            spec: json!({ "fn_id": fn_id }),
386            profile: None,
387            meta: Some(AgentMeta::default()),
388        }
389    }
390
391    fn build_service(factory: RustFnInProcessSpawnerFactory) -> TaskLaunchService {
392        let engine = Engine::new(EngineCfg::default());
393        let mut reg = SpawnerRegistry::new();
394        reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
395        let compiler = Compiler::new(reg);
396        TaskLaunchService::new(engine, compiler)
397    }
398
399    fn bp(flow: FlowNode, agents: Vec<AgentDef>) -> Blueprint {
400        Blueprint {
401            schema_version: current_schema_version(),
402            id: "ut".into(),
403            flow,
404            agents,
405            operators: vec![],
406            hints: CompilerHints::default(),
407            strategy: CompilerStrategy::default(),
408            metadata: BlueprintMetadata::default(),
409            spawner_hints: Default::default(),
410            default_agent_kind: AgentKind::Operator,
411            default_operator_kind: None,
412        }
413    }
414
415    fn launch_input(blueprint: Blueprint, init_ctx: Value) -> TaskLaunchInput {
416        TaskLaunchInput::automate(
417            blueprint,
418            "ut-op",
419            Role::Operator,
420            Duration::from_secs(30),
421            init_ctx,
422        )
423    }
424
425    #[tokio::test]
426    async fn launch_single_step_writes_out_path() {
427        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
428            Ok(WorkerResult {
429                value: json!({ "echoed": inv.prompt }),
430                ok: true,
431            })
432        });
433        let svc = build_service(factory);
434        let blueprint = bp(
435            step("echo", path("$.input"), path("$.out")),
436            vec![agent("echo", "echo")],
437        );
438        let out = svc
439            .launch(launch_input(blueprint, json!({ "input": "hi" })))
440            .await
441            .expect("launch ok");
442        assert_eq!(out.final_ctx["out"]["echoed"], "hi");
443    }
444
445    #[tokio::test]
446    async fn launch_three_step_seq_threads_ctx_forward() {
447        let factory = RustFnInProcessSpawnerFactory::new()
448            .register_fn("upper", |inv| async move {
449                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
450                Ok(WorkerResult {
451                    value: json!(s.to_uppercase()),
452                    ok: true,
453                })
454            })
455            .register_fn("suffix", |inv| async move {
456                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
457                Ok(WorkerResult {
458                    value: json!(format!("{s}!")),
459                    ok: true,
460                })
461            })
462            .register_fn("wrap", |inv| async move {
463                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
464                Ok(WorkerResult {
465                    value: json!(format!("[{s}]")),
466                    ok: true,
467                })
468            });
469        let svc = build_service(factory);
470        let flow = FlowNode::Seq {
471            children: vec![
472                step("upper", path("$.in"), path("$.s1")),
473                step("suffix", path("$.s1"), path("$.s2")),
474                step("wrap", path("$.s2"), path("$.s3")),
475            ],
476        };
477        let blueprint = bp(
478            flow,
479            vec![
480                agent("upper", "upper"),
481                agent("suffix", "suffix"),
482                agent("wrap", "wrap"),
483            ],
484        );
485        let out = svc
486            .launch(launch_input(blueprint, json!({ "in": "hello" })))
487            .await
488            .expect("launch ok");
489        assert_eq!(out.final_ctx["s1"], "HELLO");
490        assert_eq!(out.final_ctx["s2"], "HELLO!");
491        assert_eq!(out.final_ctx["s3"], "[HELLO!]");
492    }
493
494    #[tokio::test]
495    async fn launch_fanout_join_all_parallel_completes() {
496        use std::sync::atomic::{AtomicU32, Ordering};
497        let counter = Arc::new(AtomicU32::new(0));
498        let max_seen = Arc::new(AtomicU32::new(0));
499        let counter_clone = counter.clone();
500        let max_clone = max_seen.clone();
501
502        // Each worker bumps the inflight counter up, sleeps 50ms, then bumps it down.
503        // When parallel execution is working, max inflight exceeds 1.
504        let factory = RustFnInProcessSpawnerFactory::new().register_fn("para", move |inv| {
505            let counter = counter_clone.clone();
506            let max_seen = max_clone.clone();
507            async move {
508                let now = counter.fetch_add(1, Ordering::SeqCst) + 1;
509                let mut prev = max_seen.load(Ordering::SeqCst);
510                while now > prev {
511                    match max_seen.compare_exchange(prev, now, Ordering::SeqCst, Ordering::SeqCst) {
512                        Ok(_) => break,
513                        Err(p) => prev = p,
514                    }
515                }
516                tokio::time::sleep(Duration::from_millis(50)).await;
517                counter.fetch_sub(1, Ordering::SeqCst);
518                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
519                Ok(WorkerResult {
520                    value: json!(format!("did:{s}")),
521                    ok: true,
522                })
523            }
524        });
525        let svc = build_service(factory);
526        let flow = FlowNode::Fanout {
527            items: path("$.items"),
528            bind: path("$.item"),
529            body: Box::new(step("para", path("$.item"), path("$.r"))),
530            join: JoinMode::All,
531            out: path("$.results"),
532        };
533        let blueprint = bp(flow, vec![agent("para", "para")]);
534        let out = svc
535            .launch(launch_input(
536                blueprint,
537                json!({ "items": ["a", "b", "c", "d"] }),
538            ))
539            .await
540            .expect("launch ok");
541        let results = out.final_ctx["results"].as_array().expect("array");
542        assert_eq!(results.len(), 4);
543        for (i, expected) in ["a", "b", "c", "d"].iter().enumerate() {
544            assert_eq!(results[i]["r"], json!(format!("did:{expected}")));
545        }
546        let max = max_seen.load(Ordering::SeqCst);
547        assert!(
548            max >= 2,
549            "expected parallel execution (max inflight >= 2), got {max}"
550        );
551    }
552
553    #[tokio::test]
554    async fn launch_propagates_worker_error_as_flow_eval_err() {
555        let factory = RustFnInProcessSpawnerFactory::new()
556            .register_fn("ok", |inv| async move {
557                Ok(WorkerResult {
558                    value: json!(inv.prompt),
559                    ok: true,
560                })
561            })
562            .register_fn("boom", |_inv| async move {
563                Err(WorkerError::Failed("intentional boom".into()))
564            });
565        let svc = build_service(factory);
566        let flow = FlowNode::Seq {
567            children: vec![
568                step("ok", path("$.input"), path("$.s1")),
569                step("boom", path("$.s1"), path("$.s2")),
570                step("ok", path("$.s2"), path("$.s3")),
571            ],
572        };
573        let blueprint = bp(flow, vec![agent("ok", "ok"), agent("boom", "boom")]);
574        let err = svc
575            .launch(launch_input(blueprint, json!({ "input": "x" })))
576            .await
577            .expect_err("expected fail");
578        match err {
579            TaskLaunchError::FlowEval(msg) => {
580                assert!(
581                    msg.contains("boom") || msg.contains("intentional"),
582                    "expected error to mention worker failure, got: {msg}"
583                );
584            }
585            other => panic!("expected FlowEval error, got {other:?}"),
586        }
587    }
588
589    #[tokio::test]
590    async fn launch_resolves_call_extern_via_registered_externs() {
591        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
592            Ok(WorkerResult {
593                value: json!({ "echoed": inv.prompt }),
594                ok: true,
595            })
596        });
597        let mut externs = mlua_flow_ir::ExternMap::new();
598        externs.register("fmt.greet", |args: &[Value]| {
599            let name = args[0].as_str().unwrap_or("?");
600            Ok(json!(format!("hello, {name}")))
601        });
602        let svc = build_service(factory).with_externs(Arc::new(externs));
603        let flow = step(
604            "echo",
605            Expr::CallExtern {
606                ref_: "fmt.greet".into(),
607                args: vec![path("$.who")],
608            },
609            path("$.out"),
610        );
611        let blueprint = bp(flow, vec![agent("echo", "echo")]);
612        let out = svc
613            .launch(launch_input(blueprint, json!({ "who": "swarm" })))
614            .await
615            .expect("launch ok");
616        assert_eq!(out.final_ctx["out"]["echoed"], json!("hello, swarm"));
617    }
618
619    #[tokio::test]
620    async fn launch_call_extern_without_registry_fails_as_flow_eval() {
621        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
622            Ok(WorkerResult {
623                value: json!(inv.prompt),
624                ok: true,
625            })
626        });
627        let svc = build_service(factory); // default NoExterns
628        let flow = step(
629            "echo",
630            Expr::CallExtern {
631                ref_: "fmt.greet".into(),
632                args: vec![],
633            },
634            path("$.out"),
635        );
636        let blueprint = bp(flow, vec![agent("echo", "echo")]);
637        let err = svc
638            .launch(launch_input(blueprint, json!({})))
639            .await
640            .expect_err("expected fail");
641        match err {
642            TaskLaunchError::FlowEval(msg) => {
643                assert!(msg.contains("extern"), "expected extern error, got: {msg}");
644            }
645            other => panic!("expected FlowEval error, got {other:?}"),
646        }
647    }
648
649    // ──────────────────────────────────────────────────────────────────
650    // issue #13 run_id propagation (`TaskLaunchInput.run_ctx`)
651    // ──────────────────────────────────────────────────────────────────
652
653    #[tokio::test]
654    async fn launch_with_run_ctx_appends_one_step_entry_per_dispatched_step() {
655        use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
656        use crate::types::{RunId, TaskId};
657
658        let factory = RustFnInProcessSpawnerFactory::new()
659            .register_fn("upper", |inv| async move {
660                Ok(WorkerResult {
661                    value: json!(inv.prompt.to_uppercase()),
662                    ok: true,
663                })
664            })
665            .register_fn("suffix", |inv| async move {
666                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
667                Ok(WorkerResult {
668                    value: json!(format!("{s}!")),
669                    ok: true,
670                })
671            });
672        let svc = build_service(factory);
673        let flow = FlowNode::Seq {
674            children: vec![
675                step("upper", path("$.in"), path("$.s1")),
676                step("suffix", path("$.s1"), path("$.s2")),
677            ],
678        };
679        let blueprint = bp(
680            flow,
681            vec![agent("upper", "upper"), agent("suffix", "suffix")],
682        );
683
684        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
685        let run_id = RunId::new();
686        run_store
687            .create(RunRecord {
688                id: run_id.clone(),
689                task_id: TaskId::new(),
690                status: RunStatus::Running,
691                step_entries: Vec::new(),
692                operator_sid: None,
693                result_ref: None,
694                created_at: 0,
695                updated_at: 0,
696            })
697            .await
698            .expect("seed RunRecord");
699
700        let mut input = launch_input(blueprint, json!({ "in": "hi" }));
701        input.run_ctx = Some(RunContext {
702            run_id: run_id.clone(),
703            run_store: run_store.clone(),
704        });
705
706        let out = svc.launch(input).await.expect("launch ok");
707        assert_eq!(out.final_ctx["s2"], "HI!");
708
709        let run = run_store.get(&run_id).await.expect("run present");
710        assert_eq!(
711            run.step_entries.len(),
712            2,
713            "expected one step_entry per dispatched step, got {:?}",
714            run.step_entries
715        );
716        assert_eq!(run.step_entries[0].step_ref, Some("upper".to_string()));
717        assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
718        assert_eq!(run.step_entries[1].step_ref, Some("suffix".to_string()));
719        assert_eq!(run.step_entries[1].status, Some("passed".to_string()));
720    }
721
722    #[tokio::test]
723    async fn launch_without_run_ctx_appends_no_step_entries() {
724        // `run_ctx: None` (the `automate()` default) must not touch any
725        // `RunStore` — this is the pre-existing no-tracing behavior, kept
726        // as a regression guard alongside the `Some` case above.
727        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
728            Ok(WorkerResult {
729                value: json!(inv.prompt),
730                ok: true,
731            })
732        });
733        let svc = build_service(factory);
734        let blueprint = bp(
735            step("echo", path("$.input"), path("$.out")),
736            vec![agent("echo", "echo")],
737        );
738        let input = launch_input(blueprint, json!({ "input": "hi" }));
739        assert!(
740            input.run_ctx.is_none(),
741            "automate() defaults run_ctx to None"
742        );
743        let out = svc.launch(input).await.expect("launch ok");
744        assert_eq!(out.final_ctx["out"], "hi");
745    }
746}