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::{AuditDef, Blueprint, EngineDispatcher};
24use crate::core::agent_context::ContextPolicy;
25use crate::core::ctx::OperatorKind;
26use crate::core::engine::Engine;
27use crate::core::errors::EngineError;
28use crate::middleware::agent_context::AgentContextMiddleware;
29use crate::middleware::project_name_alias::ProjectNameAliasMiddleware;
30use crate::middleware::task_input::TaskInputMiddleware;
31use crate::middleware::worker_binding::WorkerBindingMiddleware;
32use crate::middleware::{AfterRunAuditMiddleware, SpawnerStack};
33use crate::operator::WorkerBinding;
34use crate::service::linker;
35use crate::store::run::RunContext;
36use crate::types::{CapToken, Role};
37use mlua_flow_ir::{Externs, NoExterns};
38use serde::{Deserialize, Serialize};
39use serde_json::Value;
40use std::collections::HashMap;
41use std::sync::Arc;
42use std::time::Duration;
43use thiserror::Error;
44
45/// Derive the "BP Agent-level" tier of the `OperatorKind` cascade from a
46/// Blueprint: for every `AgentDef` whose `spec.operator_ref` resolves to an
47/// `OperatorDef` with a `Some` `kind`, map `AgentDef.name -> OperatorKind`.
48///
49/// Deliberately **not** filtered by `AgentDef.kind == AgentKind::Operator`:
50/// the `OperatorKind` cascade is a middleware-level cross-cutting concern
51/// (spawn_hook / senior_bridge / operator-delegate gating via `Ctx.operator`),
52/// orthogonal to the Worker IMPL axis that `AgentKind` expresses (see the
53/// crate root doc, "Operator is delivered as a cross-cutting overlay through
54/// `Ctx` plus middleware"). A `RustFn` / `Lua` / `Subprocess` agent can
55/// equally declare `spec.operator_ref` to opt into a BP-declared
56/// `OperatorKind` without changing its Worker IMPL. Agents without an
57/// `operator_ref`, an unresolved `operator_ref`, or an `OperatorDef.kind =
58/// None` are simply absent from the map (= that tier falls through for
59/// them). This is a separate, independent consumer of `Blueprint.operators`
60/// from the design-time `operator_ref` validation in
61/// `blueprint::compiler::Compiler::compile` (issue: `OperatorDef`
62/// first-class treatment), which only checks the reference resolves for
63/// `AgentKind::Operator` agents and is unaffected by this function.
64/// Build the `agent name → WorkerBinding` map from
65/// `Blueprint.agents[].profile.worker_binding` — the launch-time sibling of
66/// the compile-time resolution in `OperatorSpawnerFactory::build`. Consumed
67/// by `WorkerBindingMiddleware` so the delegate axis
68/// (`OperatorDelegateMiddleware`) can resolve the binding via `ctx.agent`
69/// like every other agent-keyed table (`CompiledAgentTable.routes` idiom).
70/// Agents without a declared binding are simply absent (no silent default).
71fn derive_worker_bindings(blueprint: &Blueprint) -> HashMap<String, WorkerBinding> {
72    blueprint
73        .agents
74        .iter()
75        .filter_map(|ad| {
76            let profile = ad.profile.as_ref()?;
77            let variant = profile.worker_binding.as_ref()?;
78            Some((
79                ad.name.clone(),
80                WorkerBinding {
81                    variant: variant.clone(),
82                    tools: profile.tools.clone(),
83                },
84            ))
85        })
86        .collect()
87}
88
89/// GH #34 — extract the Blueprint-declared after-run audit hooks
90/// (`Blueprint.audits`), the launch-time input to `AfterRunAuditMiddleware`.
91/// Trivial extraction (unlike [`derive_worker_bindings`] / the agent-context
92/// derivers below, no per-agent lookup is needed — `AuditDef.agent` is a
93/// plain agent-name string already validated against `Blueprint.agents` at
94/// `Compiler::compile` time). `[]` (every pre-#34 Blueprint) means "no
95/// audit layer at all" — see the conditional `.layer(...)` wiring in
96/// [`TaskLaunchService::launch`] (invariant #4: byte-identical behavior).
97fn derive_audits(blueprint: &Blueprint) -> Vec<AuditDef> {
98    blueprint.audits.clone()
99}
100
101/// Issue #21 Phase 1: build the agent-context supply axis's "BP Global" +
102/// "BP Agent-level" context tiers from a Blueprint — the launch-time
103/// sibling of [`derive_worker_bindings`] (same "no silent default"
104/// discipline: an agent's entry is present only when it declares one).
105/// Consumed by `AgentContextMiddleware`, which shallow-merges the two
106/// tiers per spawn (agent wins) and inserts the result into
107/// `ctx.meta.runtime` only-if-absent (see
108/// `crate::middleware::agent_context`'s module doc for the full merge +
109/// precedence narrative).
110///
111/// - `.0` (global) = [`Blueprint::default_agent_ctx`], unchanged.
112/// - `.1` (per-agent) = `AgentDef.name -> AgentMeta.ctx`, entry present
113///   only for agents whose `meta` is `Some` and who declare a `ctx`
114///   (directly via `meta.ctx`, and/or indirectly via
115///   [`AgentMeta::meta_ref`] — GH #21 Phase 2, see below).
116///
117/// # GH #21 Phase 2: `AgentMeta.meta_ref` resolution
118///
119/// When an agent declares `meta.meta_ref`, it is resolved against
120/// [`derive_step_metas`]'s pool and used as the BASE layer UNDER the
121/// agent's own inline `meta.ctx` (inline wins on key collision, shallow
122/// merge — see [`shallow_merge_inline_wins`]). An unresolved `meta_ref`
123/// at this point means the caller launched a Blueprint that bypassed
124/// `Compiler::compile`'s validation (the loud gate for this case, see
125/// `blueprint::compiler::Compiler::compile`'s `UnresolvedMetaRef` check);
126/// this function stays defensive and never panics — it logs a warning and
127/// skips the base layer, letting the agent's own inline `ctx` (if any)
128/// stand alone.
129fn derive_agent_ctx(blueprint: &Blueprint) -> (Option<Value>, HashMap<String, Value>) {
130    let global = blueprint.default_agent_ctx.clone();
131    let meta_pool = derive_step_metas(blueprint);
132    let per_agent = blueprint
133        .agents
134        .iter()
135        .filter_map(|ad| {
136            let meta = ad.meta.as_ref()?;
137            let inline = meta.ctx.clone();
138            let base = meta.meta_ref.as_ref().and_then(|name| {
139                let resolved = meta_pool.get(name).cloned();
140                if resolved.is_none() {
141                    tracing::warn!(
142                        agent = %ad.name,
143                        meta_ref = %name,
144                        "derive_agent_ctx: AgentMeta.meta_ref names an undefined Blueprint.metas entry; skipping the base layer"
145                    );
146                }
147                resolved
148            });
149            let merged = match (base, inline) {
150                (None, None) => None,
151                (Some(base), None) => Some(base),
152                (None, Some(inline)) => Some(inline),
153                (Some(base), Some(inline)) => Some(shallow_merge_inline_wins(base, inline)),
154            };
155            merged.map(|ctx| (ad.name.clone(), ctx))
156        })
157        .collect();
158    (global, per_agent)
159}
160
161/// GH #21 Phase 2: shallow-merge `base` with `inline`, `inline` winning
162/// key collisions. Both sides being JSON `Object`s is the meaningful case
163/// (per-key merge); a non-`Object` `inline` is used as-is (it "wins"
164/// entirely — the malformed-shape case is left to
165/// `AgentContextMiddleware`'s own tier merge, which already warns + skips
166/// a non-`Object` tier value downstream, never failing the spawn).
167fn shallow_merge_inline_wins(base: Value, inline: Value) -> Value {
168    match (base, inline) {
169        (Value::Object(mut base), Value::Object(inline)) => {
170            for (k, v) in inline {
171                base.insert(k, v);
172            }
173            Value::Object(base)
174        }
175        (_, inline) => inline,
176    }
177}
178
179/// GH #21 Phase 2: build the `Blueprint.metas` named pool (`MetaDef.name
180/// -> MetaDef.ctx`) — the launch-time sibling of [`derive_worker_bindings`]
181/// / [`derive_agent_ctx`], resolving the Step tier's shared pool instead
182/// of a per-agent map. Consumed by `EngineDispatcher::with_step_metas`
183/// (the Step tier's `$step_meta.ref` resolver) and, indirectly, by
184/// [`derive_agent_ctx`]'s `AgentMeta.meta_ref` resolution (the Agent
185/// tier shares the same pool).
186fn derive_step_metas(blueprint: &Blueprint) -> HashMap<String, Value> {
187    blueprint
188        .metas
189        .iter()
190        .map(|m| (m.name.clone(), m.ctx.clone()))
191        .collect()
192}
193
194/// Issue #21 Phase 1: build the [`ContextPolicy`] cascade's "BP Global" +
195/// "BP Agent-level" tiers from a Blueprint — same shape and discipline as
196/// [`derive_agent_ctx`], from `Blueprint.default_context_policy` /
197/// `AgentMeta.context_policy` instead. Consumed by
198/// `AgentContextMiddleware`, which resolves the effective policy per spawn
199/// (per-agent tier outranks the BP-global one; pass-all when neither is
200/// declared for the dispatching agent).
201fn derive_context_policies(
202    blueprint: &Blueprint,
203) -> (Option<ContextPolicy>, HashMap<String, ContextPolicy>) {
204    let default_policy = blueprint.default_context_policy.clone();
205    let per_agent = blueprint
206        .agents
207        .iter()
208        .filter_map(|ad| {
209            let meta = ad.meta.as_ref()?;
210            let policy = meta.context_policy.clone()?;
211            Some((ad.name.clone(), policy))
212        })
213        .collect();
214    (default_policy, per_agent)
215}
216
217/// Issue #19 ST3: shallow-merge the "BP Global" default `init_ctx`
218/// (`Blueprint.default_init_ctx`) with the Task-level `init_ctx` — the
219/// second layer of the (eventual 4-layer) init-ctx cascade, following the
220/// same "BP default, Task overrides" shape as the `OperatorKind` cascade
221/// (see `derive_bp_agent_kinds` / `TaskLaunchInput::operator_kind`).
222///
223/// Semantics (deliberately a single rule, no deep merge / JSON Patch):
224///
225/// - `bp_default = None` → `task_init_ctx` passes through unchanged
226///   (pre-#19 Blueprints keep today's exact behavior).
227/// - Both sides are `Value::Object` → shallow key-wise merge, Task wins
228///   on collision (`task_init_ctx`'s keys are applied last).
229/// - `task_init_ctx` is present but not an `Object` (`Null` / `String` /
230///   `Array` / `Number` / `Bool`) → Task fully replaces the BP default;
231///   the caller's non-Object seed is respected as-is.
232fn merge_init_ctx(bp_default: Option<&Value>, task_init_ctx: &Value) -> Value {
233    match (bp_default, task_init_ctx) {
234        (Some(Value::Object(bp_map)), Value::Object(task_map)) => {
235            let mut merged = bp_map.clone();
236            for (k, v) in task_map {
237                merged.insert(k.clone(), v.clone());
238            }
239            Value::Object(merged)
240        }
241        (None, _) => task_init_ctx.clone(),
242        (_, task) => task.clone(),
243    }
244}
245
246/// Issue #19 ST4: 3-layer shallow-merge of the init-ctx cascade — BP
247/// default → Task → Run (lowest to highest priority). Built by chaining
248/// [`merge_init_ctx`] twice rather than introducing a distinct 3-way merge
249/// algorithm, so the Run layer inherits exactly the same "shallow Object
250/// merge, non-Object fully replaces" rule [`merge_init_ctx`] already
251/// established for the BP/Task pair (see its doc for the full semantics).
252///
253/// - `run_override: None` is a no-op — the BP+Task merge passes through
254///   unchanged, so `POST /v1/tasks/:id/runs` with no body (or a body that
255///   omits `init_ctx_override`) preserves today's rekick behavior
256///   byte-for-byte.
257/// - `run_override: Some(_)` layers on top exactly like `task_init_ctx`
258///   layers on top of `bp_default` above: both `Object` → shallow
259///   key-wise merge with Run winning collisions; Run non-`Object` →
260///   fully replaces the BP+Task merge.
261pub fn merge_init_ctx_3layer(
262    bp_default: Option<&Value>,
263    task_init_ctx: &Value,
264    run_override: Option<&Value>,
265) -> Value {
266    let bp_task = merge_init_ctx(bp_default, task_init_ctx);
267    match run_override {
268        Some(run) => merge_init_ctx(Some(&bp_task), run),
269        None => bp_task,
270    }
271}
272
273fn derive_bp_agent_kinds(blueprint: &Blueprint) -> HashMap<String, OperatorKind> {
274    let mut out = HashMap::new();
275    if blueprint.operators.is_empty() {
276        return out;
277    }
278    for agent in &blueprint.agents {
279        let Some(op_ref) = agent.spec.get("operator_ref").and_then(|v| v.as_str()) else {
280            continue;
281        };
282        let Some(op_def) = blueprint.operators.iter().find(|o| o.name == op_ref) else {
283            continue;
284        };
285        if let Some(kind) = op_def.kind {
286            out.insert(agent.name.clone(), OperatorKind::from(kind));
287        }
288    }
289    out
290}
291
292/// Failure modes of [`TaskLaunchService::launch`].
293#[derive(Debug, Error)]
294pub enum TaskLaunchError {
295    /// `Compiler::compile` rejected the Blueprint.
296    #[error("compile: {0}")]
297    Compile(#[from] CompileError),
298    /// `Engine::attach_with_ids` failed.
299    #[error("engine: {0}")]
300    Engine(#[from] EngineError),
301    /// A `Step` inside `flow.ir`'s `eval_async` produced a dispatcher
302    /// error, or a sub-flow raised.
303    #[error("flow eval: {0}")]
304    FlowEval(String),
305}
306
307/// Canonical bag of Task-level fields (`project_root` / `work_dir` /
308/// `task_metadata`) — [`TaskLaunchInput::task_input`]'s type.
309///
310/// Issue #19 ST2: replaces the ST1 `resolve_task_level_init_ctx`
311/// fold-back-into-`init_ctx` bridge (removed from
312/// `mlua-swarm-server`'s `run_flow_form`). Callers resolve these three
313/// fields once at the wire boundary — sibling body field first, falling
314/// back to the legacy shape (same three keys nested directly inside
315/// `init_ctx`) only there — and hand the result straight through here;
316/// `init_ctx` itself is no longer mutated to carry them, so it stays a
317/// pure flow-ir eval seed identical to whatever the caller sent.
318///
319/// Each field is independently optional — see
320/// [`crate::middleware::task_input::TaskInputMiddleware::new_from_fields`],
321/// which this is built for.
322///
323/// Issue #19 ST4: also `Serialize`/`Deserialize` so it can travel over the
324/// wire as `RunKickRequest.task_input_override` (`mlua-swarm-server`'s
325/// `tasks` module) and be snapshotted into `TaskRecord.task_input_spec`
326/// (JSON) for rekick to resolve back out of. Every field is
327/// `#[serde(default)]` so a caller may omit any subset (or send `{}`) and
328/// still deserialize.
329#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
330pub struct TaskInputSpec {
331    /// Task-level project root path.
332    #[serde(default)]
333    pub project_root: Option<String>,
334    /// Task-level working directory path.
335    #[serde(default)]
336    pub work_dir: Option<String>,
337    /// Task-level arbitrary metadata bag (a JSON object, or `None`).
338    #[serde(default)]
339    #[schemars(with = "Option<Value>")]
340    pub task_metadata: Option<Value>,
341}
342
343/// Input to [`TaskLaunchService::launch`].
344#[derive(Debug, Clone)]
345pub struct TaskLaunchInput {
346    /// The Blueprint to compile, link, and run.
347    pub blueprint: Blueprint,
348    /// Caller-supplied id for the Operator that owns this run.
349    pub operator_id: String,
350    /// The Operator's role for this run.
351    pub role: Role,
352    /// How long the attached session is allowed to live.
353    pub ttl: Duration,
354    /// "Runtime Global" tier of the `OperatorKind` cascade. `Some(_)` is
355    /// always an explicit request — including `Some(OperatorKind::Automate)`
356    /// — that outranks the BP-level tiers (`OperatorDef.kind` /
357    /// `Blueprint.default_operator_kind`); `None` leaves it unspecified so
358    /// those tiers / the final default decide. Under `MainAi` or
359    /// `Composite`, `MainAIMiddleware`'s `spawn_hook` before/after
360    /// callbacks become effective. See
361    /// `crate::core::ctx::collapse_operator_kind`.
362    pub operator_kind: Option<OperatorKind>,
363    /// `SeniorBridge` registry ID. `None` — no bridge; `Some(id)` —
364    /// attach a bridge previously registered via
365    /// `engine.register_senior_bridge`.
366    pub bridge_id: Option<String>,
367    /// `SpawnHook` registry ID. Same shape as above, via
368    /// `engine.register_spawn_hook`.
369    pub hook_id: Option<String>,
370    /// Operator registry ID — used on the path that hands the whole
371    /// spawn off to an external Operator. Name previously registered
372    /// with `engine.register_operator`; resolved by
373    /// `OperatorDelegateMiddleware`, which — for `kind = MainAi` or
374    /// `Composite` — bypasses `inner.spawn` and calls
375    /// `operator.execute`.
376    pub operator_backend_id: Option<String>,
377    /// "Runtime Agent-level" tier (highest priority) of the `OperatorKind`
378    /// cascade — per-agent override, keyed by `AgentDef.name`. Empty by
379    /// default (no override for any agent). See
380    /// `crate::core::ctx::collapse_operator_kind` for the full tier list.
381    pub operator_kind_overrides: HashMap<String, OperatorKind>,
382    /// The initial `ctx` (JSON `Value`) that flow.ir's `eval_async`
383    /// starts from. Every `Step.in` `$.<path>` reference reads from
384    /// here. Issue #19 ST2: a pure flow-ir eval seed — no Task-level
385    /// field is folded into it anymore; see [`Self::task_input`].
386    pub init_ctx: Value,
387    /// Task-level canonical fields (issue #19 ST2). `Some` layers a
388    /// [`crate::middleware::task_input::TaskInputMiddleware`] (built via
389    /// [`crate::middleware::task_input::TaskInputMiddleware::new_from_fields`])
390    /// onto the spawner stack just before spawn; `None` is a no-op,
391    /// identical to today's behavior for callers with no Task-level
392    /// fields to propagate.
393    pub task_input: Option<TaskInputSpec>,
394    /// Issue #13 run_id propagation: when `Some`, every step this launch
395    /// dispatches is traced into `RunRecord.step_entries` and exposes its
396    /// `run_id` via `Ctx.meta.runtime["run_id"]` (see
397    /// `EngineDispatcher::with_run`). `None` (the default via
398    /// [`Self::automate`]) preserves the pre-existing behavior — no run
399    /// tracing.
400    pub run_ctx: Option<RunContext>,
401}
402
403impl TaskLaunchInput {
404    /// Helper for existing callers on the default path — no hooks and no
405    /// per-agent `OperatorKind` overrides. Leaves the "Runtime Global" tier
406    /// unspecified (`None`), so the BP-level tiers / final default
407    /// (`OperatorKind::Automate`) decide — this preserves today's
408    /// behaviour for every existing caller without silently forcing
409    /// `Automate` as an explicit override that would outrank a BP-declared
410    /// `MainAi`/`Composite` kind. `run_ctx` and `task_input` both default
411    /// to `None` (no run tracing, no Task-level fields); construct the
412    /// struct literal directly to set either.
413    pub fn automate(
414        blueprint: Blueprint,
415        operator_id: impl Into<String>,
416        role: Role,
417        ttl: Duration,
418        init_ctx: Value,
419    ) -> Self {
420        Self {
421            blueprint,
422            operator_id: operator_id.into(),
423            role,
424            ttl,
425            operator_kind: None,
426            bridge_id: None,
427            hook_id: None,
428            operator_backend_id: None,
429            operator_kind_overrides: HashMap::new(),
430            init_ctx,
431            task_input: None,
432            run_ctx: None,
433        }
434    }
435}
436
437/// Result of a successful [`TaskLaunchService::launch`] call.
438#[derive(Debug, Clone)]
439pub struct TaskLaunchOutput {
440    /// The capability token for the attached session.
441    pub token: CapToken,
442    /// The final `ctx` after the flow ran — every `Step.out` has
443    /// been written. Application-layer callers pull the outcome out
444    /// of this `Value` and fold it into a domain status.
445    pub final_ctx: Value,
446}
447
448/// Domain service that compiles, links, and runs a Blueprint's flow to
449/// completion through the [`Engine`]. See the module doc for the full
450/// responsibility list.
451pub struct TaskLaunchService {
452    engine: Engine,
453    compiler: Compiler,
454    /// `call_extern` registry threaded into flow eval. Defaults to
455    /// [`NoExterns`] (= every `call_extern` in a Blueprint raises
456    /// `ExternError`); hosts opt in via [`Self::with_externs`] with an
457    /// `ExternMap` of pure value-shape functions.
458    externs: Arc<dyn Externs + Send + Sync>,
459}
460
461impl TaskLaunchService {
462    /// Build a service bound to one `Engine` and one `Compiler`.
463    pub fn new(engine: Engine, compiler: Compiler) -> Self {
464        Self {
465            engine,
466            compiler,
467            externs: Arc::new(NoExterns),
468        }
469    }
470
471    /// Replace the `call_extern` registry (builder style). Entries MUST be
472    /// pure functions — no side effects, no flow control; effectful work
473    /// belongs to `Step` / agents, not externs (flow-ir canonical contract).
474    pub fn with_externs(mut self, externs: Arc<dyn Externs + Send + Sync>) -> Self {
475        self.externs = externs;
476        self
477    }
478
479    /// The bound `Engine`.
480    pub fn engine(&self) -> &Engine {
481        &self.engine
482    }
483
484    /// The bound `Compiler`.
485    pub fn compiler(&self) -> &Compiler {
486        &self.compiler
487    }
488
489    /// Run the Blueprint's flow to completion and return the final
490    /// `ctx`.
491    ///
492    /// Failure paths:
493    ///
494    /// - `compiler.compile` failure → `TaskLaunchError::Compile`.
495    /// - `engine.attach` failure → `TaskLaunchError::Engine`.
496    /// - A `Step` inside `flow eval` producing a dispatcher error, or
497    ///   a sub-flow raising, → `TaskLaunchError::FlowEval`. There is
498    ///   no silent partial-success completion; failures always
499    ///   propagate.
500    pub async fn launch(
501        &self,
502        input: TaskLaunchInput,
503    ) -> Result<TaskLaunchOutput, TaskLaunchError> {
504        // After the stateless-executor refactor, the
505        // caller (Service) does compile + link +
506        // `EngineDispatcher::with_spawner` itself; the engine no longer
507        // holds any global spawner state to touch. The link path (base
508        // `SpawnerAdapter` +
509        // `LayerRegistry` resolution + `SpawnerStack` wrapping) is
510        // concentrated inside `service::linker::link` — Service
511        // scatter is intentionally prevented.
512        let compiled = self.compiler.compile(&input.blueprint)?;
513        let spawner = linker::link(
514            compiled.router.clone(),
515            &input.blueprint.spawner_hints.layers,
516            &self.engine,
517        );
518        // GH #20 Contract C: materialize an `AgentContextView` exactly
519        // once per spawn, innermost relative to every other layer below
520        // (alias / worker-binding / task-input all insert `ctx.meta.runtime`
521        // keys this layer must observe, so it is added FIRST — later
522        // `.layer()` calls become outer, see `middleware::SpawnerStack`).
523        // Unconditional (always layered): every Blueprint gets this layer
524        // even when it declares no agent-context supply tiers at all
525        // (`derive_agent_ctx` / `derive_context_policies` both return
526        // empty state then, matching the pre-#21 `AgentContextMiddleware`
527        // `Default` behavior byte-for-byte). GH #21 Phase 1: the
528        // receptacle named in the #20 comment above is now wired —
529        // `Blueprint.default_agent_ctx` / `default_context_policy` and
530        // `AgentMeta.ctx` / `context_policy` feed this layer's merge +
531        // policy resolution (see `middleware::agent_context`'s module doc
532        // for the full narrative).
533        let (agent_ctx_global, agent_ctx_per_agent) = derive_agent_ctx(&input.blueprint);
534        let (context_policy_default, context_policy_per_agent) =
535            derive_context_policies(&input.blueprint);
536        let spawner = SpawnerStack::new(spawner)
537            .layer(AgentContextMiddleware::new(
538                agent_ctx_global,
539                agent_ctx_per_agent,
540                context_policy_default,
541                context_policy_per_agent,
542            ))
543            .build();
544        // When `Blueprint.metadata.project_name_alias` is Some, layer a
545        // `ProjectNameAliasMiddleware` on top of the stack that injects the
546        // alias into `Ctx.meta.runtime.project_name_alias` just before spawn.
547        // Downstream operators (for example, the server crate's
548        // `Operator.execute`) read `ctx.meta.runtime.get("project_name_alias")`
549        // and expand it into the Spawn directive prompt body.
550        let spawner = if let Some(alias) = input.blueprint.metadata.project_name_alias.as_deref() {
551            SpawnerStack::new(spawner)
552                .layer(ProjectNameAliasMiddleware::new(alias))
553                .build()
554        } else {
555            spawner
556        };
557        // Layer the Blueprint-baked worker bindings (same ctx.meta.runtime
558        // inject shape as the alias layer above) so the delegate axis can
559        // resolve per-agent variants — see `derive_worker_bindings`.
560        let worker_bindings = derive_worker_bindings(&input.blueprint);
561        let spawner = if worker_bindings.is_empty() {
562            spawner
563        } else {
564            SpawnerStack::new(spawner)
565                .layer(WorkerBindingMiddleware::new(worker_bindings))
566                .build()
567        };
568        // GH #34: Blueprint-declared after-run audit hooks — same
569        // conditional-layering shape as the alias / worker-binding blocks
570        // above. Empty `Blueprint.audits` (every pre-#34 Blueprint) means
571        // no layer at all (invariant #4: byte-identical behavior). The
572        // router handle handed to `AfterRunAuditMiddleware` is
573        // `compiled.router` — the raw name→adapter table `Compiler::compile`
574        // built (NOT this progressively-wrapped `spawner`) — so an audit
575        // agent's own dispatch never re-enters this same layer (see
576        // `AfterRunAuditMiddleware`'s module doc, Recursion guard section).
577        let audit_defs = derive_audits(&input.blueprint);
578        let spawner = if audit_defs.is_empty() {
579            spawner
580        } else {
581            SpawnerStack::new(spawner)
582                .layer(AfterRunAuditMiddleware::new(
583                    audit_defs,
584                    compiled.router.clone(),
585                ))
586                .build()
587        };
588
589        // Task-level execution context (`project_root` / `work_dir` /
590        // `task_metadata`) — same conditional-layering shape as the alias /
591        // worker-binding blocks above. Issue #19 ST2: read directly off
592        // `input.task_input` (already resolved by the caller) instead of
593        // extracting it back out of `input.init_ctx` — `init_ctx` is a pure
594        // flow-ir eval seed now, never folded with these keys.
595        let spawner = match input.task_input.as_ref().and_then(|spec| {
596            TaskInputMiddleware::new_from_fields(
597                spec.project_root.clone(),
598                spec.work_dir.clone(),
599                spec.task_metadata.clone(),
600            )
601        }) {
602            Some(task_input) => SpawnerStack::new(spawner).layer(task_input).build(),
603            None => spawner,
604        };
605
606        // "BP Agent-level" (`OperatorDef.kind` via `operator_ref`) + "BP
607        // Global" (`Blueprint.default_operator_kind`) tiers of the
608        // `OperatorKind` cascade, baked here (the only point that has both
609        // the resolved Blueprint and the launch-time overrides in scope).
610        let bp_agent_kinds = derive_bp_agent_kinds(&input.blueprint);
611        let bp_global_kind = input
612            .blueprint
613            .default_operator_kind
614            .map(OperatorKind::from);
615
616        let token = self
617            .engine
618            .attach_with_ids(
619                input.operator_id,
620                input.role,
621                input.ttl,
622                input.operator_kind,
623                input.bridge_id,
624                input.hook_id,
625                input.operator_backend_id,
626                input.operator_kind_overrides,
627                bp_agent_kinds,
628                bp_global_kind,
629            )
630            .await?;
631        let dispatcher =
632            EngineDispatcher::with_spawner(self.engine.clone(), token.clone(), spawner);
633        let dispatcher = match input.run_ctx {
634            Some(run_ctx) => dispatcher.with_run(run_ctx),
635            None => dispatcher,
636        };
637        // GH #21 Phase 2: attach the Step tier's named `MetaDef` pool.
638        // Unconditional — an empty map (every pre-#21-Phase-2 Blueprint)
639        // is a no-op, matching `EngineDispatcher::with_spawner`'s default.
640        let dispatcher = dispatcher.with_step_metas(derive_step_metas(&input.blueprint));
641        // GH #23: attach the `StepNaming` table `Compiler::compile` already
642        // built once for this Blueprint (the sole construction site — see
643        // `core::step_naming::StepNaming::from_blueprint`'s doc).
644        // Unconditional — every compile produces one, undeclared Blueprints
645        // included (canonical falls back to `Step.ref` byte-for-byte).
646        let dispatcher = dispatcher.with_step_naming(compiled.step_naming.clone());
647        // GH #27 (follow-up to #23): attach the `ProjectionPlacement`
648        // resolver `Compiler::compile` already built once for this
649        // Blueprint (the sole construction site — see
650        // `core::projection_placement::ProjectionPlacement::from_spec`'s
651        // doc). Unconditional — every compile produces one, undeclared
652        // Blueprints included (resolves to `ProjectionPlacement::default()`).
653        let dispatcher =
654            dispatcher.with_projection_placement(compiled.projection_placement.clone());
655        // Issue #19 ST3: BP default + Task init_ctx → merged init_ctx (the
656        // 2-layer slice of the eventual 4-layer cascade; Run override is
657        // ST4 carry). `input.blueprint.default_init_ctx` is `None` for
658        // every pre-#19 Blueprint, so `merge_init_ctx` is a no-op then and
659        // this preserves today's behavior byte-for-byte.
660        let merged_init_ctx =
661            merge_init_ctx(input.blueprint.default_init_ctx.as_ref(), &input.init_ctx);
662        let final_ctx = mlua_flow_ir::eval_async_externs(
663            &input.blueprint.flow,
664            merged_init_ctx,
665            &dispatcher,
666            &*self.externs,
667        )
668        .await
669        .map_err(|e| TaskLaunchError::FlowEval(e.to_string()))?;
670        Ok(TaskLaunchOutput { token, final_ctx })
671    }
672}
673
674// ──────────────────────────────────────────────────────────────────────────
675// UT
676// ──────────────────────────────────────────────────────────────────────────
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681    use crate::blueprint::compiler::{RustFnInProcessSpawnerFactory, SpawnerRegistry};
682    use crate::blueprint::{
683        current_schema_version, AgentDef, AgentKind, AgentMeta, BlueprintMetadata, CompilerHints,
684        CompilerStrategy, MetaDef,
685    };
686    use crate::core::config::EngineCfg;
687    use crate::worker::adapter::{WorkerError, WorkerResult};
688    use mlua_flow_ir::{Expr, JoinMode, Node as FlowNode};
689    use serde_json::json;
690    use std::sync::Arc;
691
692    fn path(s: &str) -> Expr {
693        Expr::Path { at: s.to_string() }
694    }
695    fn step(ref_: &str, in_: Expr, out: Expr) -> FlowNode {
696        FlowNode::Step {
697            ref_: ref_.to_string(),
698            in_,
699            out,
700        }
701    }
702
703    fn agent(name: &str, fn_id: &str) -> AgentDef {
704        AgentDef {
705            name: name.to_string(),
706            kind: AgentKind::RustFn,
707            spec: json!({ "fn_id": fn_id }),
708            profile: None,
709            meta: Some(AgentMeta::default()),
710        }
711    }
712
713    fn build_service(factory: RustFnInProcessSpawnerFactory) -> TaskLaunchService {
714        let engine = Engine::new(EngineCfg::default());
715        let mut reg = SpawnerRegistry::new();
716        reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
717        let compiler = Compiler::new(reg);
718        TaskLaunchService::new(engine, compiler)
719    }
720
721    fn bp(flow: FlowNode, agents: Vec<AgentDef>) -> Blueprint {
722        Blueprint {
723            schema_version: current_schema_version(),
724            id: "ut".into(),
725            flow,
726            agents,
727            operators: vec![],
728            metas: vec![],
729            hints: CompilerHints::default(),
730            strategy: CompilerStrategy::default(),
731            metadata: BlueprintMetadata::default(),
732            spawner_hints: Default::default(),
733            default_agent_kind: AgentKind::Operator,
734            default_operator_kind: None,
735            default_init_ctx: None,
736            default_agent_ctx: None,
737            default_context_policy: None,
738            projection_placement: None,
739            audits: vec![],
740            degradation_policy: None,
741        }
742    }
743
744    fn launch_input(blueprint: Blueprint, init_ctx: Value) -> TaskLaunchInput {
745        TaskLaunchInput::automate(
746            blueprint,
747            "ut-op",
748            Role::Operator,
749            Duration::from_secs(30),
750            init_ctx,
751        )
752    }
753
754    // ──────────────────────────────────────────────────────────────
755    // GH #34: `derive_audits` + the conditional `AfterRunAuditMiddleware`
756    // `.layer(...)` wiring in `TaskLaunchService::launch`
757    // ──────────────────────────────────────────────────────────────
758
759    #[test]
760    fn derive_audits_empty_by_default() {
761        let blueprint = bp(
762            step("echo", path("$.input"), path("$.out")),
763            vec![agent("echo", "echo")],
764        );
765        assert!(
766            derive_audits(&blueprint).is_empty(),
767            "audits_absent_no_layer: an undeclared audits Vec must stay empty"
768        );
769    }
770
771    #[test]
772    fn derive_audits_returns_blueprint_audits_verbatim() {
773        let mut blueprint = bp(
774            step("echo", path("$.input"), path("$.out")),
775            vec![agent("echo", "echo")],
776        );
777        blueprint.audits = vec![crate::blueprint::AuditDef {
778            agent: "auditor".to_string(),
779            steps: None,
780            mode: crate::blueprint::AuditMode::Async,
781        }];
782        let got = derive_audits(&blueprint);
783        assert_eq!(got.len(), 1);
784        assert_eq!(got[0].agent, "auditor");
785    }
786
787    #[tokio::test]
788    async fn launch_appends_audit_artifact_when_audits_declared() {
789        use crate::blueprint::{AuditDef, AuditMode};
790
791        let factory = RustFnInProcessSpawnerFactory::new()
792            .register_fn("echo", |inv| async move {
793                Ok(WorkerResult {
794                    value: json!({ "echoed": inv.prompt }),
795                    ok: true,
796                })
797            })
798            .register_fn("audit-fn", |_inv| async move {
799                Ok(WorkerResult {
800                    value: json!({ "finding": "clean" }),
801                    ok: true,
802                })
803            });
804        let svc = build_service(factory);
805        let mut blueprint = bp(
806            step("echo", path("$.input"), path("$.out")),
807            vec![agent("echo", "echo"), agent("auditor", "audit-fn")],
808        );
809        blueprint.audits = vec![AuditDef {
810            agent: "auditor".to_string(),
811            steps: None,
812            mode: AuditMode::Sync,
813        }];
814        let out = svc
815            .launch(launch_input(blueprint, json!({ "input": "hi" })))
816            .await
817            .expect("launch ok — audits must never alter the audited step's outcome");
818        assert_eq!(out.final_ctx["out"]["echoed"], "hi");
819
820        let audited_task_id = svc
821            .engine()
822            .with_state("test.find_audited_task", |s| {
823                s.tasks
824                    .iter()
825                    .find(|(_, t)| t.spec.agent == "echo")
826                    .map(|(id, _)| id.clone())
827            })
828            .await
829            .expect("with_state")
830            .expect("the echo task must exist");
831        let tail = svc.engine().output_tail(&audited_task_id, 1).await;
832        let found = tail.iter().any(|ev| {
833            matches!(
834                ev,
835                crate::worker::output::OutputEvent::Artifact { name, .. } if name == "audit:echo"
836            )
837        });
838        assert!(
839            found,
840            "launch() must wire AfterRunAuditMiddleware end-to-end when Blueprint.audits is declared"
841        );
842    }
843
844    #[tokio::test]
845    async fn launch_single_step_writes_out_path() {
846        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
847            Ok(WorkerResult {
848                value: json!({ "echoed": inv.prompt }),
849                ok: true,
850            })
851        });
852        let svc = build_service(factory);
853        let blueprint = bp(
854            step("echo", path("$.input"), path("$.out")),
855            vec![agent("echo", "echo")],
856        );
857        let out = svc
858            .launch(launch_input(blueprint, json!({ "input": "hi" })))
859            .await
860            .expect("launch ok");
861        assert_eq!(out.final_ctx["out"]["echoed"], "hi");
862    }
863
864    #[tokio::test]
865    async fn launch_three_step_seq_threads_ctx_forward() {
866        let factory = RustFnInProcessSpawnerFactory::new()
867            .register_fn("upper", |inv| async move {
868                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
869                Ok(WorkerResult {
870                    value: json!(s.to_uppercase()),
871                    ok: true,
872                })
873            })
874            .register_fn("suffix", |inv| async move {
875                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
876                Ok(WorkerResult {
877                    value: json!(format!("{s}!")),
878                    ok: true,
879                })
880            })
881            .register_fn("wrap", |inv| async move {
882                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
883                Ok(WorkerResult {
884                    value: json!(format!("[{s}]")),
885                    ok: true,
886                })
887            });
888        let svc = build_service(factory);
889        let flow = FlowNode::Seq {
890            children: vec![
891                step("upper", path("$.in"), path("$.s1")),
892                step("suffix", path("$.s1"), path("$.s2")),
893                step("wrap", path("$.s2"), path("$.s3")),
894            ],
895        };
896        let blueprint = bp(
897            flow,
898            vec![
899                agent("upper", "upper"),
900                agent("suffix", "suffix"),
901                agent("wrap", "wrap"),
902            ],
903        );
904        let out = svc
905            .launch(launch_input(blueprint, json!({ "in": "hello" })))
906            .await
907            .expect("launch ok");
908        assert_eq!(out.final_ctx["s1"], "HELLO");
909        assert_eq!(out.final_ctx["s2"], "HELLO!");
910        assert_eq!(out.final_ctx["s3"], "[HELLO!]");
911    }
912
913    #[tokio::test]
914    async fn launch_fanout_join_all_parallel_completes() {
915        use std::sync::atomic::{AtomicU32, Ordering};
916        let counter = Arc::new(AtomicU32::new(0));
917        let max_seen = Arc::new(AtomicU32::new(0));
918        let counter_clone = counter.clone();
919        let max_clone = max_seen.clone();
920
921        // Each worker bumps the inflight counter up, sleeps 50ms, then bumps it down.
922        // When parallel execution is working, max inflight exceeds 1.
923        let factory = RustFnInProcessSpawnerFactory::new().register_fn("para", move |inv| {
924            let counter = counter_clone.clone();
925            let max_seen = max_clone.clone();
926            async move {
927                let now = counter.fetch_add(1, Ordering::SeqCst) + 1;
928                let mut prev = max_seen.load(Ordering::SeqCst);
929                while now > prev {
930                    match max_seen.compare_exchange(prev, now, Ordering::SeqCst, Ordering::SeqCst) {
931                        Ok(_) => break,
932                        Err(p) => prev = p,
933                    }
934                }
935                tokio::time::sleep(Duration::from_millis(50)).await;
936                counter.fetch_sub(1, Ordering::SeqCst);
937                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
938                Ok(WorkerResult {
939                    value: json!(format!("did:{s}")),
940                    ok: true,
941                })
942            }
943        });
944        let svc = build_service(factory);
945        let flow = FlowNode::Fanout {
946            items: path("$.items"),
947            bind: path("$.item"),
948            body: Box::new(step("para", path("$.item"), path("$.r"))),
949            join: JoinMode::All,
950            out: path("$.results"),
951        };
952        let blueprint = bp(flow, vec![agent("para", "para")]);
953        let out = svc
954            .launch(launch_input(
955                blueprint,
956                json!({ "items": ["a", "b", "c", "d"] }),
957            ))
958            .await
959            .expect("launch ok");
960        let results = out.final_ctx["results"].as_array().expect("array");
961        assert_eq!(results.len(), 4);
962        for (i, expected) in ["a", "b", "c", "d"].iter().enumerate() {
963            assert_eq!(results[i]["r"], json!(format!("did:{expected}")));
964        }
965        let max = max_seen.load(Ordering::SeqCst);
966        assert!(
967            max >= 2,
968            "expected parallel execution (max inflight >= 2), got {max}"
969        );
970    }
971
972    #[tokio::test]
973    async fn launch_propagates_worker_error_as_flow_eval_err() {
974        let factory = RustFnInProcessSpawnerFactory::new()
975            .register_fn("ok", |inv| async move {
976                Ok(WorkerResult {
977                    value: json!(inv.prompt),
978                    ok: true,
979                })
980            })
981            .register_fn("boom", |_inv| async move {
982                Err(WorkerError::Failed("intentional boom".into()))
983            });
984        let svc = build_service(factory);
985        let flow = FlowNode::Seq {
986            children: vec![
987                step("ok", path("$.input"), path("$.s1")),
988                step("boom", path("$.s1"), path("$.s2")),
989                step("ok", path("$.s2"), path("$.s3")),
990            ],
991        };
992        let blueprint = bp(flow, vec![agent("ok", "ok"), agent("boom", "boom")]);
993        let err = svc
994            .launch(launch_input(blueprint, json!({ "input": "x" })))
995            .await
996            .expect_err("expected fail");
997        match err {
998            TaskLaunchError::FlowEval(msg) => {
999                assert!(
1000                    msg.contains("boom") || msg.contains("intentional"),
1001                    "expected error to mention worker failure, got: {msg}"
1002                );
1003            }
1004            other => panic!("expected FlowEval error, got {other:?}"),
1005        }
1006    }
1007
1008    #[tokio::test]
1009    async fn launch_resolves_call_extern_via_registered_externs() {
1010        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1011            Ok(WorkerResult {
1012                value: json!({ "echoed": inv.prompt }),
1013                ok: true,
1014            })
1015        });
1016        let mut externs = mlua_flow_ir::ExternMap::new();
1017        externs.register("fmt.greet", |args: &[Value]| {
1018            let name = args[0].as_str().unwrap_or("?");
1019            Ok(json!(format!("hello, {name}")))
1020        });
1021        let svc = build_service(factory).with_externs(Arc::new(externs));
1022        let flow = step(
1023            "echo",
1024            Expr::CallExtern {
1025                ref_: "fmt.greet".into(),
1026                args: vec![path("$.who")],
1027            },
1028            path("$.out"),
1029        );
1030        let blueprint = bp(flow, vec![agent("echo", "echo")]);
1031        let out = svc
1032            .launch(launch_input(blueprint, json!({ "who": "swarm" })))
1033            .await
1034            .expect("launch ok");
1035        assert_eq!(out.final_ctx["out"]["echoed"], json!("hello, swarm"));
1036    }
1037
1038    #[tokio::test]
1039    async fn launch_call_extern_without_registry_fails_as_flow_eval() {
1040        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1041            Ok(WorkerResult {
1042                value: json!(inv.prompt),
1043                ok: true,
1044            })
1045        });
1046        let svc = build_service(factory); // default NoExterns
1047        let flow = step(
1048            "echo",
1049            Expr::CallExtern {
1050                ref_: "fmt.greet".into(),
1051                args: vec![],
1052            },
1053            path("$.out"),
1054        );
1055        let blueprint = bp(flow, vec![agent("echo", "echo")]);
1056        let err = svc
1057            .launch(launch_input(blueprint, json!({})))
1058            .await
1059            .expect_err("expected fail");
1060        match err {
1061            TaskLaunchError::FlowEval(msg) => {
1062                assert!(msg.contains("extern"), "expected extern error, got: {msg}");
1063            }
1064            other => panic!("expected FlowEval error, got {other:?}"),
1065        }
1066    }
1067
1068    // ──────────────────────────────────────────────────────────────────
1069    // issue #13 run_id propagation (`TaskLaunchInput.run_ctx`)
1070    // ──────────────────────────────────────────────────────────────────
1071
1072    #[tokio::test]
1073    async fn launch_with_run_ctx_appends_one_step_entry_per_dispatched_step() {
1074        use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
1075        use crate::types::{RunId, TaskId};
1076
1077        let factory = RustFnInProcessSpawnerFactory::new()
1078            .register_fn("upper", |inv| async move {
1079                Ok(WorkerResult {
1080                    value: json!(inv.prompt.to_uppercase()),
1081                    ok: true,
1082                })
1083            })
1084            .register_fn("suffix", |inv| async move {
1085                let s = serde_json::from_str::<String>(&inv.prompt).unwrap_or(inv.prompt);
1086                Ok(WorkerResult {
1087                    value: json!(format!("{s}!")),
1088                    ok: true,
1089                })
1090            });
1091        let svc = build_service(factory);
1092        let flow = FlowNode::Seq {
1093            children: vec![
1094                step("upper", path("$.in"), path("$.s1")),
1095                step("suffix", path("$.s1"), path("$.s2")),
1096            ],
1097        };
1098        let blueprint = bp(
1099            flow,
1100            vec![agent("upper", "upper"), agent("suffix", "suffix")],
1101        );
1102
1103        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1104        let run_id = RunId::new();
1105        run_store
1106            .create(RunRecord {
1107                id: run_id.clone(),
1108                task_id: TaskId::new(),
1109                status: RunStatus::Running,
1110                step_entries: Vec::new(),
1111                degradations: Vec::new(),
1112                operator_sid: None,
1113                result_ref: None,
1114                created_at: 0,
1115                updated_at: 0,
1116            })
1117            .await
1118            .expect("seed RunRecord");
1119
1120        let mut input = launch_input(blueprint, json!({ "in": "hi" }));
1121        input.run_ctx = Some(RunContext {
1122            run_id: run_id.clone(),
1123            run_store: run_store.clone(),
1124        });
1125
1126        let out = svc.launch(input).await.expect("launch ok");
1127        assert_eq!(out.final_ctx["s2"], "HI!");
1128
1129        let run = run_store.get(&run_id).await.expect("run present");
1130        assert_eq!(
1131            run.step_entries.len(),
1132            2,
1133            "expected one step_entry per dispatched step, got {:?}",
1134            run.step_entries
1135        );
1136        assert_eq!(run.step_entries[0].step_ref, Some("upper".to_string()));
1137        assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
1138        assert_eq!(run.step_entries[1].step_ref, Some("suffix".to_string()));
1139        assert_eq!(run.step_entries[1].status, Some("passed".to_string()));
1140    }
1141
1142    #[tokio::test]
1143    async fn launch_without_run_ctx_appends_no_step_entries() {
1144        // `run_ctx: None` (the `automate()` default) must not touch any
1145        // `RunStore` — this is the pre-existing no-tracing behavior, kept
1146        // as a regression guard alongside the `Some` case above.
1147        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1148            Ok(WorkerResult {
1149                value: json!(inv.prompt),
1150                ok: true,
1151            })
1152        });
1153        let svc = build_service(factory);
1154        let blueprint = bp(
1155            step("echo", path("$.input"), path("$.out")),
1156            vec![agent("echo", "echo")],
1157        );
1158        let input = launch_input(blueprint, json!({ "input": "hi" }));
1159        assert!(
1160            input.run_ctx.is_none(),
1161            "automate() defaults run_ctx to None"
1162        );
1163        let out = svc.launch(input).await.expect("launch ok");
1164        assert_eq!(out.final_ctx["out"], "hi");
1165    }
1166
1167    // ──────────────────────────────────────────────────────────────────
1168    // issue #19 ST2: `TaskLaunchInput.task_input` (direct-sibling-read
1169    // replacement for the ST1 `from_init_ctx(&input.init_ctx)` call)
1170    // ──────────────────────────────────────────────────────────────────
1171
1172    #[tokio::test]
1173    async fn launch_with_task_input_leaves_init_ctx_object_seed_unmutated() {
1174        // Issue #19 ST2 invariant: `init_ctx` is a pure flow-ir eval seed —
1175        // `task_input` must not be folded into it. Regression guard for the
1176        // ST1 `resolve_task_level_init_ctx` fold-back this subtask removes:
1177        // if it ever crept back in here, `project_root` / `work_dir` /
1178        // `task_metadata` would leak into `final_ctx` as extra top-level
1179        // keys nobody wrote via a `Step.out`.
1180        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1181            Ok(WorkerResult {
1182                value: json!({ "echoed": inv.prompt }),
1183                ok: true,
1184            })
1185        });
1186        let svc = build_service(factory);
1187        let blueprint = bp(
1188            step("echo", path("$.input"), path("$.out")),
1189            vec![agent("echo", "echo")],
1190        );
1191        let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1192        input.task_input = Some(TaskInputSpec {
1193            project_root: Some("/repo".to_string()),
1194            work_dir: Some("/repo/work".to_string()),
1195            task_metadata: Some(json!({ "issue": 19 })),
1196        });
1197        let out = svc.launch(input).await.expect("launch ok");
1198        assert_eq!(out.final_ctx["out"]["echoed"], "hi");
1199        assert!(
1200            out.final_ctx.get("project_root").is_none(),
1201            "task_input must not be folded into the flow-ir ctx seed, got {:?}",
1202            out.final_ctx
1203        );
1204        assert!(out.final_ctx.get("work_dir").is_none());
1205        assert!(out.final_ctx.get("task_metadata").is_none());
1206    }
1207
1208    #[tokio::test]
1209    async fn launch_with_task_input_none_is_a_no_op() {
1210        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1211            Ok(WorkerResult {
1212                value: json!(inv.prompt),
1213                ok: true,
1214            })
1215        });
1216        let svc = build_service(factory);
1217        let blueprint = bp(
1218            step("echo", path("$.input"), path("$.out")),
1219            vec![agent("echo", "echo")],
1220        );
1221        let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1222        assert!(input.task_input.is_none(), "automate() defaults to None");
1223        input.task_input = None;
1224        let out = svc.launch(input).await.expect("launch ok");
1225        assert_eq!(out.final_ctx["out"], "hi");
1226    }
1227
1228    #[tokio::test]
1229    async fn launch_with_task_input_all_fields_absent_is_a_no_op() {
1230        // `Some(TaskInputSpec::default())` — outer Some, all 3 inner fields
1231        // None — must behave identically to `task_input: None` (mirrors
1232        // `TaskInputMiddleware::new_from_fields`'s own no-op contract).
1233        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1234            Ok(WorkerResult {
1235                value: json!(inv.prompt),
1236                ok: true,
1237            })
1238        });
1239        let svc = build_service(factory);
1240        let blueprint = bp(
1241            step("echo", path("$.input"), path("$.out")),
1242            vec![agent("echo", "echo")],
1243        );
1244        let mut input = launch_input(blueprint, json!({ "input": "hi" }));
1245        input.task_input = Some(TaskInputSpec::default());
1246        let out = svc.launch(input).await.expect("launch ok");
1247        assert_eq!(out.final_ctx["out"], "hi");
1248    }
1249
1250    // ──────────────────────────────────────────────────────────────────
1251    // issue #19 ST3: `merge_init_ctx` (BP default + Task init_ctx)
1252    // ──────────────────────────────────────────────────────────────────
1253
1254    #[test]
1255    fn merge_init_ctx_bp_default_only_passes_through_when_task_is_empty_object() {
1256        let bp_default = json!({ "seeded": "from-bp" });
1257        let task = json!({});
1258        let merged = merge_init_ctx(Some(&bp_default), &task);
1259        assert_eq!(merged, json!({ "seeded": "from-bp" }));
1260    }
1261
1262    #[test]
1263    fn merge_init_ctx_task_only_passes_through_when_bp_default_is_empty_object() {
1264        let bp_default = json!({});
1265        let task = json!({ "seeded": "from-task" });
1266        let merged = merge_init_ctx(Some(&bp_default), &task);
1267        assert_eq!(merged, json!({ "seeded": "from-task" }));
1268    }
1269
1270    #[test]
1271    fn merge_init_ctx_both_objects_task_wins_on_key_collision() {
1272        let bp_default = json!({ "a": "bp", "b": "bp-only" });
1273        let task = json!({ "a": "task", "c": "task-only" });
1274        let merged = merge_init_ctx(Some(&bp_default), &task);
1275        assert_eq!(
1276            merged,
1277            json!({ "a": "task", "b": "bp-only", "c": "task-only" })
1278        );
1279    }
1280
1281    #[test]
1282    fn merge_init_ctx_non_object_task_fully_replaces_bp_default() {
1283        let bp_default = json!({ "seeded": "from-bp" });
1284        let task = json!("plain-string-seed");
1285        let merged = merge_init_ctx(Some(&bp_default), &task);
1286        assert_eq!(merged, json!("plain-string-seed"));
1287    }
1288
1289    #[test]
1290    fn merge_init_ctx_no_bp_default_is_a_no_op() {
1291        let task = json!({ "input": "hi" });
1292        let merged = merge_init_ctx(None, &task);
1293        assert_eq!(merged, task);
1294    }
1295
1296    // ──────────────────────────────────────────────────────────────────
1297    // issue #19 ST4: `merge_init_ctx_3layer` (BP default + Task + Run)
1298    // ──────────────────────────────────────────────────────────────────
1299
1300    #[test]
1301    fn merge_init_ctx_3layer_no_run_override_equals_bp_task_merge_only() {
1302        // `run_override: None` must be a pure pass-through of the BP+Task
1303        // merge — this is the `POST /v1/tasks/:id/runs` no-body rekick
1304        // path, which must preserve pre-#19 behavior byte-for-byte.
1305        let bp_default = json!({ "a": "bp", "b": "bp-only" });
1306        let task = json!({ "a": "task", "c": "task-only" });
1307        let three_layer = merge_init_ctx_3layer(Some(&bp_default), &task, None);
1308        let two_layer = merge_init_ctx(Some(&bp_default), &task);
1309        assert_eq!(three_layer, two_layer);
1310        assert_eq!(
1311            three_layer,
1312            json!({ "a": "task", "b": "bp-only", "c": "task-only" })
1313        );
1314    }
1315
1316    #[test]
1317    fn merge_init_ctx_3layer_run_object_wins_on_key_collision_over_bp_and_task() {
1318        let bp_default = json!({ "a": "bp", "b": "bp-only" });
1319        let task = json!({ "a": "task", "c": "task-only" });
1320        let run_override = json!({ "a": "run", "d": "run-only" });
1321        let merged = merge_init_ctx_3layer(Some(&bp_default), &task, Some(&run_override));
1322        assert_eq!(
1323            merged,
1324            json!({ "a": "run", "b": "bp-only", "c": "task-only", "d": "run-only" }),
1325            "Run wins on collision (a); BP-only (b) and Task-only (c) keys survive"
1326        );
1327    }
1328
1329    #[test]
1330    fn merge_init_ctx_3layer_run_non_object_fully_replaces_bp_task_merge() {
1331        let bp_default = json!({ "seeded": "from-bp" });
1332        let task = json!({ "seeded": "from-task" });
1333        let run_override = json!("plain-string-run-seed");
1334        let merged = merge_init_ctx_3layer(Some(&bp_default), &task, Some(&run_override));
1335        assert_eq!(merged, json!("plain-string-run-seed"));
1336    }
1337
1338    #[test]
1339    fn merge_init_ctx_3layer_no_bp_default_and_no_run_override_is_task_passthrough() {
1340        let task = json!({ "input": "hi" });
1341        let merged = merge_init_ctx_3layer(None, &task, None);
1342        assert_eq!(merged, task);
1343    }
1344
1345    #[tokio::test]
1346    async fn launch_merges_bp_default_init_ctx_into_task_init_ctx() {
1347        // End-to-end guard: `Blueprint.default_init_ctx` actually reaches
1348        // `eval_async_externs` — not merely unit-tested in isolation.
1349        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |inv| async move {
1350            Ok(WorkerResult {
1351                value: json!(inv.prompt),
1352                ok: true,
1353            })
1354        });
1355        let svc = build_service(factory);
1356        let mut blueprint = bp(
1357            step("echo", path("$.greeting"), path("$.out")),
1358            vec![agent("echo", "echo")],
1359        );
1360        blueprint.default_init_ctx = Some(json!({ "greeting": "hello from bp" }));
1361        // Task supplies an empty object — BP default alone seeds `$.greeting`.
1362        let out = svc
1363            .launch(launch_input(blueprint, json!({})))
1364            .await
1365            .expect("launch ok");
1366        assert_eq!(out.final_ctx["out"], "hello from bp");
1367    }
1368
1369    // ──────────────────────────────────────────────────────────────────
1370    // issue #21 Phase 1: `derive_agent_ctx` / `derive_context_policies`
1371    // ──────────────────────────────────────────────────────────────────
1372
1373    fn agent_with_meta(name: &str, fn_id: &str, meta: AgentMeta) -> AgentDef {
1374        AgentDef {
1375            name: name.to_string(),
1376            kind: AgentKind::RustFn,
1377            spec: json!({ "fn_id": fn_id }),
1378            profile: None,
1379            meta: Some(meta),
1380        }
1381    }
1382
1383    #[test]
1384    fn derive_agent_ctx_empty_blueprint_yields_empty_state() {
1385        let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
1386        let (global, per_agent) = derive_agent_ctx(&blueprint);
1387        assert_eq!(global, None);
1388        assert!(per_agent.is_empty());
1389    }
1390
1391    #[test]
1392    fn derive_agent_ctx_populated_blueprint_yields_correct_maps() {
1393        let mut blueprint = bp(
1394            step("echo", path("$.in"), path("$.out")),
1395            vec![
1396                agent_with_meta(
1397                    "with-ctx",
1398                    "echo",
1399                    AgentMeta {
1400                        ctx: Some(json!({ "org_conventions": "x" })),
1401                        ..Default::default()
1402                    },
1403                ),
1404                agent("no-ctx", "echo"),
1405            ],
1406        );
1407        blueprint.default_agent_ctx = Some(json!({ "seeded": "from-bp" }));
1408        let (global, per_agent) = derive_agent_ctx(&blueprint);
1409        assert_eq!(global, Some(json!({ "seeded": "from-bp" })));
1410        assert_eq!(
1411            per_agent.len(),
1412            1,
1413            "agents without AgentMeta.ctx are absent, not defaulted to null: {per_agent:?}"
1414        );
1415        assert_eq!(
1416            per_agent.get("with-ctx"),
1417            Some(&json!({ "org_conventions": "x" }))
1418        );
1419        assert!(!per_agent.contains_key("no-ctx"));
1420    }
1421
1422    #[test]
1423    fn derive_context_policies_empty_blueprint_yields_empty_state() {
1424        let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
1425        let (default_policy, per_agent) = derive_context_policies(&blueprint);
1426        assert_eq!(default_policy, None);
1427        assert!(per_agent.is_empty());
1428    }
1429
1430    #[test]
1431    fn derive_context_policies_populated_blueprint_yields_correct_maps() {
1432        let mut blueprint = bp(
1433            step("echo", path("$.in"), path("$.out")),
1434            vec![
1435                agent_with_meta(
1436                    "with-policy",
1437                    "echo",
1438                    AgentMeta {
1439                        context_policy: Some(ContextPolicy {
1440                            include: None,
1441                            exclude: vec!["work_dir".to_string()],
1442                            ..Default::default()
1443                        }),
1444                        ..Default::default()
1445                    },
1446                ),
1447                agent("no-policy", "echo"),
1448            ],
1449        );
1450        blueprint.default_context_policy = Some(ContextPolicy {
1451            include: Some(vec!["project_root".to_string()]),
1452            exclude: vec![],
1453            ..Default::default()
1454        });
1455        let (default_policy, per_agent) = derive_context_policies(&blueprint);
1456        assert_eq!(
1457            default_policy,
1458            Some(ContextPolicy {
1459                include: Some(vec!["project_root".to_string()]),
1460                exclude: vec![],
1461                ..Default::default()
1462            })
1463        );
1464        assert_eq!(per_agent.len(), 1);
1465        assert_eq!(
1466            per_agent.get("with-policy"),
1467            Some(&ContextPolicy {
1468                include: None,
1469                exclude: vec!["work_dir".to_string()],
1470                ..Default::default()
1471            })
1472        );
1473        assert!(!per_agent.contains_key("no-policy"));
1474    }
1475
1476    // ──────────────────────────────────────────────────────────────────
1477    // issue #21 Phase 2: `derive_step_metas` / `AgentMeta.meta_ref`
1478    // resolution inside `derive_agent_ctx`
1479    // ──────────────────────────────────────────────────────────────────
1480
1481    #[test]
1482    fn derive_step_metas_empty_blueprint_yields_empty_map() {
1483        let blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
1484        assert!(derive_step_metas(&blueprint).is_empty());
1485    }
1486
1487    #[test]
1488    fn derive_step_metas_populated_blueprint_yields_name_to_ctx_map() {
1489        let mut blueprint = bp(step("echo", path("$.in"), path("$.out")), vec![]);
1490        blueprint.metas = vec![
1491            MetaDef {
1492                name: "heavy-scan".to_string(),
1493                ctx: json!({ "work_dir": "/x" }),
1494            },
1495            MetaDef {
1496                name: "light-scan".to_string(),
1497                ctx: json!({ "work_dir": "/y" }),
1498            },
1499        ];
1500        let metas = derive_step_metas(&blueprint);
1501        assert_eq!(metas.len(), 2);
1502        assert_eq!(metas.get("heavy-scan"), Some(&json!({ "work_dir": "/x" })));
1503        assert_eq!(metas.get("light-scan"), Some(&json!({ "work_dir": "/y" })));
1504    }
1505
1506    #[test]
1507    fn derive_agent_ctx_meta_ref_resolves_as_base_under_inline_ctx() {
1508        let mut blueprint = bp(
1509            step("echo", path("$.in"), path("$.out")),
1510            vec![agent_with_meta(
1511                "with-meta-ref",
1512                "echo",
1513                AgentMeta {
1514                    ctx: Some(json!({ "work_dir": "/inline-wins" })),
1515                    meta_ref: Some("shared".to_string()),
1516                    ..Default::default()
1517                },
1518            )],
1519        );
1520        blueprint.metas = vec![MetaDef {
1521            name: "shared".to_string(),
1522            ctx: json!({ "work_dir": "/base", "extra": "from-pool" }),
1523        }];
1524        let (_, per_agent) = derive_agent_ctx(&blueprint);
1525        assert_eq!(
1526            per_agent.get("with-meta-ref"),
1527            Some(&json!({ "work_dir": "/inline-wins", "extra": "from-pool" })),
1528            "inline ctx must win the collided key while pool-only keys survive the merge"
1529        );
1530    }
1531
1532    #[test]
1533    fn derive_agent_ctx_meta_ref_alone_uses_pool_ctx_verbatim() {
1534        let mut blueprint = bp(
1535            step("echo", path("$.in"), path("$.out")),
1536            vec![agent_with_meta(
1537                "with-meta-ref-only",
1538                "echo",
1539                AgentMeta {
1540                    meta_ref: Some("shared".to_string()),
1541                    ..Default::default()
1542                },
1543            )],
1544        );
1545        blueprint.metas = vec![MetaDef {
1546            name: "shared".to_string(),
1547            ctx: json!({ "work_dir": "/base" }),
1548        }];
1549        let (_, per_agent) = derive_agent_ctx(&blueprint);
1550        assert_eq!(
1551            per_agent.get("with-meta-ref-only"),
1552            Some(&json!({ "work_dir": "/base" }))
1553        );
1554    }
1555
1556    #[test]
1557    fn derive_agent_ctx_unresolved_meta_ref_never_panics_and_falls_back_to_inline() {
1558        let blueprint = bp(
1559            step("echo", path("$.in"), path("$.out")),
1560            vec![agent_with_meta(
1561                "with-unresolved-meta-ref",
1562                "echo",
1563                AgentMeta {
1564                    ctx: Some(json!({ "work_dir": "/inline-only" })),
1565                    meta_ref: Some("missing".to_string()),
1566                    ..Default::default()
1567                },
1568            )],
1569        );
1570        // No `blueprint.metas` entries at all — `meta_ref` unresolved.
1571        let (_, per_agent) = derive_agent_ctx(&blueprint);
1572        assert_eq!(
1573            per_agent.get("with-unresolved-meta-ref"),
1574            Some(&json!({ "work_dir": "/inline-only" })),
1575            "an unresolved meta_ref must never panic; the agent's own inline ctx still applies"
1576        );
1577    }
1578}