Skip to main content

greentic_runner_host/runner/
engine.rs

1use std::collections::HashMap;
2use std::env;
3use std::error::Error as StdError;
4use std::str::FromStr;
5use std::sync::Arc;
6use std::time::Duration;
7
8use crate::component_api::node::{ExecCtx as ComponentExecCtx, TenantCtx as ComponentTenantCtx};
9use anyhow::{Context, Result, anyhow, bail};
10use indexmap::IndexMap;
11use parking_lot::RwLock;
12use serde::{Deserialize, Serialize};
13use serde_json::{Map as JsonMap, Value, json};
14use tokio::task;
15
16use super::mocks::MockLayer;
17use super::templating::{TemplateOptions, render_template_value};
18use crate::config::{FlowRetryConfig, HostConfig};
19use crate::pack::{FlowDescriptor, PackRuntime};
20use crate::runner::invocation::{InvocationMeta, build_invocation_envelope};
21use crate::telemetry::{
22    FlowSpanAttributes, RolloutIds, annotate_span, backoff_delay_ms, set_flow_context,
23};
24#[cfg(feature = "fault-injection")]
25use crate::testing::fault_injection::{FaultContext, FaultPoint, maybe_fail};
26use crate::validate::{
27    ValidationConfig, ValidationIssue, ValidationMode, validate_component_envelope,
28    validate_tool_envelope,
29};
30use greentic_flow::SLOT_SCHEMA_METADATA_KEY;
31use greentic_types::{Flow, Node, NodeId, Routing};
32
33/// Component ID of the slot-extractor WASM component. Used to detect
34/// slot-extractor nodes and inject flow-level `slot_schema` as
35/// `slot_definitions` into the invocation payload (Phase D).
36const SLOT_EXTRACTOR_COMPONENT_ID: &str = "ai.greentic.component-slot-extractor";
37
38/// Callback trait for resolving cross-pack provider invocations.
39///
40/// When a `provider.invoke` node references a provider that is not in the
41/// current pack, the flow engine calls this resolver as a fallback.
42/// Implementations typically delegate to a capability registry that knows
43/// about all packs in the bundle.
44pub trait CrossPackResolver: Send + Sync {
45    fn invoke(
46        &self,
47        provider_id: &str,
48        provider_type: Option<&str>,
49        op: &str,
50        input: &[u8],
51        tenant: &str,
52        team: Option<&str>,
53    ) -> Result<Value>;
54}
55
56pub struct FlowEngine {
57    packs: Vec<Arc<PackRuntime>>,
58    flows: Vec<FlowDescriptor>,
59    flow_sources: HashMap<FlowKey, usize>,
60    /// Pack ids whose manifest declares a `messaging.*` provider. Such a pack's
61    /// flows are that provider's own ingress plumbing, not the application
62    /// entrypoint, so they are excluded from type-only entry-flow resolution.
63    /// Without this, a multi-provider bundle (app pack + `messaging-*` provider
64    /// packs) registers several entry `messaging` flows and
65    /// `entry_flow_by_type("messaging")` bails "ambiguous; pack_id is required".
66    messaging_provider_pack_ids: std::collections::HashSet<String>,
67    flow_cache: RwLock<HashMap<FlowKey, HostFlow>>,
68    default_env: String,
69    validation: ValidationConfig,
70    cross_pack_resolver: Option<Arc<dyn CrossPackResolver>>,
71    /// Rollout identifiers of the revision-keyed runtime this engine belongs to,
72    /// stamped onto every per-invocation `TenantCtx` for telemetry attribution
73    /// (C5.4). Empty for tenant-only (legacy) runtimes; the Phase-D revision
74    /// dispatcher supplies real IDs via [`with_rollout_ids`](Self::with_rollout_ids).
75    rollout_ids: RolloutIds,
76    /// Bridges `sorla.call` flow nodes into a separate runtime over pub/sub.
77    /// Not feature-gated: `sorla.call` is a core runtime-dispatch node.
78    remote_dispatch_handler: Option<Arc<dyn crate::runner::remote_dispatch::RemoteDispatchHandler>>,
79    /// Controls whether `dw.agent` nodes run in-process (default) or are
80    /// rerouted over the durable agentic NATS path (`GREENTIC_AW_DISPATCH=nats`).
81    #[cfg(feature = "agentic-worker")]
82    dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch,
83    #[cfg(feature = "agentic-worker")]
84    agent_node_handler: Option<Arc<dyn crate::runner::agent_node::AgentNodeHandler>>,
85    #[cfg(feature = "agentic-worker")]
86    graph_node_handler: Option<Arc<dyn crate::runner::graph_node::GraphNodeHandler>>,
87    /// Per-tenant MCP tool source for `component == "mcp"` flow nodes
88    /// (role `flow_editor`). Built once from env so the TTL catalog cache is
89    /// shared across nodes/flows. `None` when MCP is unconfigured/opted-out,
90    /// in which case MCP nodes fail gracefully with a clear node error.
91    #[cfg(feature = "agentic-worker")]
92    mcp_tool_source: Option<Arc<greentic_aw_runtime::McpToolSource>>,
93}
94
95#[derive(Clone, Debug, PartialEq, Eq, Hash)]
96struct FlowKey {
97    pack_id: String,
98    flow_id: String,
99}
100
101#[derive(Clone, Debug, Serialize, Deserialize)]
102pub struct FlowSnapshot {
103    pub pack_id: String,
104    pub flow_id: String,
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub next_flow: Option<String>,
107    pub next_node: String,
108    /// True only when this snapshot was taken because conditional routing fell
109    /// through and the node parked at ITSELF awaiting the next submit — the
110    /// card case. False for `session.wait`, whose `next_node` is the SUCCESSOR,
111    /// not the node that was waiting; attaching a person's answers there would
112    /// name a node that had not run yet. Defaulted so snapshots written before
113    /// this field behave exactly as they did.
114    #[serde(default)]
115    pub awaiting_submit: bool,
116    pub state: ExecutionState,
117}
118
119#[derive(Clone, Debug)]
120pub struct FlowWait {
121    pub reason: Option<String>,
122    pub snapshot: FlowSnapshot,
123}
124
125#[derive(Clone, Debug)]
126pub enum FlowStatus {
127    Completed,
128    Waiting(Box<FlowWait>),
129}
130
131#[derive(Clone, Debug)]
132pub struct FlowExecution {
133    pub output: Value,
134    pub status: FlowStatus,
135}
136
137#[derive(Clone, Debug)]
138struct HostFlow {
139    id: String,
140    start: Option<NodeId>,
141    nodes: IndexMap<NodeId, HostNode>,
142    /// Flow-level slot definitions extracted from `metadata.extra["greentic.slot_schema"]`.
143    /// Injected into slot-extractor component invocations at dispatch time (Phase D).
144    slot_schema: Option<Value>,
145    vars_init: JsonMap<String, Value>,
146}
147
148#[derive(Clone, Debug)]
149pub struct HostNode {
150    kind: NodeKind,
151    /// Backwards-compatible component label for observers/transcript.
152    pub component: String,
153    component_id: String,
154    operation_name: Option<String>,
155    operation_in_mapping: Option<String>,
156    payload_expr: Value,
157    routing: Routing,
158    /// Per-node implicit output bindings: after this node runs, each entry
159    /// `{ varName: template }` is rendered against a context where `prev` is
160    /// the node's own output payload, and the result is written to
161    /// `ExecutionState.vars[varName]`.
162    vars_out: Option<JsonMap<String, Value>>,
163}
164
165impl HostNode {
166    pub fn component_id(&self) -> &str {
167        &self.component_id
168    }
169
170    pub fn operation_name(&self) -> Option<&str> {
171        self.operation_name.as_deref()
172    }
173
174    pub fn operation_in_mapping(&self) -> Option<&str> {
175        self.operation_in_mapping.as_deref()
176    }
177}
178
179#[cfg(test)]
180impl HostNode {
181    /// Test-only constructor. `HostNode`'s fields (and `NodeKind`/`Routing`
182    /// literals) are private to this module, so sibling-module unit tests
183    /// (e.g. `trace::recorder`) that need to build a `NodeEvent` for the
184    /// `ExecutionObserver` trait cannot construct one via struct literal.
185    /// This is additive test scaffolding only — no production behavior change.
186    pub(crate) fn for_test(component_id: &str, operation_name: Option<&str>) -> Self {
187        HostNode {
188            kind: NodeKind::Exec {
189                target_component: component_id.to_string(),
190            },
191            component: component_id.to_string(),
192            component_id: component_id.to_string(),
193            operation_name: operation_name.map(str::to_string),
194            operation_in_mapping: None,
195            payload_expr: Value::Null,
196            routing: Routing::End,
197            vars_out: None,
198        }
199    }
200}
201
202#[derive(Clone, Debug)]
203enum NodeKind {
204    Exec {
205        target_component: String,
206    },
207    PackComponent {
208        component_ref: String,
209    },
210    ProviderInvoke,
211    FlowCall,
212    /// Hand the turn over to another flow and do NOT come back.
213    ///
214    /// [`FlowCall`] is a subroutine: it awaits the callee and turns a
215    /// `FlowStatus::Waiting` into a hard error, so a target that asks the user
216    /// anything can never be called. `FlowGoto` is the transfer instead — it
217    /// resolves to [`NodeControl::Jump`], which switches the walk to the target
218    /// flow rather than nesting an execution, so the target's status BECOMES
219    /// the turn's status. A target that parks parks the turn, and the snapshot
220    /// records `next_flow`, so the next inbound activity resumes inside the
221    /// target rather than back here.
222    ///
223    /// That is what a menu whose options open a conversation needs, and what
224    /// `flow.call` structurally cannot do.
225    FlowGoto,
226    BuiltinEmit {
227        kind: EmitKind,
228    },
229    BuiltinStateGet,
230    BuiltinStateSet,
231    /// Session-scoped variable write: renders `value` against the current
232    /// template context and inserts into `ExecutionState.vars[name]`.
233    /// Config shape: `{ name: String, value: any }`.
234    VarSet {
235        name: String,
236        value: Value,
237    },
238    Wait,
239    DwAgent {
240        agent_id: String,
241    },
242    DwAgentGraph {
243        graph_id: String,
244    },
245    /// Native runtime-dispatch node: publishes work to a separate runtime
246    /// (e.g. sorx) via the injected [`RemoteDispatchHandler`]. `target` is the
247    /// node operation (the logical runtime target).
248    SorlaCall {
249        target: String,
250    },
251    /// Native runtime-dispatch node for the Operala runtime. Mirrors
252    /// [`SorlaCall`] but routes to the `"operala"` runtime name.
253    OperalaCall {
254        target: String,
255    },
256    /// Native runtime-dispatch node for an out-of-process agentic runtime.
257    /// Mirrors [`SorlaCall`] but routes to the `"agentic"` runtime name.
258    /// This is an ADDITIONAL path: the in-process `dw.agent` node is
259    /// completely separate and untouched.
260    AgenticCall {
261        target: String,
262    },
263    /// Native runtime-dispatch node for the Telco-X runtime. Mirrors
264    /// [`SorlaCall`] but routes to the `"telco-x"` runtime name. Wire-ready: the
265    /// runtime side (a telco-x NATS dispatch service) is not built yet, so an
266    /// `await: true` node pauses until that runtime exists.
267    TelcoXCall {
268        target: String,
269    },
270    /// Native runtime-dispatch node for the Human-in-the-Loop approval runtime.
271    /// Mirrors [`SorlaCall`] but routes to the `"approval"` runtime name and
272    /// applies an autonomy gate (auto-approve below the configured risk /
273    /// above the configured confidence) before dispatching.
274    ApprovalCall {
275        target: String,
276    },
277    /// Flow-execution MCP node (LOCKED ENCODING v2): `component == "mcp"` with
278    /// `server`/`tool` carried in the node payload/config. Invokes the named
279    /// MCP tool through the tenant's `flow_editor` MCP catalog (reusing
280    /// `greentic-aw-runtime`'s `McpToolSource`). Completely separate from the
281    /// agent-loop MCP path (role `agentic_worker`).
282    ///
283    /// `server_id`/`tool` here are the values resolved at flow-load time;
284    /// `execute_mcp` re-reads them from the rendered payload (source of truth)
285    /// and only uses these as a fallback for the legacy `operation` encoding.
286    Mcp {
287        server_id: String,
288        tool: String,
289    },
290}
291
292#[derive(Clone, Debug)]
293enum EmitKind {
294    Log,
295    Response,
296    Other(String),
297}
298
299struct ComponentOverrides<'a> {
300    component: Option<&'a str>,
301    operation: Option<&'a str>,
302}
303
304struct ComponentCall {
305    component_ref: String,
306    operation: String,
307    input: Value,
308    config: Value,
309    /// Whether the originating node has an `on_error`-family route, so a
310    /// component failure is surfaced as a node_io `{errors}` output and routed
311    /// to that branch instead of aborting the flow (see `node_has_error_route`).
312    has_error_route: bool,
313}
314
315impl FlowExecution {
316    fn completed(output: Value) -> Self {
317        Self {
318            output,
319            status: FlowStatus::Completed,
320        }
321    }
322
323    fn waiting(output: Value, wait: FlowWait) -> Self {
324        Self {
325            output,
326            status: FlowStatus::Waiting(Box::new(wait)),
327        }
328    }
329}
330
331impl FlowEngine {
332    pub async fn new(packs: Vec<Arc<PackRuntime>>, config: Arc<HostConfig>) -> Result<Self> {
333        let mut flow_sources: HashMap<FlowKey, usize> = HashMap::new();
334        let mut messaging_provider_pack_ids: std::collections::HashSet<String> =
335            std::collections::HashSet::new();
336        let mut descriptors = Vec::new();
337        let mut bindings = HashMap::new();
338        for pack in &config.pack_bindings {
339            bindings.insert(pack.pack_id.clone(), pack.flows.clone());
340        }
341        let enforce_bindings = !bindings.is_empty();
342        for (idx, pack) in packs.iter().enumerate() {
343            let pack_id = pack.metadata().pack_id.clone();
344            if enforce_bindings && !bindings.contains_key(&pack_id) {
345                bail!("no gtbind entries found for pack {}", pack_id);
346            }
347            // Mark packs that declare a `messaging.*` provider so their ingress
348            // flows are excluded from type-only entry-flow routing (see
349            // `messaging_provider_pack_ids`). Derived once here, off the hot path.
350            let declares_messaging_provider = pack
351                .provider_registry_optional()
352                .ok()
353                .flatten()
354                .map(|registry| {
355                    registry
356                        .operator_metadata()
357                        .iter()
358                        .any(|meta| meta.provider_type.starts_with("messaging."))
359                })
360                .unwrap_or(false);
361            if declares_messaging_provider {
362                messaging_provider_pack_ids.insert(pack_id.clone());
363            }
364            let flows = pack.list_flows().await?;
365            let allowed = bindings.get(&pack_id).map(|flows| {
366                flows
367                    .iter()
368                    .cloned()
369                    .collect::<std::collections::HashSet<_>>()
370            });
371            let mut seen = std::collections::HashSet::new();
372            for flow in flows {
373                if let Some(ref allow) = allowed
374                    && !allow.contains(&flow.id)
375                {
376                    continue;
377                }
378                seen.insert(flow.id.clone());
379                tracing::info!(
380                    flow_id = %flow.id,
381                    flow_type = %flow.flow_type,
382                    pack_id = %flow.pack_id,
383                    pack_index = idx,
384                    "registered flow"
385                );
386                if let Ok(flow_ir) = pack.load_flow(&flow.id) {
387                    for node in flow_ir.nodes.values() {
388                        config
389                            .secrets_policy
390                            .register_flow_secret_refs(&node.input.mapping);
391                        config
392                            .secrets_policy
393                            .register_flow_secret_refs(&node.output.mapping);
394                    }
395                }
396                flow_sources.insert(
397                    FlowKey {
398                        pack_id: flow.pack_id.clone(),
399                        flow_id: flow.id.clone(),
400                    },
401                    idx,
402                );
403                descriptors.retain(|existing: &FlowDescriptor| {
404                    !(existing.id == flow.id && existing.pack_id == flow.pack_id)
405                });
406                descriptors.push(flow);
407            }
408            if let Some(allow) = allowed {
409                let missing = allow.difference(&seen).cloned().collect::<Vec<_>>();
410                if !missing.is_empty() {
411                    bail!(
412                        "gtbind flow ids missing in pack {}: {}",
413                        pack_id,
414                        missing.join(", ")
415                    );
416                }
417            }
418        }
419
420        let mut flow_map = HashMap::new();
421        for flow in &descriptors {
422            let pack_id = flow.pack_id.clone();
423            if let Some(&pack_idx) = flow_sources.get(&FlowKey {
424                pack_id: pack_id.clone(),
425                flow_id: flow.id.clone(),
426            }) {
427                let pack_clone = Arc::clone(&packs[pack_idx]);
428                let flow_id = flow.id.clone();
429                let task_flow_id = flow_id.clone();
430                match task::spawn_blocking(move || pack_clone.load_flow(&task_flow_id)).await {
431                    Ok(Ok(loaded_flow)) => {
432                        flow_map.insert(
433                            FlowKey {
434                                pack_id: pack_id.clone(),
435                                flow_id,
436                            },
437                            HostFlow::from(loaded_flow),
438                        );
439                    }
440                    Ok(Err(err)) => {
441                        tracing::warn!(flow_id = %flow.id, error = %err, "failed to load flow metadata");
442                    }
443                    Err(err) => {
444                        tracing::warn!(flow_id = %flow.id, error = %err, "join error loading flow metadata");
445                    }
446                }
447            }
448        }
449
450        Ok(Self {
451            packs,
452            flows: descriptors,
453            flow_sources,
454            messaging_provider_pack_ids,
455            flow_cache: RwLock::new(flow_map),
456            default_env: env::var("GREENTIC_ENV").unwrap_or_else(|_| "local".to_string()),
457            validation: config.validation.clone(),
458            cross_pack_resolver: None,
459            rollout_ids: RolloutIds::default(),
460            remote_dispatch_handler: None,
461            #[cfg(feature = "agentic-worker")]
462            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
463            #[cfg(feature = "agentic-worker")]
464            agent_node_handler: None,
465            #[cfg(feature = "agentic-worker")]
466            graph_node_handler: None,
467            #[cfg(feature = "agentic-worker")]
468            mcp_tool_source: crate::runner::mcp_node::source_from_env(),
469        })
470    }
471
472    /// Bind the rollout identifiers of the revision-keyed runtime this engine
473    /// serves, so every invocation's telemetry carries deployment/bundle/
474    /// revision attribution (C5.4). Called by the Phase-D revision dispatcher
475    /// when it constructs a revision runtime; tenant-only runtimes leave the
476    /// default (empty) IDs.
477    pub fn with_rollout_ids(mut self, rollout_ids: RolloutIds) -> Self {
478        self.rollout_ids = rollout_ids;
479        self
480    }
481
482    /// The rollout identifiers bound to this engine (read counterpart to
483    /// [`with_rollout_ids`](Self::with_rollout_ids)). Empty by default for the
484    /// legacy tenant-only path.
485    pub fn rollout_ids(&self) -> &RolloutIds {
486        &self.rollout_ids
487    }
488
489    /// Set an optional cross-pack resolver for `provider.invoke` nodes that
490    /// reference providers in other packs (resolved via capability registry).
491    pub fn set_cross_pack_resolver(&mut self, resolver: Arc<dyn CrossPackResolver>) {
492        self.cross_pack_resolver = Some(resolver);
493    }
494
495    /// Set the handler that bridges `sorla.call` flow nodes into a separate
496    /// runtime over pub/sub. Constructed by the runner binary when a transport
497    /// (e.g. NATS) is configured.
498    pub fn set_remote_dispatch_handler(
499        &mut self,
500        handler: Arc<dyn crate::runner::remote_dispatch::RemoteDispatchHandler>,
501    ) {
502        self.remote_dispatch_handler = Some(handler);
503    }
504
505    /// Set the handler that bridges `DwAgent` flow nodes into the agentic-worker
506    /// runtime. Constructed by the runner binary (Task 4.3).
507    #[cfg(feature = "agentic-worker")]
508    pub fn set_agent_node_handler(
509        &mut self,
510        handler: Arc<dyn crate::runner::agent_node::AgentNodeHandler>,
511    ) {
512        self.agent_node_handler = Some(handler);
513    }
514
515    /// Set the handler that bridges `DwAgentGraph` flow nodes into the durable
516    /// graph executor. Constructed by the pack loader (Task 8). Mirrors
517    /// [`set_agent_node_handler`].
518    ///
519    /// [`set_agent_node_handler`]: FlowEngine::set_agent_node_handler
520    #[cfg(feature = "agentic-worker")]
521    pub fn set_graph_node_handler(
522        &mut self,
523        handler: Arc<dyn crate::runner::graph_node::GraphNodeHandler>,
524    ) {
525        self.graph_node_handler = Some(handler);
526    }
527
528    /// Set the dispatch mode for `dw.agent` nodes.
529    ///
530    /// - [`DwAgentDispatch::InProcess`] (default): runs the agent in-process via
531    ///   [`AgentNodeHandler`]. Zero configuration overhead; today's behaviour.
532    /// - [`DwAgentDispatch::Nats`]: reroutes the node over the durable agentic
533    ///   NATS path (`greentic.agentic.request.v1`), identical to an `agentic.call`
534    ///   node. Requires [`set_remote_dispatch_handler`] to also be set.
535    ///
536    /// Called by `runtime.rs` when `GREENTIC_AW_DISPATCH=nats`.
537    ///
538    /// [`AgentNodeHandler`]: crate::runner::agent_node::AgentNodeHandler
539    /// [`set_remote_dispatch_handler`]: FlowEngine::set_remote_dispatch_handler
540    #[cfg(feature = "agentic-worker")]
541    pub fn set_dw_agent_dispatch(&mut self, mode: crate::runner::agent_node::DwAgentDispatch) {
542        self.dw_agent_dispatch = mode;
543    }
544
545    async fn get_or_load_flow(&self, pack_id: &str, flow_id: &str) -> Result<HostFlow> {
546        let key = FlowKey {
547            pack_id: pack_id.to_string(),
548            flow_id: flow_id.to_string(),
549        };
550        if let Some(flow) = self.flow_cache.read().get(&key).cloned() {
551            return Ok(flow);
552        }
553
554        let pack_idx = *self
555            .flow_sources
556            .get(&key)
557            .with_context(|| format!("flow {pack_id}:{flow_id} not registered"))?;
558        let pack = Arc::clone(&self.packs[pack_idx]);
559        let flow_id_owned = flow_id.to_string();
560        let task_flow_id = flow_id_owned.clone();
561        let flow = task::spawn_blocking(move || pack.load_flow(&task_flow_id))
562            .await
563            .context("failed to join flow metadata task")??;
564        let host_flow = HostFlow::from(flow);
565        self.flow_cache.write().insert(
566            FlowKey {
567                pack_id: pack_id.to_string(),
568                flow_id: flow_id_owned.clone(),
569            },
570            host_flow.clone(),
571        );
572        Ok(host_flow)
573    }
574
575    /// Create the `flow.execute` span and install per-invocation telemetry:
576    /// declared span fields, the task-local tenant context, and the **exported**
577    /// `gt.*` attribution — the live `pack_id` plus any rollout identifiers from
578    /// the owning revision runtime (C5.4). Returned for the caller to
579    /// `.instrument()`. Both `execute` and `resume` route through here so every
580    /// per-invocation entry point carries the same attribution.
581    fn flow_execute_span(&self, ctx: &FlowContext<'_>) -> tracing::Span {
582        let span = tracing::info_span!(
583            "flow.execute",
584            tenant = tracing::field::Empty,
585            flow_id = tracing::field::Empty,
586            node_id = tracing::field::Empty,
587            tool = tracing::field::Empty,
588            action = tracing::field::Empty
589        );
590        annotate_span(
591            &span,
592            &FlowSpanAttributes {
593                tenant: ctx.tenant,
594                flow_id: ctx.flow_id,
595                node_id: ctx.node_id,
596                tool: ctx.tool,
597                action: ctx.action,
598            },
599        );
600        set_flow_context(
601            &span,
602            &self.default_env,
603            ctx.tenant,
604            ctx.flow_id,
605            ctx.node_id,
606            ctx.provider_id,
607            ctx.session_id,
608            ctx.pack_id,
609            &self.rollout_ids,
610        );
611        span
612    }
613
614    pub async fn execute(&self, ctx: FlowContext<'_>, input: Value) -> Result<FlowExecution> {
615        self.execute_with_entry(ctx, input, None).await
616    }
617
618    /// Execute a flow whose cursor starts at `entry_node` instead of the flow's
619    /// declared entrypoint.
620    ///
621    /// Card-driven messaging packs carry the id of the next node to run on the
622    /// inbound activity (the designer emits it as `nextCardId`). A host that can
623    /// only call [`FlowEngine::execute`] restarts such a flow at its entrypoint
624    /// on every turn, so the capture nodes chained between two cards never run.
625    ///
626    /// Unlike [`FlowEngine::resume`] this needs no persisted `FlowSnapshot`:
627    /// state starts fresh from `input`. That is what makes it usable for packs
628    /// whose pause points are rendered cards rather than `session.wait` nodes —
629    /// those never park, so they never leave a snapshot behind.
630    ///
631    /// An `entry_node` that is not a node of the flow is a hard error. Falling
632    /// back to the entrypoint would re-introduce the silent restart this exists
633    /// to remove.
634    pub async fn execute_from(
635        &self,
636        ctx: FlowContext<'_>,
637        input: Value,
638        entry_node: &str,
639    ) -> Result<FlowExecution> {
640        self.execute_with_entry(ctx, input, Some(entry_node.to_string()))
641            .await
642    }
643
644    async fn execute_with_entry(
645        &self,
646        ctx: FlowContext<'_>,
647        input: Value,
648        entry_node: Option<String>,
649    ) -> Result<FlowExecution> {
650        // Validate the caller's entry node BEFORE the retry loop below, whose
651        // session-flow arm converts a terminal error into an Ok envelope. A
652        // node id the flow does not have is a caller/pack defect, not a
653        // transient failure, and must surface as an error rather than as a
654        // rendered "something went wrong" card.
655        if let Some(node) = entry_node.as_deref() {
656            let flow_ir = self.get_or_load_flow(ctx.pack_id, ctx.flow_id).await?;
657            let node_id = NodeId::from_str(node)
658                .with_context(|| format!("invalid entry node id `{node}`"))?;
659            if !flow_ir.nodes.contains_key(&node_id) {
660                bail!("flow {} has no node `{}`", ctx.flow_id, node);
661            }
662        }
663        let span = self.flow_execute_span(&ctx);
664        let retry_config = ctx.retry_config;
665        let original_input = input;
666        let mut ctx = ctx;
667        let metric_tenant = ctx.tenant.to_string();
668        let metric_flow_id = ctx.flow_id.to_string();
669        let started = std::time::Instant::now();
670        let result = async move {
671            let mut attempt = 0u32;
672            loop {
673                attempt += 1;
674                ctx.attempt = attempt;
675                #[cfg(feature = "fault-injection")]
676                {
677                    let fault_ctx = FaultContext {
678                        pack_id: ctx.pack_id,
679                        flow_id: ctx.flow_id,
680                        node_id: ctx.node_id,
681                        attempt: ctx.attempt,
682                    };
683                    maybe_fail(FaultPoint::Timeout, fault_ctx)
684                        .map_err(|err| anyhow!(err.to_string()))?;
685                }
686                match self
687                    .execute_once(&ctx, original_input.clone(), entry_node.clone())
688                    .await
689                {
690                    Ok(value) => return Ok(value),
691                    Err(err) => {
692                        if attempt >= retry_config.max_attempts || !should_retry(&err) {
693                            // User-facing session flows surface the terminal
694                            // error as a metadata-only Ok envelope so the
695                            // messaging provider renders it instead of leaking
696                            // raw engine text to the chat.
697                            if ctx.session_id.is_some() {
698                                return Ok(FlowExecution::completed(json!({
699                                    "metadata": {
700                                        "error_kind": "flow_execution_failed",
701                                        "error_message": err.to_string(),
702                                        "flow_id": ctx.flow_id,
703                                    }
704                                })));
705                            }
706                            return Err(err);
707                        }
708                        let delay = backoff_delay_ms(retry_config.base_delay_ms, attempt - 1);
709                        tracing::warn!(
710                            tenant = ctx.tenant,
711                            flow_id = ctx.flow_id,
712                            attempt,
713                            max_attempts = retry_config.max_attempts,
714                            delay_ms = delay,
715                            error = %err,
716                            "transient flow execution failure, backing off"
717                        );
718                        tokio::time::sleep(Duration::from_millis(delay)).await;
719                    }
720                }
721            }
722        }
723        .instrument(span)
724        .await;
725        let status = if result.is_ok() { "ok" } else { "err" };
726        let duration_ms = started.elapsed().as_secs_f64() * 1000.0;
727        crate::metrics::record_flow_execution(&metric_tenant, &metric_flow_id, status, duration_ms);
728        result
729    }
730
731    pub async fn resume(
732        &self,
733        ctx: FlowContext<'_>,
734        snapshot: FlowSnapshot,
735        input: Value,
736    ) -> Result<FlowExecution> {
737        if snapshot.pack_id != ctx.pack_id {
738            bail!(
739                "snapshot pack {} does not match requested {}",
740                snapshot.pack_id,
741                ctx.pack_id
742            );
743        }
744        let resume_flow = snapshot
745            .next_flow
746            .clone()
747            .unwrap_or_else(|| snapshot.flow_id.clone());
748        let flow_ir = self.get_or_load_flow(ctx.pack_id, &resume_flow).await?;
749        // Computed before `snapshot.state` is moved out below, since it borrows
750        // `snapshot` (specifically `next_node` and `awaiting_submit`).
751        let pending_card_answers = pending_from_snapshot(&snapshot, &input);
752        let mut state = snapshot.state;
753        // Replace BOTH `input` AND `entry` with the new activity. The
754        // routing context (built by `build_routing_context`) reads
755        // `entry.input.metadata.*` for the synthesised `response.*` fields
756        // that conditional routes test against — keeping the snapshot's
757        // stale entry would make `response.action` perpetually empty and
758        // every condition fail, looping the user back to the wait point
759        // forever. `replace_input` only touches `state.input`, so we have
760        // to refresh `entry` ourselves; `ensure_entry` is a no-op once
761        // entry is non-null.
762        state.replace_input(input.clone());
763        // Park the submitted fields for the node that was awaiting them. Only a
764        // snapshot flagged `awaiting_submit` names the waiting node in
765        // `next_node`; `session.wait` names its SUCCESSOR, which has not run.
766        state.pending_card_answers = pending_card_answers;
767        state.entry = input;
768        let span = self.flow_execute_span(&ctx);
769        self.drive_flow(&ctx, flow_ir, state, Some(snapshot.next_node), resume_flow)
770            .instrument(span)
771            .await
772    }
773
774    async fn execute_once(
775        &self,
776        ctx: &FlowContext<'_>,
777        input: Value,
778        entry_node: Option<String>,
779    ) -> Result<FlowExecution> {
780        let flow_ir = self.get_or_load_flow(ctx.pack_id, ctx.flow_id).await?;
781        let mut state = ExecutionState::new(input);
782        for (name, default) in flow_ir.vars_init.iter() {
783            state
784                .vars
785                .entry(name.clone())
786                .or_insert_with(|| default.clone());
787        }
788        self.drive_flow(ctx, flow_ir, state, entry_node, ctx.flow_id.to_string())
789            .await
790    }
791
792    async fn drive_flow(
793        &self,
794        ctx: &FlowContext<'_>,
795        mut flow_ir: HostFlow,
796        mut state: ExecutionState,
797        resume_from: Option<String>,
798        mut current_flow_id: String,
799    ) -> Result<FlowExecution> {
800        let mut current = match resume_from {
801            Some(node) => NodeId::from_str(&node)
802                .with_context(|| format!("invalid resume node id `{node}`"))?,
803            None => flow_ir
804                .start
805                .clone()
806                .or_else(|| flow_ir.nodes.keys().next().cloned())
807                .with_context(|| format!("flow {} has no start node", flow_ir.id))?,
808        };
809
810        loop {
811            let step_ctx = FlowContext {
812                tenant: ctx.tenant,
813                pack_id: ctx.pack_id,
814                flow_id: current_flow_id.as_str(),
815                node_id: ctx.node_id,
816                tool: ctx.tool,
817                action: ctx.action,
818                session_id: ctx.session_id,
819                provider_id: ctx.provider_id,
820                reply_scope: ctx.reply_scope,
821                retry_config: ctx.retry_config,
822                attempt: ctx.attempt,
823                observer: ctx.observer,
824                mocks: ctx.mocks,
825            };
826            let node = flow_ir
827                .nodes
828                .get(&current)
829                .with_context(|| format!("node {} not found", current.as_str()))?;
830
831            let payload_template = node.payload_expr.clone();
832            let prev = state
833                .last_output
834                .as_ref()
835                .cloned()
836                .unwrap_or_else(|| Value::Object(JsonMap::new()));
837            let ctx_value = template_context(&state, prev);
838            #[cfg(feature = "fault-injection")]
839            {
840                let fault_ctx = FaultContext {
841                    pack_id: ctx.pack_id,
842                    flow_id: ctx.flow_id,
843                    node_id: Some(current.as_str()),
844                    attempt: ctx.attempt,
845                };
846                maybe_fail(FaultPoint::TemplateRender, fault_ctx)
847                    .map_err(|err| anyhow!(err.to_string()))?;
848            }
849            let mut payload =
850                render_template_value(&payload_template, &ctx_value, TemplateOptions::default())
851                    .context("failed to render node input template")?;
852            let node_id = current.clone();
853
854            // Phase D: inject flow-level slot_schema as slot_definitions into
855            // the slot-extractor's input when the author omitted inline
856            // definitions. Explicit inline `slot_definitions` win
857            // (back-compat with M2.4 NDA demo).
858            if let NodeKind::Exec { target_component } = &node.kind
859                && target_component == SLOT_EXTRACTOR_COMPONENT_ID
860                && let Some(schema) = flow_ir.slot_schema.as_ref()
861                && let Some(map) = payload.as_object_mut()
862            {
863                let input = map.entry("input").or_insert(Value::Null);
864                inject_slot_definitions(input, schema, step_ctx.flow_id, node_id.as_str());
865            }
866
867            let observed_payload = payload.clone();
868            let event = NodeEvent {
869                context: &step_ctx,
870                node_id: node_id.as_str(),
871                node,
872                payload: &observed_payload,
873            };
874            if let Some(observer) = step_ctx.observer {
875                observer.on_node_start(&event);
876            }
877            let dispatch = self
878                .dispatch_node(
879                    &step_ctx,
880                    node_id.as_str(),
881                    node,
882                    &mut state,
883                    payload,
884                    &event,
885                )
886                .await;
887            let DispatchOutcome {
888                mut output,
889                control,
890            } = match dispatch {
891                Ok(outcome) => outcome,
892                Err(err) => {
893                    if let Some(observer) = step_ctx.observer {
894                        observer.on_node_error(&event, err.as_ref());
895                    }
896                    // Propagate so `execute()`'s retry loop can retry transient
897                    // failures, then convert to a metadata-only Ok envelope at
898                    // the top level once retries are exhausted (session flows).
899                    return Err(err);
900                }
901            };
902
903            let owned_the_submit =
904                attach_pending_card_answers(&mut state, node_id.as_str(), node, &mut output);
905            state.nodes.insert(node_id.clone().into(), output.clone());
906            state.last_output = Some(output.payload.clone());
907            // Apply per-node vars_out bindings: render each template against a
908            // context where `prev` is this node's own output payload (already
909            // stored in state.last_output above), then write into state.vars.
910            if let Some(bindings) = node.vars_out.as_ref() {
911                let ctx = template_context(&state, output.payload.clone());
912                for (var_name, template) in bindings.iter() {
913                    let rendered = render_template_value(
914                        template,
915                        &ctx,
916                        TemplateOptions {
917                            allow_pointer: true,
918                        },
919                    )
920                    .with_context(|| format!("failed to render vars_out binding `{var_name}`"))?;
921                    state.vars.insert(var_name.clone(), rendered);
922                }
923            }
924            if let Some(observer) = step_ctx.observer {
925                observer.on_node_end(&event, &output.payload);
926            }
927
928            match control {
929                NodeControl::Continue => {
930                    enum NextDecision {
931                        Next(NodeId),
932                        End,
933                        Wait,
934                    }
935                    let decision = match &node.routing {
936                        Routing::Next { node_id } => NextDecision::Next(node_id.clone()),
937                        Routing::End | Routing::Reply => NextDecision::End,
938                        Routing::Branch { default, .. } => match default {
939                            Some(target) => NextDecision::Next(target.clone()),
940                            None => NextDecision::End,
941                        },
942                        Routing::Custom(raw) => {
943                            match evaluate_custom_routing(raw, &output, &state, &flow_ir, &node_id)
944                            {
945                                CustomRoutingDecision::Next(nid) => NextDecision::Next(nid),
946                                CustomRoutingDecision::End => NextDecision::End,
947                                CustomRoutingDecision::Wait => NextDecision::Wait,
948                            }
949                        }
950                    };
951
952                    // This node has now routed on the submit that was delivered
953                    // to it, so the action is spent. `response.*` is synthesised
954                    // from the run's entry envelope and is therefore run-scoped:
955                    // left in place it matches again at the NEXT card and the
956                    // run walks straight past it, advancing the journey by two
957                    // pages per submit. Clearing it here means every later node
958                    // sees a freshly rendered card with no pending action, falls
959                    // through its conditional routing, and parks for the user.
960                    if owned_the_submit {
961                        consume_routing_action(&mut state.entry);
962                    }
963
964                    match decision {
965                        NextDecision::Next(n) => current = n,
966                        NextDecision::End => {
967                            let nodes_snapshot = state.nodes.clone();
968                            let final_output = state.finalize_with(Some(output.payload.clone()));
969                            return Ok(FlowExecution::completed(lift_first_node_error_from_nodes(
970                                final_output,
971                                &nodes_snapshot,
972                            )));
973                        }
974                        NextDecision::Wait => {
975                            // Conditional routing fell through. Pause at the
976                            // current node so the next inbound activity
977                            // resumes here and re-evaluates this node's
978                            // routing with the user's new submit payload.
979                            let mut snapshot_state = state.clone();
980                            snapshot_state.clear_egress();
981                            let snapshot = FlowSnapshot {
982                                pack_id: step_ctx.pack_id.to_string(),
983                                flow_id: step_ctx.flow_id.to_string(),
984                                next_flow: (current_flow_id != step_ctx.flow_id)
985                                    .then_some(current_flow_id.clone()),
986                                next_node: node_id.as_str().to_string(),
987                                awaiting_submit: true,
988                                state: snapshot_state,
989                            };
990                            let output_value = state.finalize_with(Some(output.payload.clone()));
991                            return Ok(FlowExecution::waiting(
992                                output_value,
993                                FlowWait {
994                                    reason: Some(format!(
995                                        "awaiting user submit at node `{}`",
996                                        node_id.as_str()
997                                    )),
998                                    snapshot,
999                                },
1000                            ));
1001                        }
1002                    }
1003                }
1004                NodeControl::Wait { reason } => {
1005                    let next: Option<NodeId> = match &node.routing {
1006                        Routing::Next { node_id } => Some(node_id.clone()),
1007                        Routing::End | Routing::Reply => None,
1008                        Routing::Branch { default, .. } => default.clone(),
1009                        Routing::Custom(raw) => {
1010                            match evaluate_custom_routing(raw, &output, &state, &flow_ir, &node_id)
1011                            {
1012                                CustomRoutingDecision::Next(nid) => Some(nid),
1013                                // session.wait operator must have an
1014                                // explicit forward target — both End and
1015                                // Wait decisions collapse to "no next" and
1016                                // surface the same error below.
1017                                CustomRoutingDecision::End | CustomRoutingDecision::Wait => None,
1018                            }
1019                        }
1020                    };
1021                    let resume_target = next.ok_or_else(|| {
1022                        anyhow!(
1023                            "session.wait node {} requires a non-empty route",
1024                            current.as_str()
1025                        )
1026                    })?;
1027                    let mut snapshot_state = state.clone();
1028                    snapshot_state.clear_egress();
1029                    let snapshot = FlowSnapshot {
1030                        pack_id: step_ctx.pack_id.to_string(),
1031                        flow_id: step_ctx.flow_id.to_string(),
1032                        next_flow: (current_flow_id != step_ctx.flow_id)
1033                            .then_some(current_flow_id.clone()),
1034                        next_node: resume_target.as_str().to_string(),
1035                        awaiting_submit: false,
1036                        state: snapshot_state,
1037                    };
1038                    let output_value = state.clone().finalize_with(None);
1039                    return Ok(FlowExecution::waiting(
1040                        output_value,
1041                        FlowWait { reason, snapshot },
1042                    ));
1043                }
1044                NodeControl::Jump(jump) => {
1045                    let jump_target = self.apply_jump(&step_ctx, &mut state, jump).await?;
1046                    flow_ir = jump_target.flow;
1047                    current_flow_id = jump_target.flow_id;
1048                    current = jump_target.node_id;
1049                }
1050                NodeControl::Respond {
1051                    text,
1052                    card_cbor,
1053                    needs_user,
1054                } => {
1055                    let response = json!({
1056                        "text": text,
1057                        "card_cbor": card_cbor,
1058                        "needs_user": needs_user,
1059                    });
1060                    state.push_egress(response);
1061                    let nodes_snapshot = state.nodes.clone();
1062                    let final_output = state.finalize_with(None);
1063                    return Ok(FlowExecution::completed(lift_first_node_error_from_nodes(
1064                        final_output,
1065                        &nodes_snapshot,
1066                    )));
1067                }
1068            }
1069        }
1070    }
1071
1072    async fn dispatch_node(
1073        &self,
1074        ctx: &FlowContext<'_>,
1075        node_id: &str,
1076        node: &HostNode,
1077        state: &mut ExecutionState,
1078        mut payload: Value,
1079        event: &NodeEvent<'_>,
1080    ) -> Result<DispatchOutcome> {
1081        inject_card_locale(&mut payload, &state.entry);
1082        inject_card_route(&mut payload, &state.entry, node);
1083        match &node.kind {
1084            NodeKind::Exec { target_component } => self
1085                .execute_component_exec(
1086                    ctx,
1087                    node_id,
1088                    node,
1089                    payload,
1090                    event,
1091                    ComponentOverrides {
1092                        component: Some(target_component.as_str()),
1093                        operation: node.operation_name.as_deref(),
1094                    },
1095                )
1096                .await
1097                .and_then(component_dispatch_outcome),
1098            NodeKind::PackComponent { component_ref } => self
1099                .execute_component_call(ctx, node_id, node, payload, component_ref.as_str(), event)
1100                .await
1101                .and_then(component_dispatch_outcome),
1102            NodeKind::FlowCall => self
1103                .execute_flow_call(ctx, payload)
1104                .await
1105                .map(DispatchOutcome::complete),
1106            NodeKind::FlowGoto => execute_flow_goto(payload),
1107            NodeKind::ProviderInvoke => self
1108                .execute_provider_invoke(ctx, node_id, state, payload, event)
1109                .await
1110                .map(DispatchOutcome::complete),
1111            NodeKind::BuiltinEmit { kind } => {
1112                match kind {
1113                    EmitKind::Log | EmitKind::Response => {}
1114                    EmitKind::Other(component) => {
1115                        tracing::debug!(%component, "handling emit.* as builtin");
1116                    }
1117                }
1118                state.push_egress(payload.clone());
1119                Ok(DispatchOutcome::complete(NodeOutput::new(payload)))
1120            }
1121            NodeKind::BuiltinStateGet => self
1122                .execute_state_get(ctx, payload)
1123                .await
1124                .map(DispatchOutcome::complete),
1125            NodeKind::BuiltinStateSet => self
1126                .execute_state_set(ctx, payload)
1127                .await
1128                .map(DispatchOutcome::complete),
1129            NodeKind::VarSet { name, value } => {
1130                if name.trim().is_empty() {
1131                    tracing::warn!(
1132                        node_id = %node_id,
1133                        "var_set node has an empty variable name; skipping write"
1134                    );
1135                    return Ok(DispatchOutcome::complete(NodeOutput::new(
1136                        serde_json::json!({ "ok": true }),
1137                    )));
1138                }
1139                let prev = state.last_output.clone().unwrap_or(Value::Null);
1140                let ctx_val = template_context(state, prev);
1141                let rendered = render_template_value(
1142                    value,
1143                    &ctx_val,
1144                    TemplateOptions {
1145                        allow_pointer: true,
1146                    },
1147                )
1148                .context("failed to render var_set value")?;
1149                state.vars.insert(name.clone(), rendered);
1150                Ok(DispatchOutcome::complete(NodeOutput::new(
1151                    serde_json::json!({ "ok": true }),
1152                )))
1153            }
1154            NodeKind::Wait => {
1155                let reason = extract_wait_reason(&payload);
1156                Ok(DispatchOutcome::wait(NodeOutput::new(payload), reason))
1157            }
1158            NodeKind::DwAgent { agent_id } => {
1159                #[cfg(feature = "agentic-worker")]
1160                match self.dw_agent_dispatch {
1161                    crate::runner::agent_node::DwAgentDispatch::Nats => {
1162                        // Reroute to the durable out-of-process agentic path.
1163                        // Wrap the raw node payload as the dispatch `input` (the
1164                        // serve invoker reads `input.user_text`); `await=true` →
1165                        // pause+resume, identical to `agentic.call`.
1166                        let remote_payload = serde_json::json!({ "await": true, "input": payload });
1167                        self.execute_remote_dispatch(ctx, "agentic", agent_id, remote_payload)
1168                            .await
1169                    }
1170                    crate::runner::agent_node::DwAgentDispatch::InProcess => self
1171                        .execute_dw_agent(ctx, agent_id, payload)
1172                        .await
1173                        .map(DispatchOutcome::complete),
1174                }
1175                #[cfg(not(feature = "agentic-worker"))]
1176                self.execute_dw_agent(ctx, agent_id, payload)
1177                    .await
1178                    .map(DispatchOutcome::complete)
1179            }
1180            NodeKind::DwAgentGraph { graph_id } => self
1181                .execute_dw_agent_graph(ctx, graph_id, payload)
1182                .await
1183                .map(DispatchOutcome::complete),
1184            NodeKind::SorlaCall { target } => self.execute_sorla_call(ctx, target, payload).await,
1185            NodeKind::OperalaCall { target } => {
1186                self.execute_operala_call(ctx, target, payload).await
1187            }
1188            NodeKind::AgenticCall { target } => {
1189                self.execute_agentic_call(ctx, target, payload).await
1190            }
1191            NodeKind::TelcoXCall { target } => {
1192                self.execute_telco_x_call(ctx, target, payload).await
1193            }
1194            NodeKind::ApprovalCall { target } => {
1195                self.execute_approval_call(ctx, target, payload).await
1196            }
1197            NodeKind::Mcp { server_id, tool } => self
1198                .execute_mcp(ctx, server_id, tool, payload)
1199                .await
1200                .map(DispatchOutcome::complete),
1201        }
1202    }
1203
1204    #[cfg(feature = "agentic-worker")]
1205    async fn execute_dw_agent(
1206        &self,
1207        ctx: &FlowContext<'_>,
1208        agent_id: &str,
1209        payload: Value,
1210    ) -> Result<NodeOutput> {
1211        let handler = self
1212            .agent_node_handler
1213            .as_ref()
1214            .context("DwAgent node dispatched but no AgentNodeHandler configured on FlowEngine")?;
1215        let session_id = ctx.session_id.unwrap_or("");
1216        let result = handler
1217            .execute(
1218                ctx.tenant,
1219                &self.default_env,
1220                agent_id,
1221                session_id,
1222                &payload,
1223            )
1224            .await?;
1225        Ok(NodeOutput::new(result))
1226    }
1227
1228    #[cfg(not(feature = "agentic-worker"))]
1229    async fn execute_dw_agent(
1230        &self,
1231        _ctx: &FlowContext<'_>,
1232        agent_id: &str,
1233        _payload: Value,
1234    ) -> Result<NodeOutput> {
1235        anyhow::bail!(
1236            "DwAgent node '{agent_id}' cannot run: this build was compiled without the \
1237             `agentic-worker` feature. Rebuild with --features agentic-worker."
1238        )
1239    }
1240
1241    /// Dispatch a `sorla.call` flow node to the configured
1242    /// [`RemoteDispatchHandler`], publishing the work to a separate runtime.
1243    ///
1244    /// Input payload contract (JSON):
1245    /// `{ "await": bool (default true), "operation": str, "deadline_ms": u64?,
1246    ///    "input": any }`.
1247    ///
1248    /// The correlation id is the canonical session hint (`ctx.session_id`)
1249    /// suffixed with `::pack=<pack_id>::flow=<flow_id>` markers. The bare hint
1250    /// already encodes the conversation; the markers let the resume path
1251    /// (`RuntimeSessionResumer`) route the response back to a registered
1252    /// `(pack_id, flow_id)` and re-derive the store key. The markers are the
1253    /// exact inverse of the resumer's parsing (`::flow=` then `::pack=`,
1254    /// split off the trailing end).
1255    ///
1256    /// - `await=true`  -> publish + PAUSE the flow ([`DispatchOutcome::wait`]).
1257    /// - `await=false` -> publish + complete immediately with
1258    ///   `{ "dispatched": true, "correlation_id": <marked hint> }`.
1259    ///
1260    /// [`RemoteDispatchHandler`]: crate::runner::remote_dispatch::RemoteDispatchHandler
1261    async fn execute_sorla_call(
1262        &self,
1263        ctx: &FlowContext<'_>,
1264        target: &str,
1265        payload: Value,
1266    ) -> Result<DispatchOutcome> {
1267        self.execute_remote_dispatch(ctx, "sorla", target, payload)
1268            .await
1269    }
1270
1271    /// Dispatch an `operala.call` flow node via the shared remote-dispatch seam.
1272    /// Identical to [`execute_sorla_call`] except the runtime name is `"operala"`.
1273    async fn execute_operala_call(
1274        &self,
1275        ctx: &FlowContext<'_>,
1276        target: &str,
1277        payload: Value,
1278    ) -> Result<DispatchOutcome> {
1279        self.execute_remote_dispatch(ctx, "operala", target, payload)
1280            .await
1281    }
1282
1283    /// Dispatch an `agentic.call` flow node via the shared remote-dispatch seam.
1284    /// Identical to [`execute_sorla_call`] except the runtime name is `"agentic"`.
1285    /// This is the out-of-process agentic path; the in-process `dw.agent` node
1286    /// is completely separate and untouched.
1287    async fn execute_agentic_call(
1288        &self,
1289        ctx: &FlowContext<'_>,
1290        target: &str,
1291        payload: Value,
1292    ) -> Result<DispatchOutcome> {
1293        self.execute_remote_dispatch(ctx, "agentic", target, payload)
1294            .await
1295    }
1296
1297    /// Dispatch a `telco-x.call` flow node via the shared remote-dispatch seam.
1298    /// Mirrors [`execute_operala_call`] with runtime name `"telco-x"`. Wire-ready:
1299    /// no telco-x runtime is deployed yet, so an awaiting node pauses until one is.
1300    async fn execute_telco_x_call(
1301        &self,
1302        ctx: &FlowContext<'_>,
1303        target: &str,
1304        payload: Value,
1305    ) -> Result<DispatchOutcome> {
1306        self.execute_remote_dispatch(ctx, "telco-x", target, payload)
1307            .await
1308    }
1309
1310    /// Dispatch an `approval.call` flow node. Applies the autonomy gate first:
1311    /// when the gate says a human is NOT required, complete immediately on the
1312    /// `approved` branch WITHOUT creating a pending approval; otherwise dispatch
1313    /// to the `"approval"` runtime over the shared remote-dispatch seam (which
1314    /// durably pauses the flow until the human resolves it).
1315    async fn execute_approval_call(
1316        &self,
1317        ctx: &FlowContext<'_>,
1318        target: &str,
1319        payload: Value,
1320    ) -> Result<DispatchOutcome> {
1321        let input = payload.get("input").cloned().unwrap_or(Value::Null);
1322        if !approval_requires_human(&input) {
1323            // Match the shape the resume path injects ({ok, output, error})
1324            // so downstream conditions read the decision at the same
1325            // relative path regardless of whether a human was involved.
1326            let output = NodeOutput::new(serde_json::json!({
1327                "ok": true,
1328                "output": { "decision": "approved", "auto": true },
1329                "error": serde_json::Value::Null,
1330            }));
1331            return Ok(DispatchOutcome::complete(output));
1332        }
1333        self.execute_remote_dispatch(ctx, "approval", target, payload)
1334            .await
1335    }
1336
1337    /// Execute a `component == "mcp"` flow node (LOCKED ENCODING v2).
1338    ///
1339    /// `payload` is the already-rendered node input mapping (the engine
1340    /// templates `{{ }}` against flow state before dispatch), shaped
1341    /// `{ "server": <id>, "tool": <name>, "arguments": <object>,
1342    ///    "output": <optional string state key> }`.
1343    ///
1344    /// `server`/`tool` are sourced from this payload (the source of truth);
1345    /// the `server_id`/`tool` parsed at flow-load time are passed in only as a
1346    /// fallback for the legacy `operation = "<server>/<tool>"` encoding. The MCP
1347    /// tool is invoked through the tenant's `flow_editor` catalog (reusing
1348    /// `greentic-aw-runtime`'s `McpToolSource`); the result value is bound under
1349    /// `output` when present, else returned as the node payload.
1350    ///
1351    /// Graceful by contract: MCP being unconfigured or the tool being
1352    /// unreachable yields a structured `{"error": ...}` value — never a panic,
1353    /// never an aborted runtime.
1354    #[cfg(feature = "agentic-worker")]
1355    async fn execute_mcp(
1356        &self,
1357        ctx: &FlowContext<'_>,
1358        server_id: &str,
1359        tool: &str,
1360        payload: Value,
1361    ) -> Result<NodeOutput> {
1362        // Payload is the source of truth: prefer the rendered `server`/`tool`
1363        // from config, falling back to the values resolved at flow-load time
1364        // (legacy `operation`/`mcp:` encoding).
1365        let payload_server = crate::runner::mcp_node::str_field(&payload, "server");
1366        let payload_tool = crate::runner::mcp_node::str_field(&payload, "tool");
1367        let server_id = payload_server.as_deref().unwrap_or(server_id);
1368        let tool = payload_tool.as_deref().unwrap_or(tool);
1369
1370        // `arguments` defaults to `{}` so a no-arg tool needs no config.
1371        let arguments = payload
1372            .get("arguments")
1373            .cloned()
1374            .unwrap_or_else(|| Value::Object(JsonMap::new()));
1375
1376        // A route the pack carries itself, so a deployed runner with no admin
1377        // credentials can dispatch at all; `None` (a pack predating the
1378        // sidecar) falls back to the catalog exactly as before.
1379        let pack = self
1380            .packs
1381            .iter()
1382            .find(|pack| pack.metadata().pack_id == ctx.pack_id);
1383        let pack_routes = pack.and_then(|pack| pack.mcp_routes());
1384
1385        // The team the AUTHORING session resolved this server's token under, as
1386        // recorded in the sidecar. `FlowContext` carries no team and a deployed
1387        // workload has no principled source for one, so this is the only place
1388        // the value can come from. Absent yields `None`, and the credential
1389        // read then tries the tenant-default `_` scope exactly as before.
1390        let auth_team = pack_routes
1391            .and_then(|routes| routes.get(server_id))
1392            .and_then(|route| route.auth_team.as_deref());
1393
1394        // The HOST's secrets manager, not one derived from `SECRETS_BACKEND`.
1395        // That env only names `env` and `broker`; an operator booting a bundle
1396        // runs on greentic-start's dev store, which the runner has no variant
1397        // for — so a manager built from the environment can never read the
1398        // credential, and the node would dispatch without one.
1399        let result = crate::runner::mcp_node::invoke_with_secrets(
1400            self.mcp_tool_source.as_ref(),
1401            pack_routes,
1402            pack.map(|p| p.secrets()),
1403            ctx.tenant,
1404            &self.default_env,
1405            auth_team,
1406            server_id,
1407            tool,
1408            &arguments,
1409        )
1410        .await;
1411
1412        // Bind the result under the optional `output` state key. When absent,
1413        // the raw tool result becomes the node payload (still addressable via
1414        // the standard `node.<id>.payload` mechanism).
1415        let bound = match payload.get("output").and_then(Value::as_str) {
1416            Some(key) if !key.is_empty() => json!({ key: result.clone() }),
1417            _ => result.clone(),
1418        };
1419        Ok(mcp_output(bound, &result))
1420    }
1421
1422    /// Compile-time stub for the MCP flow node when the agentic-worker feature
1423    /// (which carries the MCP runtime deps) is disabled. The node degrades to a
1424    /// clear error value rather than failing the build or the run.
1425    #[cfg(not(feature = "agentic-worker"))]
1426    async fn execute_mcp(
1427        &self,
1428        _ctx: &FlowContext<'_>,
1429        server_id: &str,
1430        tool: &str,
1431        _payload: Value,
1432    ) -> Result<NodeOutput> {
1433        Ok(NodeOutput::new(json!({
1434            "error": format!(
1435                "mcp node '{server_id}/{tool}' requires the agentic-worker feature (MCP runtime not compiled in)"
1436            )
1437        })))
1438    }
1439
1440    /// Shared body for all native remote-dispatch flow nodes (`sorla.call`,
1441    /// `operala.call`, `agentic.call`). Routes through the injected
1442    /// [`RemoteDispatchHandler`] with the given `runtime` name.
1443    ///
1444    /// Input payload contract (JSON):
1445    /// `{ "await": bool (default true), "operation": str, "deadline_ms": u64?,
1446    ///    "input": any }`.
1447    ///
1448    /// The correlation id is the canonical session hint (`ctx.session_id`)
1449    /// suffixed with `::pack=<pack_id>::flow=<flow_id>` markers so the resume
1450    /// path (`RuntimeSessionResumer`) can route the response back.
1451    ///
1452    /// - `await=true`  -> publish + PAUSE the flow ([`DispatchOutcome::wait`]).
1453    /// - `await=false` -> publish + complete immediately with
1454    ///   `{ "dispatched": true, "correlation_id": <marked hint> }`.
1455    ///
1456    /// [`RemoteDispatchHandler`]: crate::runner::remote_dispatch::RemoteDispatchHandler
1457    async fn execute_remote_dispatch(
1458        &self,
1459        ctx: &FlowContext<'_>,
1460        runtime: &str,
1461        target: &str,
1462        payload: Value,
1463    ) -> Result<DispatchOutcome> {
1464        let handler = self.remote_dispatch_handler.as_ref().with_context(|| {
1465            format!("{runtime}.call node dispatched but no RemoteDispatchHandler configured")
1466        })?;
1467
1468        let await_mode = payload
1469            .get("await")
1470            .and_then(Value::as_bool)
1471            .unwrap_or(true);
1472        let operation = payload
1473            .get("operation")
1474            .and_then(Value::as_str)
1475            .unwrap_or_default()
1476            .to_string();
1477        let deadline_ms = payload.get("deadline_ms").and_then(Value::as_u64);
1478        let inner_input = payload.get("input").cloned().unwrap_or(Value::Null);
1479
1480        // The resume path (`RuntimeSessionResumer`) recovers `pack_id` and
1481        // `flow_id` from `::pack=`/`::flow=` markers on the correlation id to
1482        // route the synthesized resume envelope, then strips them to recover the
1483        // bare canonical hint used as the store key. So the published
1484        // correlation id MUST carry those markers and preserve the bare hint.
1485        //
1486        // Bare canonical hint = everything before the first `::` marker. This is
1487        // robust whether `ctx.session_id` is already bare (the production case)
1488        // or has accreted a marker.
1489        let raw_hint = ctx.session_id.unwrap_or_default();
1490        let bare_hint = raw_hint.split("::").next().unwrap_or_default();
1491        // The store key (`FlowResumeStore::save`) hashes the inbound reply
1492        // scope's `conversation`/`thread`/`reply_to`. The bare canonical hint
1493        // only encodes `conversation`, so a wait saved against a non-empty
1494        // `thread`/`reply_to` would be un-keyable on resume. Append OPAQUE
1495        // `::thread=`/`::reply=` markers so `RuntimeSessionResumer` can rebuild
1496        // the EXACT reply scope and recompute the same `scope_hash`. The remote
1497        // bridge echoes the correlation verbatim, so this needs no bridge change.
1498        // Markers are omitted when their value is empty (back-compat with the
1499        // no-thread case).
1500        let mut correlation_id =
1501            format!("{}::pack={}::flow={}", bare_hint, ctx.pack_id, ctx.flow_id);
1502        if let Some(scope) = ctx.reply_scope {
1503            if let Some(thread) = scope.thread.as_deref().filter(|value| !value.is_empty()) {
1504                correlation_id.push_str("::thread=");
1505                correlation_id.push_str(thread);
1506            }
1507            if let Some(reply_to) = scope.reply_to.as_deref().filter(|value| !value.is_empty()) {
1508                correlation_id.push_str("::reply=");
1509                correlation_id.push_str(reply_to);
1510            }
1511        }
1512        let mode = if await_mode {
1513            greentic_types::DispatchMode::Await
1514        } else {
1515            greentic_types::DispatchMode::FireAndForget
1516        };
1517
1518        let action = handler
1519            .dispatch(crate::runner::remote_dispatch::RemoteDispatch {
1520                tenant: ctx.tenant.to_string(),
1521                env: self.default_env.clone(),
1522                runtime: runtime.to_string(),
1523                target: target.to_string(),
1524                operation,
1525                mode,
1526                correlation_id: correlation_id.clone(),
1527                input: inner_input,
1528                deadline_ms,
1529            })
1530            .await?;
1531
1532        match action {
1533            crate::runner::remote_dispatch::RemoteDispatchAction::AwaitingResponse {
1534                correlation_id,
1535            } => {
1536                let reason = format!("await-runtime:{correlation_id}");
1537                let output = NodeOutput::new(serde_json::json!({
1538                    "pending": true,
1539                    "correlation_id": correlation_id,
1540                }));
1541                Ok(DispatchOutcome::wait(output, Some(reason)))
1542            }
1543            crate::runner::remote_dispatch::RemoteDispatchAction::Dispatched => {
1544                let output = NodeOutput::new(serde_json::json!({
1545                    "dispatched": true,
1546                    "correlation_id": correlation_id,
1547                }));
1548                Ok(DispatchOutcome::complete(output))
1549            }
1550        }
1551    }
1552
1553    /// Dispatch a `DwAgentGraph` flow node to the configured
1554    /// [`GraphNodeHandler`]. Mirrors [`execute_dw_agent`]: same tenant/env/
1555    /// session-id derivation, same envelope, same "handler not configured"
1556    /// error path.
1557    ///
1558    /// [`execute_dw_agent`]: FlowEngine::execute_dw_agent
1559    #[cfg(feature = "agentic-worker")]
1560    async fn execute_dw_agent_graph(
1561        &self,
1562        ctx: &FlowContext<'_>,
1563        graph_id: &str,
1564        payload: Value,
1565    ) -> Result<NodeOutput> {
1566        let handler = self.graph_node_handler.as_ref().context(
1567            "DwAgentGraph node dispatched but no GraphNodeHandler configured on FlowEngine",
1568        )?;
1569        let session_id = ctx.session_id.unwrap_or("");
1570        let result = handler
1571            .execute(
1572                ctx.tenant,
1573                &self.default_env,
1574                graph_id,
1575                session_id,
1576                &payload,
1577            )
1578            .await?;
1579        Ok(NodeOutput::new(result))
1580    }
1581
1582    #[cfg(not(feature = "agentic-worker"))]
1583    async fn execute_dw_agent_graph(
1584        &self,
1585        _ctx: &FlowContext<'_>,
1586        graph_id: &str,
1587        _payload: Value,
1588    ) -> Result<NodeOutput> {
1589        anyhow::bail!(
1590            "DwAgentGraph node '{graph_id}' cannot run: this build was compiled without the \
1591             `agentic-worker` feature. Rebuild with --features agentic-worker."
1592        )
1593    }
1594
1595    async fn execute_state_get(&self, ctx: &FlowContext<'_>, payload: Value) -> Result<NodeOutput> {
1596        let key = Self::extract_state_key_helper(&payload)?;
1597        let pack = self.pack_for_flow(ctx)?;
1598        let store = pack
1599            .state_store_handle()
1600            .context("state store is not configured for this runtime")?;
1601        let tenant_ctx = self.state_tenant_ctx(ctx)?;
1602        let state_key = greentic_state::StateKey::new(&key);
1603        let value = store
1604            .get_json(
1605                &tenant_ctx,
1606                crate::storage::state::STATE_PREFIX,
1607                &state_key,
1608                None,
1609            )
1610            .with_context(|| format!("state.get failed for key `{key}`"))?;
1611        let payload = serde_json::json!({
1612            "key": key,
1613            "value": value,
1614            "found": value.is_some(),
1615        });
1616        Ok(NodeOutput::new(payload))
1617    }
1618
1619    async fn execute_state_set(&self, ctx: &FlowContext<'_>, payload: Value) -> Result<NodeOutput> {
1620        let key = Self::extract_state_key_helper(&payload)?;
1621        let value = payload.get("value").cloned().unwrap_or(Value::Null);
1622        let pack = self.pack_for_flow(ctx)?;
1623        let store = pack
1624            .state_store_handle()
1625            .context("state store is not configured for this runtime")?;
1626        let tenant_ctx = self.state_tenant_ctx(ctx)?;
1627        let state_key = greentic_state::StateKey::new(&key);
1628        store
1629            .set_json(
1630                &tenant_ctx,
1631                crate::storage::state::STATE_PREFIX,
1632                &state_key,
1633                None,
1634                &value,
1635                None,
1636            )
1637            .with_context(|| format!("state.set failed for key `{key}`"))?;
1638        let payload = serde_json::json!({ "key": key, "value": value });
1639        Ok(NodeOutput::new(payload))
1640    }
1641
1642    fn pack_for_flow(&self, ctx: &FlowContext<'_>) -> Result<&Arc<PackRuntime>> {
1643        let key = FlowKey {
1644            pack_id: ctx.pack_id.to_string(),
1645            flow_id: ctx.flow_id.to_string(),
1646        };
1647        let idx = self.flow_sources.get(&key).with_context(|| {
1648            format!("flow {} (pack {}) not registered", ctx.flow_id, ctx.pack_id)
1649        })?;
1650        Ok(&self.packs[*idx])
1651    }
1652
1653    fn extract_state_key_helper(payload: &Value) -> Result<String> {
1654        payload
1655            .get("key")
1656            .and_then(Value::as_str)
1657            .map(String::from)
1658            .filter(|k| !k.is_empty())
1659            .context("state node payload missing required `key` (non-empty string)")
1660    }
1661
1662    fn state_tenant_ctx(&self, ctx: &FlowContext<'_>) -> Result<greentic_types::TenantCtx> {
1663        let env = greentic_types::EnvId::from_str(&self.default_env)
1664            .with_context(|| format!("invalid env id `{}`", self.default_env))?;
1665        let tenant = greentic_types::TenantId::from_str(ctx.tenant)
1666            .with_context(|| format!("invalid tenant id `{}`", ctx.tenant))?;
1667        Ok(greentic_types::TenantCtx::new(env, tenant))
1668    }
1669
1670    async fn apply_jump(
1671        &self,
1672        ctx: &FlowContext<'_>,
1673        state: &mut ExecutionState,
1674        jump: JumpControl,
1675    ) -> Result<JumpTarget> {
1676        let target_flow = jump.flow.trim();
1677        if target_flow.is_empty() {
1678            bail!("missing_flow");
1679        }
1680
1681        let flow = self
1682            .get_or_load_flow(ctx.pack_id, target_flow)
1683            .await
1684            .with_context(|| format!("unknown_flow:{target_flow}"))?;
1685
1686        let target_node = if let Some(node) = jump.node.as_deref() {
1687            let parsed = NodeId::from_str(node).with_context(|| format!("unknown_node:{node}"))?;
1688            if !flow.nodes.contains_key(&parsed) {
1689                bail!("unknown_node:{node}");
1690            }
1691            parsed
1692        } else {
1693            flow.start
1694                .clone()
1695                .or_else(|| flow.nodes.keys().next().cloned())
1696                .ok_or_else(|| anyhow!("jump_failed: flow {target_flow} has no start node"))?
1697        };
1698
1699        let max_redirects = jump.max_redirects.unwrap_or(3);
1700        if state.redirect_count() >= max_redirects {
1701            bail!("redirect_limit");
1702        }
1703        state.increment_redirect_count();
1704        state.replace_input(jump.payload.clone());
1705        state.last_output = Some(jump.payload);
1706        tracing::info!(
1707            flow_id = %ctx.flow_id,
1708            target_flow = %target_flow,
1709            target_node = %target_node.as_str(),
1710            reason = ?jump.reason,
1711            redirects = state.redirect_count(),
1712            "flow.jump.applied"
1713        );
1714
1715        Ok(JumpTarget {
1716            flow_id: target_flow.to_string(),
1717            flow,
1718            node_id: target_node,
1719        })
1720    }
1721
1722    async fn execute_flow_call(&self, ctx: &FlowContext<'_>, payload: Value) -> Result<NodeOutput> {
1723        #[derive(Deserialize)]
1724        struct FlowCallPayload {
1725            #[serde(alias = "flow")]
1726            flow_id: String,
1727            #[serde(default)]
1728            input: Value,
1729        }
1730
1731        let call: FlowCallPayload =
1732            serde_json::from_value(payload).context("invalid payload for flow.call node")?;
1733        if call.flow_id.trim().is_empty() {
1734            bail!("flow.call requires a non-empty flow_id");
1735        }
1736
1737        let sub_input = if call.input.is_null() {
1738            Value::Null
1739        } else {
1740            call.input
1741        };
1742
1743        let flow_id_owned = call.flow_id;
1744        let action = "flow.call";
1745        let sub_ctx = FlowContext {
1746            tenant: ctx.tenant,
1747            pack_id: ctx.pack_id,
1748            flow_id: flow_id_owned.as_str(),
1749            node_id: None,
1750            tool: ctx.tool,
1751            action: Some(action),
1752            session_id: ctx.session_id,
1753            provider_id: ctx.provider_id,
1754            reply_scope: ctx.reply_scope,
1755            retry_config: ctx.retry_config,
1756            attempt: ctx.attempt,
1757            observer: ctx.observer,
1758            mocks: ctx.mocks,
1759        };
1760
1761        let execution = Box::pin(self.execute(sub_ctx, sub_input))
1762            .await
1763            .with_context(|| format!("flow.call failed for {}", flow_id_owned))?;
1764        match execution.status {
1765            FlowStatus::Completed => Ok(NodeOutput::new(execution.output)),
1766            FlowStatus::Waiting(wait) => bail!(
1767                "flow.call cannot pause (flow {} waiting {:?})",
1768                flow_id_owned,
1769                wait.reason
1770            ),
1771        }
1772    }
1773}
1774
1775/// Build the jump that hands this turn over to another flow.
1776///
1777/// Payload shape mirrors `flow.call`'s, so a flow document reads the same
1778/// either way:
1779///
1780/// ```yaml
1781/// <node_id>:
1782///   flow.goto:
1783///     flow_id: support        # required; `flow` accepted as an alias
1784///     node: ask_order         # optional entry node, else the flow's start
1785///     input: { order: "..." } # optional; becomes the target's input
1786/// ```
1787///
1788/// Returns a `NodeControl::Jump`, which `apply_jump` then applies to the SAME
1789/// walk — that is what makes the target's `Waiting` become the turn's, instead
1790/// of the hard error `flow.call` raises. Loop protection is inherited: every
1791/// jump increments the execution's redirect count and `redirect_limit` trips at
1792/// `max_redirects` (default 3).
1793fn execute_flow_goto(payload: Value) -> Result<DispatchOutcome> {
1794    #[derive(Deserialize)]
1795    struct FlowGotoPayload {
1796        #[serde(alias = "flow")]
1797        flow_id: String,
1798        #[serde(default)]
1799        node: Option<String>,
1800        #[serde(default)]
1801        input: Value,
1802        #[serde(default)]
1803        max_redirects: Option<u32>,
1804        #[serde(default)]
1805        reason: Option<String>,
1806    }
1807
1808    let goto: FlowGotoPayload =
1809        serde_json::from_value(payload).context("invalid payload for flow.goto node")?;
1810    let flow = goto.flow_id.trim().to_string();
1811    if flow.is_empty() {
1812        bail!("flow.goto requires a non-empty flow_id");
1813    }
1814    let node = goto
1815        .node
1816        .map(|n| n.trim().to_string())
1817        .filter(|n| !n.is_empty());
1818
1819    let jump = JumpControl {
1820        flow,
1821        node,
1822        payload: goto.input,
1823        // `hints` carries component-supplied routing metadata; a declarative
1824        // goto has none to add, and passing anything here would put a value in
1825        // the target's meta that the flow author never wrote.
1826        hints: Value::Null,
1827        max_redirects: goto.max_redirects,
1828        reason: goto.reason.or_else(|| Some("flow.goto node".to_string())),
1829    };
1830    let output = NodeOutput::with_meta(jump.payload.clone(), jump.hints.clone());
1831    Ok(DispatchOutcome::with_control(
1832        output,
1833        NodeControl::Jump(jump),
1834    ))
1835}
1836
1837impl FlowEngine {
1838    async fn execute_component_exec(
1839        &self,
1840        ctx: &FlowContext<'_>,
1841        node_id: &str,
1842        node: &HostNode,
1843        payload: Value,
1844        event: &NodeEvent<'_>,
1845        overrides: ComponentOverrides<'_>,
1846    ) -> Result<NodeOutput> {
1847        #[derive(Deserialize)]
1848        struct ComponentPayload {
1849            #[serde(default, alias = "component_ref", alias = "component")]
1850            component: Option<String>,
1851            #[serde(alias = "op")]
1852            operation: Option<String>,
1853            #[serde(default)]
1854            input: Value,
1855            #[serde(default)]
1856            config: Value,
1857        }
1858
1859        let payload: ComponentPayload =
1860            serde_json::from_value(payload).context("invalid payload for component.exec")?;
1861        let component_ref = overrides
1862            .component
1863            .map(str::to_string)
1864            .or_else(|| payload.component.filter(|v| !v.trim().is_empty()))
1865            .with_context(|| "component.exec requires a component_ref")?;
1866        let operation = resolve_component_operation(
1867            node_id,
1868            node.component_id.as_str(),
1869            payload.operation,
1870            overrides.operation,
1871            node.operation_in_mapping.as_deref(),
1872        )?;
1873
1874        let call = ComponentCall {
1875            component_ref,
1876            operation,
1877            input: payload.input,
1878            config: payload.config,
1879            has_error_route: node_has_error_route(&node.routing),
1880        };
1881
1882        self.invoke_component_call(ctx, node_id, call, event).await
1883    }
1884
1885    async fn execute_component_call(
1886        &self,
1887        ctx: &FlowContext<'_>,
1888        node_id: &str,
1889        node: &HostNode,
1890        payload: Value,
1891        component_ref: &str,
1892        event: &NodeEvent<'_>,
1893    ) -> Result<NodeOutput> {
1894        let payload_operation = extract_operation_from_mapping(&payload);
1895        let (input, config) = split_operation_payload(payload);
1896        let operation = resolve_component_operation(
1897            node_id,
1898            node.component_id.as_str(),
1899            payload_operation,
1900            node.operation_name.as_deref(),
1901            node.operation_in_mapping.as_deref(),
1902        )?;
1903        let call = ComponentCall {
1904            component_ref: component_ref.to_string(),
1905            operation,
1906            input,
1907            config,
1908            has_error_route: node_has_error_route(&node.routing),
1909        };
1910        self.invoke_component_call(ctx, node_id, call, event).await
1911    }
1912
1913    async fn invoke_component_call(
1914        &self,
1915        ctx: &FlowContext<'_>,
1916        node_id: &str,
1917        mut call: ComponentCall,
1918        event: &NodeEvent<'_>,
1919    ) -> Result<NodeOutput> {
1920        self.validate_component(ctx, event, &call)?;
1921        let key = FlowKey {
1922            pack_id: ctx.pack_id.to_string(),
1923            flow_id: ctx.flow_id.to_string(),
1924        };
1925        let pack_idx = *self.flow_sources.get(&key).with_context(|| {
1926            format!("flow {} (pack {}) not registered", ctx.flow_id, ctx.pack_id)
1927        })?;
1928        let pack = Arc::clone(&self.packs[pack_idx]);
1929
1930        // Promote adaptive-card defaults from node config (default_card_asset /
1931        // default_card_inline / default_source) into the invocation, so the
1932        // component receives a valid `card_spec` field even when the user input
1933        // is empty (e.g. webchat ConversationStart with no text). Without this,
1934        // schema validation in the component reports AC_INVOCATION_MISSING_FIELD
1935        // and the renderer falls back to a generic "Welcome" placeholder.
1936        promote_card_config_to_invocation(&mut call.input, &call.config);
1937
1938        // Pre-resolve card asset paths: read JSON files from the pack's assets
1939        // directory and inject as inline_json so the component doesn't need
1940        // WASI filesystem access.
1941        resolve_card_assets(&mut call.input, &pack);
1942
1943        // When the input is a card-like invocation (has card_source/card_spec),
1944        // pass it directly to the component instead of wrapping in an
1945        // InvocationEnvelope.  The envelope serialises the payload field as a
1946        // byte array which the component cannot decode back, and the
1947        // InvocationPayload::parse heuristic strips domain fields when a
1948        // `payload` key is present (e.g.  the card's Handlebars template
1949        // context `payload: {}`).
1950        let is_card = is_card_invocation(&call.input);
1951
1952        let input_json = if is_card {
1953            serde_json::to_string(&call.input)?
1954        } else {
1955            // Runtime owns ctx; flows must not embed ctx, even if they provide envelopes.
1956            let meta = InvocationMeta {
1957                env: &self.default_env,
1958                tenant: ctx.tenant,
1959                flow_id: ctx.flow_id,
1960                node_id: Some(node_id),
1961                provider_id: ctx.provider_id,
1962                session_id: ctx.session_id,
1963                attempt: ctx.attempt,
1964            };
1965            let invocation_envelope =
1966                build_invocation_envelope(meta, call.operation.as_str(), call.input)
1967                    .context("build invocation envelope for component call")?;
1968            serde_json::to_string(&invocation_envelope)?
1969        };
1970        let config_json = if call.config.is_null() {
1971            None
1972        } else {
1973            Some(serde_json::to_string(&call.config)?)
1974        };
1975
1976        let exec_ctx = component_exec_ctx(ctx, node_id);
1977        #[cfg(feature = "fault-injection")]
1978        {
1979            let fault_ctx = FaultContext {
1980                pack_id: ctx.pack_id,
1981                flow_id: ctx.flow_id,
1982                node_id: Some(node_id),
1983                attempt: ctx.attempt,
1984            };
1985            maybe_fail(FaultPoint::BeforeComponentCall, fault_ctx)
1986                .map_err(|err| anyhow!(err.to_string()))?;
1987        }
1988        let value = pack
1989            .invoke_component(
1990                call.component_ref.as_str(),
1991                exec_ctx,
1992                call.operation.as_str(),
1993                config_json,
1994                input_json,
1995            )
1996            .await?;
1997        #[cfg(feature = "fault-injection")]
1998        {
1999            let fault_ctx = FaultContext {
2000                pack_id: ctx.pack_id,
2001                flow_id: ctx.flow_id,
2002                node_id: Some(node_id),
2003                attempt: ctx.attempt,
2004            };
2005            maybe_fail(FaultPoint::AfterComponentCall, fault_ctx)
2006                .map_err(|err| anyhow!(err.to_string()))?;
2007        }
2008
2009        if let Some((code, message)) = component_error(&value) {
2010            // node_io error routing: a node with an `on_error`-family route
2011            // surfaces the failure as an `{errors}` output and lets its error
2012            // branch handle it. Nodes without such a route keep the historical
2013            // hard-fail, so this is purely additive.
2014            if call.has_error_route {
2015                return Ok(NodeOutput::errored(value));
2016            }
2017            bail!(
2018                "component {} failed: {}: {}",
2019                call.component_ref,
2020                code,
2021                message
2022            );
2023        }
2024        // MCP-shaped tool errors (greentic-mcp-generator's tool_error_with_status)
2025        // come back as a top-level `{ "error": { "code", "message", "status" } }`
2026        // value with the WIT envelope still ok=true (because the wasm guest
2027        // returned normally). Treat them the same as a component_error so the
2028        // engine error-envelope lift path surfaces the failure to the user.
2029        if let Some((code, message)) = mcp_tool_error(&value) {
2030            bail!(
2031                "component {} returned tool error: {}: {}",
2032                call.component_ref,
2033                code,
2034                message
2035            );
2036        }
2037        let meta = outcome_meta(&value);
2038        Ok(NodeOutput::with_meta(value, meta))
2039    }
2040
2041    async fn execute_provider_invoke(
2042        &self,
2043        ctx: &FlowContext<'_>,
2044        node_id: &str,
2045        state: &ExecutionState,
2046        payload: Value,
2047        event: &NodeEvent<'_>,
2048    ) -> Result<NodeOutput> {
2049        #[derive(Deserialize)]
2050        struct ProviderPayload {
2051            #[serde(default)]
2052            provider_id: Option<String>,
2053            #[serde(default)]
2054            provider_type: Option<String>,
2055            #[serde(default, alias = "operation")]
2056            op: Option<String>,
2057            #[serde(default)]
2058            input: Value,
2059            #[serde(default)]
2060            in_map: Value,
2061            #[serde(default)]
2062            out_map: Value,
2063            #[serde(default)]
2064            err_map: Value,
2065        }
2066
2067        let payload: ProviderPayload =
2068            serde_json::from_value(payload).context("invalid payload for provider.invoke")?;
2069        let op = payload
2070            .op
2071            .as_deref()
2072            .filter(|v| !v.trim().is_empty())
2073            .with_context(|| "provider.invoke requires an op")?
2074            .to_string();
2075
2076        let prev = state
2077            .last_output
2078            .as_ref()
2079            .cloned()
2080            .unwrap_or_else(|| Value::Object(JsonMap::new()));
2081        let base_ctx = template_context(state, prev);
2082
2083        let input_value = if !payload.in_map.is_null() {
2084            let mut ctx_value = base_ctx.clone();
2085            if let Value::Object(ref mut map) = ctx_value {
2086                map.insert("input".into(), payload.input.clone());
2087                map.insert("result".into(), payload.input.clone());
2088            }
2089            render_template_value(
2090                &payload.in_map,
2091                &ctx_value,
2092                TemplateOptions {
2093                    allow_pointer: true,
2094                },
2095            )
2096            .context("failed to render provider.invoke in_map")?
2097        } else if !payload.input.is_null() {
2098            payload.input
2099        } else {
2100            Value::Null
2101        };
2102        let input_json = serde_json::to_vec(&input_value)?;
2103
2104        self.validate_tool(
2105            ctx,
2106            event,
2107            payload.provider_id.as_deref(),
2108            payload.provider_type.as_deref(),
2109            &op,
2110            &input_value,
2111        )?;
2112
2113        let key = FlowKey {
2114            pack_id: ctx.pack_id.to_string(),
2115            flow_id: ctx.flow_id.to_string(),
2116        };
2117        let pack_idx = *self.flow_sources.get(&key).with_context(|| {
2118            format!("flow {} (pack {}) not registered", ctx.flow_id, ctx.pack_id)
2119        })?;
2120        let pack = Arc::clone(&self.packs[pack_idx]);
2121        let binding = pack.resolve_provider(
2122            payload.provider_id.as_deref(),
2123            payload.provider_type.as_deref(),
2124        );
2125
2126        // If pack-local resolution fails, try the cross-pack resolver (capability registry).
2127        if binding.is_err()
2128            && let Some(output) = self.try_invoke_cross_pack_resolver(
2129                payload.provider_id.as_deref(),
2130                payload.provider_type.as_deref(),
2131                &op,
2132                &input_json,
2133                ctx.tenant,
2134            )?
2135        {
2136            return Ok(output);
2137        }
2138
2139        let binding = binding?;
2140        let exec_ctx = component_exec_ctx(ctx, node_id);
2141        #[cfg(feature = "fault-injection")]
2142        {
2143            let fault_ctx = FaultContext {
2144                pack_id: ctx.pack_id,
2145                flow_id: ctx.flow_id,
2146                node_id: Some(node_id),
2147                attempt: ctx.attempt,
2148            };
2149            maybe_fail(FaultPoint::BeforeToolCall, fault_ctx)
2150                .map_err(|err| anyhow!(err.to_string()))?;
2151        }
2152        let provider_metric_id = payload
2153            .provider_id
2154            .as_deref()
2155            .or(payload.provider_type.as_deref())
2156            .unwrap_or("unknown");
2157        let invoke_started = std::time::Instant::now();
2158        let invoke_result = pack
2159            .invoke_provider(&binding, exec_ctx, &op, input_json)
2160            .await;
2161        let invoke_duration_ms = invoke_started.elapsed().as_secs_f64() * 1000.0;
2162        crate::metrics::record_provider_invocation(
2163            ctx.tenant,
2164            provider_metric_id,
2165            &op,
2166            if invoke_result.is_ok() { "ok" } else { "err" },
2167            invoke_duration_ms,
2168        );
2169        let result = invoke_result?;
2170        #[cfg(feature = "fault-injection")]
2171        {
2172            let fault_ctx = FaultContext {
2173                pack_id: ctx.pack_id,
2174                flow_id: ctx.flow_id,
2175                node_id: Some(node_id),
2176                attempt: ctx.attempt,
2177            };
2178            maybe_fail(FaultPoint::AfterToolCall, fault_ctx)
2179                .map_err(|err| anyhow!(err.to_string()))?;
2180        }
2181
2182        let output = if payload.out_map.is_null() {
2183            result
2184        } else {
2185            let mut ctx_value = base_ctx;
2186            if let Value::Object(ref mut map) = ctx_value {
2187                map.insert("input".into(), result.clone());
2188                map.insert("result".into(), result.clone());
2189            }
2190            render_template_value(
2191                &payload.out_map,
2192                &ctx_value,
2193                TemplateOptions {
2194                    allow_pointer: true,
2195                },
2196            )
2197            .context("failed to render provider.invoke out_map")?
2198        };
2199        let _ = payload.err_map;
2200        Ok(NodeOutput::new(output))
2201    }
2202
2203    fn try_invoke_cross_pack_resolver(
2204        &self,
2205        provider_id: Option<&str>,
2206        provider_type: Option<&str>,
2207        op: &str,
2208        input_json: &[u8],
2209        tenant: &str,
2210    ) -> Result<Option<NodeOutput>> {
2211        eprintln!(
2212            "[DEBUG] provider.invoke: pack-local failed, has_resolver={}",
2213            self.cross_pack_resolver.is_some()
2214        );
2215        let Some(resolver) = self.cross_pack_resolver.as_ref() else {
2216            return Ok(None);
2217        };
2218        let provider_id = provider_id.unwrap_or("unknown");
2219        tracing::info!(
2220            provider_id,
2221            op = %op,
2222            "provider.invoke: pack-local resolution failed, trying cross-pack resolver"
2223        );
2224        let result_value =
2225            resolver.invoke(provider_id, provider_type, op, input_json, tenant, None)?;
2226        Ok(Some(NodeOutput::new(result_value)))
2227    }
2228
2229    fn validate_component(
2230        &self,
2231        ctx: &FlowContext<'_>,
2232        event: &NodeEvent<'_>,
2233        call: &ComponentCall,
2234    ) -> Result<()> {
2235        if self.validation.mode == ValidationMode::Off {
2236            return Ok(());
2237        }
2238        let mut metadata = JsonMap::new();
2239        metadata.insert("tenant_id".to_string(), json!(ctx.tenant));
2240        if let Some(id) = ctx.session_id {
2241            metadata.insert("session".to_string(), json!({ "id": id }));
2242        }
2243        let envelope = json!({
2244            "component_id": call.component_ref,
2245            "operation": call.operation,
2246            "input": call.input,
2247            "config": call.config,
2248            "metadata": Value::Object(metadata),
2249        });
2250        let issues = validate_component_envelope(&envelope);
2251        self.report_validation(ctx, event, "component", issues)
2252    }
2253
2254    fn validate_tool(
2255        &self,
2256        ctx: &FlowContext<'_>,
2257        event: &NodeEvent<'_>,
2258        provider_id: Option<&str>,
2259        provider_type: Option<&str>,
2260        operation: &str,
2261        input: &Value,
2262    ) -> Result<()> {
2263        if self.validation.mode == ValidationMode::Off {
2264            return Ok(());
2265        }
2266        let tool_id = provider_id.or(provider_type).unwrap_or("provider.invoke");
2267        let mut metadata = JsonMap::new();
2268        metadata.insert("tenant_id".to_string(), json!(ctx.tenant));
2269        if let Some(id) = ctx.session_id {
2270            metadata.insert("session".to_string(), json!({ "id": id }));
2271        }
2272        let envelope = json!({
2273            "tool_id": tool_id,
2274            "operation": operation,
2275            "input": input,
2276            "metadata": Value::Object(metadata),
2277        });
2278        let issues = validate_tool_envelope(&envelope);
2279        self.report_validation(ctx, event, "tool", issues)
2280    }
2281
2282    fn report_validation(
2283        &self,
2284        ctx: &FlowContext<'_>,
2285        event: &NodeEvent<'_>,
2286        kind: &str,
2287        issues: Vec<ValidationIssue>,
2288    ) -> Result<()> {
2289        if issues.is_empty() {
2290            return Ok(());
2291        }
2292        if let Some(observer) = ctx.observer {
2293            observer.on_validation(event, &issues);
2294        }
2295        match self.validation.mode {
2296            ValidationMode::Warn => {
2297                tracing::warn!(
2298                    tenant = ctx.tenant,
2299                    flow_id = ctx.flow_id,
2300                    node_id = event.node_id,
2301                    kind,
2302                    issues = ?issues,
2303                    "invocation envelope validation issues"
2304                );
2305                Ok(())
2306            }
2307            ValidationMode::Error => {
2308                tracing::error!(
2309                    tenant = ctx.tenant,
2310                    flow_id = ctx.flow_id,
2311                    node_id = event.node_id,
2312                    kind,
2313                    issues = ?issues,
2314                    "invocation envelope validation failed"
2315                );
2316                bail!("invocation_validation_failed");
2317            }
2318            ValidationMode::Off => Ok(()),
2319        }
2320    }
2321
2322    pub fn flows(&self) -> &[FlowDescriptor] {
2323        &self.flows
2324    }
2325
2326    /// Node ids declared by a flow, for callers that must tell a flow node
2327    /// apart from something else that shares the id space — a card asset, in
2328    /// the case of [`crate::runner::card_nav`].
2329    ///
2330    /// Loads the flow if it is not cached yet; an unloadable flow yields an
2331    /// empty list rather than an error, because the caller's question ("is
2332    /// this a node?") has a sound negative answer either way.
2333    pub async fn flow_node_ids(&self, pack_id: &str, flow_id: &str) -> Vec<String> {
2334        match self.get_or_load_flow(pack_id, flow_id).await {
2335            Ok(flow) => flow
2336                .nodes
2337                .keys()
2338                .map(|id| id.as_str().to_string())
2339                .collect(),
2340            Err(error) => {
2341                tracing::debug!(
2342                    pack_id,
2343                    flow_id,
2344                    error = %error,
2345                    "flow not loadable while listing node ids; treating as no nodes"
2346                );
2347                Vec::new()
2348            }
2349        }
2350    }
2351
2352    pub fn flow_by_key(&self, pack_id: &str, flow_id: &str) -> Option<&FlowDescriptor> {
2353        self.flows
2354            .iter()
2355            .find(|descriptor| descriptor.pack_id == pack_id && descriptor.id == flow_id)
2356    }
2357
2358    pub fn flow_by_type(&self, flow_type: &str) -> Option<&FlowDescriptor> {
2359        let mut matches = self
2360            .flows
2361            .iter()
2362            .filter(|descriptor| descriptor.flow_type == flow_type);
2363        let first = matches.next()?;
2364        if matches.next().is_some() {
2365            return None;
2366        }
2367        Some(first)
2368    }
2369
2370    /// Resolve a flow by type, considering only application entrypoint flows.
2371    ///
2372    /// Used to disambiguate an inbound provider event (routed by flow type,
2373    /// with no explicit `pack_id`/`flow_id`) when a pack registers one public
2374    /// entrypoint plus internal helper flows of the same type — the common
2375    /// "dispatcher + sub-flows" shape. Internal flows are only reachable via
2376    /// `flow.call`, so they must never win a type-only route.
2377    ///
2378    /// Flows owned by a messaging **provider** pack (its manifest declares a
2379    /// `messaging.*` provider) are also excluded: a provider ships its own
2380    /// ingress `main`/`default` flow that is plumbing for *that provider*, not
2381    /// the application. In a multi-provider bundle that flow would otherwise
2382    /// compete with the app's real entrypoint and make the route ambiguous.
2383    ///
2384    /// Returns `None` when zero or more than one *application* entry flow of the
2385    /// type exists (genuinely ambiguous — the caller must then require a
2386    /// `pack_id`).
2387    pub fn entry_flow_by_type(&self, flow_type: &str) -> Option<&FlowDescriptor> {
2388        let mut matches = self.flows.iter().filter(|descriptor| {
2389            descriptor.flow_type == flow_type
2390                && descriptor.entry
2391                && !self
2392                    .messaging_provider_pack_ids
2393                    .contains(&descriptor.pack_id)
2394        });
2395        let first = matches.next()?;
2396        if matches.next().is_some() {
2397            return None;
2398        }
2399        Some(first)
2400    }
2401
2402    pub fn flow_by_id(&self, flow_id: &str) -> Option<&FlowDescriptor> {
2403        let mut matches = self
2404            .flows
2405            .iter()
2406            .filter(|descriptor| descriptor.id == flow_id);
2407        let first = matches.next()?;
2408        if matches.next().is_some() {
2409            return None;
2410        }
2411        Some(first)
2412    }
2413}
2414
2415pub trait ExecutionObserver: Send + Sync {
2416    fn on_node_start(&self, event: &NodeEvent<'_>);
2417    fn on_node_end(&self, event: &NodeEvent<'_>, output: &Value);
2418    fn on_node_error(&self, event: &NodeEvent<'_>, error: &dyn StdError);
2419    fn on_validation(&self, _event: &NodeEvent<'_>, _issues: &[ValidationIssue]) {}
2420}
2421
2422pub struct NodeEvent<'a> {
2423    pub context: &'a FlowContext<'a>,
2424    pub node_id: &'a str,
2425    pub node: &'a HostNode,
2426    pub payload: &'a Value,
2427}
2428
2429/// Safety backstop for a CONVERSATIONAL `dw.agent` node's park-and-loop cycle.
2430///
2431/// This is NOT a UX limit — it exists purely so that a stuck or misbehaving
2432/// conversational agent (one that never emits `conversation_ended`) cannot
2433/// trap a flow at the same node forever. After this many parked turns at a
2434/// single conversational `dw.agent` node without a `conversation_ended`
2435/// termination, the flow force-advances to the node's successor using the
2436/// agent's last output. Deliberately a plain constant: no env var, no
2437/// per-agent config knob.
2438///
2439/// On this lane nothing in the engine reads it: `dispatch_node` has no
2440/// conversational `DwAgent` branch to enforce the cap. Its only referent is
2441/// the port-pending `conversational_dw_agent` test harness, so it carries that
2442/// harness's cfg — otherwise `-D warnings` flags it dead in any build that
2443/// turns `agentic-worker` on.
2444#[cfg(conversational_dw_agent_port)]
2445const MAX_PARK_TURNS: u32 = 100;
2446
2447/// Submitted fields waiting to be attached to the output of the node that
2448/// parked for them.
2449///
2450/// It cannot be attached at resume time: `drive_flow` re-dispatches
2451/// `snapshot.next_node` and `state.nodes.insert` REPLACES that node's stored
2452/// output, so anything written before the dispatch is destroyed.
2453#[derive(Clone, Debug, Serialize, Deserialize)]
2454struct PendingCardAnswers {
2455    node_id: String,
2456    answers: JsonMap<String, Value>,
2457}
2458
2459#[derive(Clone, Debug, Serialize, Deserialize)]
2460pub struct ExecutionState {
2461    #[serde(default)]
2462    entry: Value,
2463    #[serde(default)]
2464    input: Value,
2465    #[serde(default)]
2466    nodes: HashMap<String, NodeOutput>,
2467    #[serde(default)]
2468    egress: Vec<Value>,
2469    #[serde(default, skip_serializing_if = "Option::is_none")]
2470    last_output: Option<Value>,
2471    #[serde(default)]
2472    redirect_count: u32,
2473    #[serde(default)]
2474    vars: JsonMap<String, Value>,
2475    /// Per-node park-loop turn counter for conversational `dw.agent` nodes,
2476    /// keyed by node id. Mirrors `redirect_count`'s per-execution safety-cap
2477    /// pattern, but tracked per node since a flow may hold more than one
2478    /// conversational agent.
2479    #[serde(default)]
2480    park_turns: HashMap<String, u32>,
2481    /// Marks a node as awaiting an async `dw.agent` NATS dispatch response.
2482    /// Set on dispatch, checked-and-cleared on resume; mirrors `park_turns`'
2483    /// per-node, serde-defaulted, persisted-in-snapshot pattern.
2484    #[serde(default)]
2485    pending_agent_await: HashMap<String, ()>,
2486    /// Nodes that dispatched an approval request and are parked awaiting the
2487    /// decision. Set on dispatch, cleared when the response re-enters the node.
2488    /// Without it a stray inbound arriving mid-await would look like a first
2489    /// entry and re-dispatch — a duplicate approval request to the operator.
2490    #[serde(default)]
2491    pending_approval_await: HashMap<String, ()>,
2492    /// Submitted fields awaiting attachment to their node's output. In practice
2493    /// never survives a snapshot — it is consumed by the first dispatch after a
2494    /// resume — but is serde-defaulted like its neighbours.
2495    #[serde(default, skip_serializing_if = "Option::is_none")]
2496    pending_card_answers: Option<PendingCardAnswers>,
2497}
2498
2499impl ExecutionState {
2500    fn new(input: Value) -> Self {
2501        Self {
2502            entry: input.clone(),
2503            input,
2504            nodes: HashMap::new(),
2505            egress: Vec::new(),
2506            last_output: None,
2507            redirect_count: 0,
2508            vars: JsonMap::new(),
2509            park_turns: HashMap::new(),
2510            pending_agent_await: HashMap::new(),
2511            pending_approval_await: HashMap::new(),
2512            pending_card_answers: None,
2513        }
2514    }
2515
2516    /// Refresh `entry` from `input` if the snapshot was loaded without an
2517    /// entry value. Kept for backwards compatibility with snapshots
2518    /// persisted before the entry-refresh fix in `FlowEngine::resume`.
2519    #[allow(dead_code)]
2520    fn ensure_entry(&mut self) {
2521        if self.entry.is_null() {
2522            self.entry = self.input.clone();
2523        }
2524    }
2525
2526    fn context(&self) -> Value {
2527        let mut nodes = JsonMap::new();
2528        for (id, output) in &self.nodes {
2529            nodes.insert(
2530                id.clone(),
2531                json!({
2532                    "ok": output.ok,
2533                    "payload": output.payload.clone(),
2534                    "meta": output.meta.clone(),
2535                }),
2536            );
2537        }
2538        json!({
2539            "entry": self.entry.clone(),
2540            "input": self.input.clone(),
2541            "nodes": nodes,
2542            "redirect_count": self.redirect_count,
2543        })
2544    }
2545
2546    fn outputs_map(&self) -> JsonMap<String, Value> {
2547        let mut outputs = JsonMap::new();
2548        for (id, output) in &self.nodes {
2549            outputs.insert(id.clone(), node_output_view(&output.payload));
2550        }
2551        outputs
2552    }
2553    fn push_egress(&mut self, payload: Value) {
2554        self.egress.push(payload);
2555    }
2556
2557    fn replace_input(&mut self, input: Value) {
2558        self.input = input;
2559    }
2560
2561    fn clear_egress(&mut self) {
2562        self.egress.clear();
2563    }
2564
2565    fn redirect_count(&self) -> u32 {
2566        self.redirect_count
2567    }
2568
2569    fn increment_redirect_count(&mut self) {
2570        self.redirect_count = self.redirect_count.saturating_add(1);
2571    }
2572
2573    fn finalize_with(mut self, final_payload: Option<Value>) -> Value {
2574        if self.egress.is_empty() {
2575            return final_payload.unwrap_or(Value::Null);
2576        }
2577        let mut emitted = std::mem::take(&mut self.egress);
2578        if let Some(value) = final_payload {
2579            match value {
2580                Value::Null => {}
2581                Value::Array(items) => emitted.extend(items),
2582                // A terminal `emit.response` node BOTH pushes its payload to
2583                // egress (see `push_egress` in `dispatch_node`) AND returns that
2584                // same payload as its node output, which the `End` path passes
2585                // here as `final_payload`. Appending it unconditionally would
2586                // emit the response twice (the webchat "double card"). Skip the
2587                // re-append when it merely repeats the last emitted response;
2588                // a genuinely distinct terminal output is still appended.
2589                other if emitted.last() == Some(&other) => {}
2590                other => emitted.push(other),
2591            }
2592        }
2593        Value::Array(emitted)
2594    }
2595}
2596
2597#[derive(Clone, Debug, Serialize, Deserialize)]
2598struct NodeOutput {
2599    ok: bool,
2600    payload: Value,
2601    meta: Value,
2602}
2603
2604impl NodeOutput {
2605    fn new(payload: Value) -> Self {
2606        Self {
2607            ok: true,
2608            payload,
2609            meta: Value::Null,
2610        }
2611    }
2612
2613    /// `ok=false` output stashing error context in `meta.error`. Currently
2614    /// only used by `lift_first_node_error_from_nodes` tests — kept around so
2615    /// drive_flow can resume populating it once we have a hook for it.
2616    #[allow(dead_code)]
2617    fn with_error(node_id: &str, err: &(dyn std::error::Error + 'static)) -> Self {
2618        Self {
2619            ok: false,
2620            payload: Value::Null,
2621            meta: json!({
2622                "error": {
2623                    "kind": "flow_node_failed",
2624                    "message": err.to_string(),
2625                    "node_id": node_id,
2626                }
2627            }),
2628        }
2629    }
2630}
2631
2632struct DispatchOutcome {
2633    output: NodeOutput,
2634    control: NodeControl,
2635}
2636
2637impl DispatchOutcome {
2638    fn complete(output: NodeOutput) -> Self {
2639        Self {
2640            output,
2641            control: NodeControl::Continue,
2642        }
2643    }
2644
2645    fn wait(output: NodeOutput, reason: Option<String>) -> Self {
2646        Self {
2647            output,
2648            control: NodeControl::Wait { reason },
2649        }
2650    }
2651
2652    fn with_control(output: NodeOutput, control: NodeControl) -> Self {
2653        Self { output, control }
2654    }
2655}
2656
2657#[derive(Clone, Debug)]
2658enum NodeControl {
2659    Continue,
2660    Wait {
2661        reason: Option<String>,
2662    },
2663    Jump(JumpControl),
2664    Respond {
2665        text: Option<String>,
2666        card_cbor: Option<Vec<u8>>,
2667        needs_user: Option<bool>,
2668    },
2669}
2670
2671#[derive(Clone, Debug)]
2672struct JumpControl {
2673    flow: String,
2674    node: Option<String>,
2675    payload: Value,
2676    hints: Value,
2677    max_redirects: Option<u32>,
2678    reason: Option<String>,
2679}
2680
2681#[derive(Clone, Debug)]
2682struct JumpTarget {
2683    flow_id: String,
2684    flow: HostFlow,
2685    node_id: NodeId,
2686}
2687
2688impl NodeOutput {
2689    fn with_meta(payload: Value, meta: Value) -> Self {
2690        Self {
2691            ok: true,
2692            payload,
2693            meta,
2694        }
2695    }
2696
2697    /// A failure output (`ok == false`). `build_routing_context` derives the
2698    /// `error_event` from this, so a node with an `on_error`-family route lands
2699    /// on its failure branch; `node_output_view` exposes the `{errors}` envelope.
2700    fn errored(payload: Value) -> Self {
2701        Self {
2702            ok: false,
2703            payload,
2704            meta: Value::Null,
2705        }
2706    }
2707}
2708
2709fn component_exec_ctx(ctx: &FlowContext<'_>, node_id: &str) -> ComponentExecCtx {
2710    ComponentExecCtx {
2711        tenant: ComponentTenantCtx {
2712            tenant: ctx.tenant.to_string(),
2713            team: None,
2714            user: ctx.provider_id.map(str::to_string),
2715            trace_id: None,
2716            i18n_id: None,
2717            correlation_id: ctx.session_id.map(str::to_string),
2718            deadline_unix_ms: None,
2719            attempt: ctx.attempt,
2720            idempotency_key: ctx.session_id.map(str::to_string),
2721        },
2722        i18n_id: None,
2723        flow_id: ctx.flow_id.to_string(),
2724        node_id: Some(node_id.to_string()),
2725    }
2726}
2727
2728/// Surface a component-emitted `outcome` (from its output envelope) as node
2729/// metadata, so the routing context can match `event == "<outcome>"`. A
2730/// component opts in by adding `"outcome": "<name>"` to its output envelope
2731/// (alongside `ok`); `<name>` must be one of its declared
2732/// `ComponentDescribe.outcomes`. Returns `Value::Null` when the component does
2733/// not emit one — the engine then falls back to the `ok`-derived default
2734/// (`on_success`/`on_error`) in `build_routing_context`.
2735/// Adapt a raw component/node result `Value` into the typed node_io [`NodeOutput`]
2736/// (`greentic_types::node_io`). Native `{data}` / `{errors}` envelopes parse straight
2737/// through; legacy `{ok, error}` results are shimmed (`ok:false` + `error` → `Errors`,
2738/// otherwise → `Data{data: <value>}`) so existing packs keep routing unchanged.
2739fn to_node_output(value: &Value) -> greentic_types::node_io::NodeOutput {
2740    use greentic_types::node_io::{ErrorKind, NodeError, NodeOutput as NioOutput};
2741
2742    if let Value::Object(map) = value {
2743        // Native node_io envelopes carry a sole `errors` or `data` key and no legacy
2744        // `ok` flag — deserialize them directly so `kind`/`retryable`/etc. round-trip.
2745        let native_errors = map.contains_key("errors") && !map.contains_key("ok");
2746        let native_data = map.contains_key("data") && !map.contains_key("ok") && map.len() == 1;
2747        if (native_errors || native_data)
2748            && let Ok(parsed) = serde_json::from_value::<NioOutput>(value.clone())
2749        {
2750            return parsed;
2751        }
2752        // Legacy failure envelope `{ok:false, error:{code,message}}` → Errors.
2753        if let Some((code, message)) = component_error(value) {
2754            return NioOutput::failed(vec![NodeError {
2755                code,
2756                message,
2757                kind: ErrorKind::Internal,
2758                retryable: false,
2759                source: None,
2760                details: Value::Null,
2761            }]);
2762        }
2763    }
2764    // Default: a bare result (or `{ok:true, ...}`) is success data.
2765    NioOutput::ok(value.clone())
2766}
2767
2768/// Build the per-node template view exposed under `{{node.<id>...}}`. Object payloads
2769/// keep their fields at the top level (legacy `{{node.<id>.<field>}}`) and additionally
2770/// gain canonical node_io surfaces `data` (`{{node.<id>.data.<field>}}`) and `errors`
2771/// (`{{node.<id>.errors}}`). Non-object payloads are exposed verbatim, as before.
2772fn node_output_view(payload: &Value) -> Value {
2773    let nio = to_node_output(payload);
2774    let data = nio.data().cloned().unwrap_or(Value::Null);
2775    let errors = serde_json::to_value(nio.errors()).unwrap_or_else(|_| Value::Array(Vec::new()));
2776    match payload {
2777        Value::Object(map) => {
2778            let mut view = map.clone();
2779            view.insert("data".to_string(), data);
2780            view.insert("errors".to_string(), errors);
2781            Value::Object(view)
2782        }
2783        other => other.clone(),
2784    }
2785}
2786
2787/// The result of reading `answer_fields` from a card node's raw input mapping.
2788///
2789/// `Absent` and `Malformed` both fall back to the permissive (unfiltered)
2790/// path in [`attach_pending_card_answers`] — the contract is "absent means
2791/// this pack predates the allow-list", so failing permissively rather than
2792/// closed is deliberate for both. They are kept as separate variants (rather
2793/// than collapsed into one `None`) purely so the caller can log the
2794/// `Malformed` case: an ordinary pre-upgrade pack has no `answer_fields` key
2795/// at all and must stay quiet, but a *present-and-broken* value is a
2796/// designer-side bug reproducing this feature's original leak, and must not
2797/// be silently indistinguishable from the ordinary case in the logs.
2798enum DeclaredAnswerFields {
2799    /// The `answer_fields` key is not present at all. The ordinary case for
2800    /// every pack built before this feature — must not be logged.
2801    Absent,
2802    /// The key is present but is not a valid array of strings (wrong type,
2803    /// or an array containing a non-string entry — e.g. an unresolved
2804    /// template). Distinct from `Absent` so it can be surfaced.
2805    Malformed,
2806    /// The key is present and a valid (possibly empty) array of strings.
2807    Declared(Vec<String>),
2808}
2809
2810/// The card's own declared allow-list for `answers`, read from its raw input
2811/// mapping (`HostNode::payload_expr`) BEFORE template rendering — greentic-designer
2812/// writes the card's declared `Input.*` ids under this key
2813/// (`answer_fields: ["field_a", "field_b", ...]`) at composer-save time.
2814///
2815/// Reading `payload_expr` here is safe from upstream interference: it is
2816/// built once, from the pack's `Node.input.mapping`, in `HostNode`'s
2817/// `From<Node>` impl, and `flow_ir.nodes` is never mutated for the lifetime of
2818/// a `drive_flow` call (only `.get()` — see `engine.rs` near `drive_flow`'s
2819/// dispatch loop). The dispatch loop's own template render
2820/// (`let payload_template = node.payload_expr.clone();`, a few lines above the
2821/// call site) clones it into a separate value before rendering, so the render
2822/// never touches `node.payload_expr` itself. By the time this function runs —
2823/// after that node has already been dispatched — `node.payload_expr` is
2824/// byte-identical to what was loaded from the pack.
2825///
2826/// `Absent` means the key is absent — a pack built before this change, or any
2827/// path that never runs the designer injector. That is deliberately treated as
2828/// "no allow-list", not "empty allow-list": those packs, and the fixtures for
2829/// paths that skip the injector, would otherwise silently lose every submitted
2830/// answer. A present-but-malformed value (not an array, or containing a
2831/// non-string entry) is likewise treated as permissive (see
2832/// [`DeclaredAnswerFields`]), so a malformed pack fails open instead of
2833/// collapsing to zero answers — but unlike `Absent`, it is observable.
2834///
2835/// `Declared(vec![])` — a present but EMPTY array — means the card genuinely
2836/// declares no inputs; the caller must produce `{}`, not the unfiltered set.
2837fn declared_answer_fields(payload_expr: &Value) -> DeclaredAnswerFields {
2838    let Some(raw) = payload_expr.get("answer_fields") else {
2839        return DeclaredAnswerFields::Absent;
2840    };
2841    let Some(arr) = raw.as_array() else {
2842        return DeclaredAnswerFields::Malformed;
2843    };
2844    match arr
2845        .iter()
2846        .map(|entry| entry.as_str().map(str::to_string))
2847        .collect::<Option<Vec<String>>>()
2848    {
2849        Some(list) => DeclaredAnswerFields::Declared(list),
2850        None => DeclaredAnswerFields::Malformed,
2851    }
2852}
2853
2854/// Merge the pending submitted fields into `output` when it belongs to the node
2855/// that parked for them, then CONSUME the pending entry.
2856///
2857/// Consuming rather than reading is what stops a loop back through the same node
2858/// (without a resume) re-attaching stale answers.
2859///
2860/// When `node`'s input mapping carries `answer_fields` (see
2861/// [`declared_answer_fields`]), `answers` is intersected down to exactly those
2862/// keys — `submitted_fields` unions the whole submit envelope, which on the
2863/// `greentic-start` path includes transport identity (`tenant`, `session_id`,
2864/// `from`, ...) and pack config (routing keys, secret references, ...) that a
2865/// person never typed. Without a declared allow-list, `answers` stays exactly
2866/// what `submitted_fields` produced (today's behaviour, unchanged).
2867///
2868/// Called immediately before `state.nodes.insert`, and that position is
2869/// load-bearing — see `submitted_answers_survive_the_resume_redispatch_and_reach_a_later_node`.
2870/// Remove the routing `action` from a run's entry envelope.
2871///
2872/// Mirrors the two shapes [`build_routing_context`] reads it from: the
2873/// greentic-start demo path wraps the activity (`entry.input.metadata`), the
2874/// direct runner path does not (`entry.metadata`).
2875fn consume_routing_action(entry: &mut Value) {
2876    for pointer in ["/input/metadata", "/metadata"] {
2877        if let Some(Value::Object(meta)) = entry.pointer_mut(pointer) {
2878            meta.remove("action");
2879        }
2880    }
2881}
2882
2883/// Returns true when the pending submit belonged to `node_id` and was consumed
2884/// here — the caller clears the routing action once this node has routed on it.
2885fn attach_pending_card_answers(
2886    state: &mut ExecutionState,
2887    node_id: &str,
2888    node: &HostNode,
2889    output: &mut NodeOutput,
2890) -> bool {
2891    let is_target = state
2892        .pending_card_answers
2893        .as_ref()
2894        .is_some_and(|pending| pending.node_id == node_id);
2895    if !is_target {
2896        return false;
2897    }
2898    let Some(pending) = state.pending_card_answers.take() else {
2899        return false;
2900    };
2901    // No `ok` check on purpose: the answers are real whether or not the
2902    // re-render succeeded, and dropping them would let a transient render error
2903    // silently destroy what someone typed.
2904    let Some(map) = output.payload.as_object_mut() else {
2905        tracing::warn!(
2906            node_id = %node_id,
2907            "node output payload is not an object; submitted answers not attached"
2908        );
2909        return true;
2910    };
2911    if map.contains_key("answers") {
2912        tracing::warn!(
2913            node_id = %node_id,
2914            "node output already carries `answers`; overwriting with the submitted fields"
2915        );
2916    }
2917    let answers = match declared_answer_fields(&node.payload_expr) {
2918        DeclaredAnswerFields::Declared(allow_list) => {
2919            let allowed: std::collections::HashSet<&str> =
2920                allow_list.iter().map(String::as_str).collect();
2921            pending
2922                .answers
2923                .into_iter()
2924                .filter(|(key, _)| allowed.contains(key.as_str()))
2925                .collect()
2926        }
2927        DeclaredAnswerFields::Malformed => {
2928            // Distinct from `Absent` on purpose: an ordinary pre-upgrade pack
2929            // has no `answer_fields` key and must stay quiet, but a
2930            // present-and-broken value reproduces this feature's original
2931            // leak (the full unfiltered envelope, including any secret
2932            // references, ships as `answers`) and must be observable.
2933            tracing::warn!(
2934                node_id = %node_id,
2935                "answer_fields present but malformed; falling back to unfiltered answers"
2936            );
2937            pending.answers
2938        }
2939        DeclaredAnswerFields::Absent => pending.answers,
2940    };
2941    map.insert("answers".to_string(), Value::Object(answers));
2942    true
2943}
2944
2945/// Decide what to park for the node awaiting a submit, from the snapshot being
2946/// resumed and the fresh input that carries the submission.
2947///
2948/// Only a snapshot flagged `awaiting_submit` names the waiting node in
2949/// `next_node`; a `session.wait` snapshot names its SUCCESSOR, which has not
2950/// run yet, so attaching answers there would name the wrong node. Returns
2951/// `None` in that case, so nothing gets parked.
2952fn pending_from_snapshot(snapshot: &FlowSnapshot, input: &Value) -> Option<PendingCardAnswers> {
2953    if !snapshot.awaiting_submit {
2954        return None;
2955    }
2956    Some(PendingCardAnswers {
2957        node_id: snapshot.next_node.clone(),
2958        answers: submitted_fields(input),
2959    })
2960}
2961
2962fn outcome_meta(output: &Value) -> Value {
2963    match output.get("outcome").and_then(Value::as_str) {
2964        Some(outcome) => json!({ "outcome": outcome }),
2965        None => Value::Null,
2966    }
2967}
2968
2969fn component_error(value: &Value) -> Option<(String, String)> {
2970    let obj = value.as_object()?;
2971    let ok = obj.get("ok").and_then(Value::as_bool)?;
2972    if ok {
2973        return None;
2974    }
2975    let err = obj.get("error")?.as_object()?;
2976    let code = err
2977        .get("code")
2978        .and_then(Value::as_str)
2979        .unwrap_or("component_error");
2980    let message = err
2981        .get("message")
2982        .and_then(Value::as_str)
2983        .unwrap_or("component reported error");
2984    Some((code.to_string(), message.to_string()))
2985}
2986
2987/// MCP tool-error wire shape from greentic-mcp-generator's `tool_error_with_status`:
2988/// `{ "error": { "code", "message", "status" } }`. The component returned ok=true at
2989/// the WIT level (the HTTP failure was caught and serialized), so the regular
2990/// component_error path doesn't catch it.
2991fn mcp_tool_error(value: &Value) -> Option<(String, String)> {
2992    let obj = value.as_object()?;
2993    // Must be the error shape: no `result` field, just `error`.
2994    if obj.contains_key("result") {
2995        return None;
2996    }
2997    let err = obj.get("error")?.as_object()?;
2998    let code = err
2999        .get("code")
3000        .and_then(Value::as_str)
3001        .unwrap_or("tool_error");
3002    let raw_message = err
3003        .get("message")
3004        .and_then(Value::as_str)
3005        .unwrap_or("tool returned an error");
3006    let status = err.get("status").and_then(Value::as_u64);
3007    let message = match status {
3008        Some(s) => format!("{raw_message} (status {s})"),
3009        None => raw_message.to_string(),
3010    };
3011    Some((code.to_string(), message))
3012}
3013
3014fn extract_wait_reason(payload: &Value) -> Option<String> {
3015    match payload {
3016        Value::String(s) => Some(s.clone()),
3017        Value::Object(map) => map
3018            .get("reason")
3019            .and_then(Value::as_str)
3020            .map(|value| value.to_string()),
3021        _ => None,
3022    }
3023}
3024
3025fn component_dispatch_outcome(output: NodeOutput) -> Result<DispatchOutcome> {
3026    if let Some(control) = parse_component_control(&output.payload)? {
3027        return Ok(match control {
3028            NodeControl::Jump(jump) => {
3029                let adjusted = NodeOutput::with_meta(jump.payload.clone(), jump.hints.clone());
3030                DispatchOutcome::with_control(adjusted, NodeControl::Jump(jump))
3031            }
3032            NodeControl::Respond {
3033                text,
3034                card_cbor,
3035                needs_user,
3036            } => DispatchOutcome::with_control(
3037                output,
3038                NodeControl::Respond {
3039                    text,
3040                    card_cbor,
3041                    needs_user,
3042                },
3043            ),
3044            other => DispatchOutcome::with_control(output, other),
3045        });
3046    }
3047    Ok(DispatchOutcome::complete(output))
3048}
3049
3050fn parse_component_control(payload: &Value) -> Result<Option<NodeControl>> {
3051    let Value::Object(map) = payload else {
3052        return Ok(None);
3053    };
3054    let Some(control_value) = map.get("greentic_control") else {
3055        return Ok(None);
3056    };
3057    let control = control_value
3058        .as_object()
3059        .ok_or_else(|| anyhow!("jump_failed: greentic_control must be an object"))?;
3060    let action = control
3061        .get("action")
3062        .and_then(Value::as_str)
3063        .ok_or_else(|| anyhow!("jump_failed: greentic_control.action is required"))?;
3064    let version = control
3065        .get("v")
3066        .and_then(Value::as_u64)
3067        .ok_or_else(|| anyhow!("jump_failed: greentic_control.v is required"))?;
3068    if version != 1 {
3069        bail!("jump_failed: unsupported greentic_control.v={version}");
3070    }
3071
3072    match action {
3073        "jump" => {
3074            let flow = control
3075                .get("flow")
3076                .and_then(Value::as_str)
3077                .map(str::trim)
3078                .filter(|value| !value.is_empty())
3079                .ok_or_else(|| anyhow!("jump_failed: jump flow is required"))?
3080                .to_string();
3081            let node = control
3082                .get("node")
3083                .and_then(Value::as_str)
3084                .map(str::trim)
3085                .filter(|value| !value.is_empty())
3086                .map(str::to_string);
3087            let payload = control.get("payload").cloned().unwrap_or(Value::Null);
3088            let hints = control.get("hints").cloned().unwrap_or(Value::Null);
3089            let max_redirects = control
3090                .get("max_redirects")
3091                .and_then(Value::as_u64)
3092                .and_then(|value| u32::try_from(value).ok());
3093            let reason = control
3094                .get("reason")
3095                .and_then(Value::as_str)
3096                .map(str::to_string);
3097            Ok(Some(NodeControl::Jump(JumpControl {
3098                flow,
3099                node,
3100                payload,
3101                hints,
3102                max_redirects,
3103                reason,
3104            })))
3105        }
3106        "respond" => {
3107            let text = control
3108                .get("text")
3109                .and_then(Value::as_str)
3110                .map(str::to_string);
3111            let card_cbor = control
3112                .get("card_cbor")
3113                .and_then(Value::as_array)
3114                .map(|bytes| {
3115                    bytes
3116                        .iter()
3117                        .filter_map(Value::as_u64)
3118                        .filter_map(|value| u8::try_from(value).ok())
3119                        .collect::<Vec<_>>()
3120                });
3121            let needs_user = control.get("needs_user").and_then(Value::as_bool);
3122            Ok(Some(NodeControl::Respond {
3123                text,
3124                card_cbor,
3125                needs_user,
3126            }))
3127        }
3128        _ => Ok(None),
3129    }
3130}
3131
3132/// Make `in.input.*` resolve even when the flow entry IS the message (the
3133/// env/revision path passes `envelope.payload` — the message — directly), not
3134/// the legacy `{ "input": <message> }` wrapper. Packs are compiled against the
3135/// legacy shape and read `in.input.metadata.*` (e.g. a card button's dispatch
3136/// `flow_{{in.input.metadata.operation}}`); on the direct path `in.input` was
3137/// null, so metadata-based routing fell through to the entry/welcome flow.
3138///
3139/// When the entry is an object without an `input` key, alias `input` to the
3140/// entry itself so both `in.input.X` and `in.X` resolve. Entries that already
3141/// carry an explicit `input` (the legacy wrapper) are left untouched.
3142fn alias_input_to_entry(mut entry: Value) -> Value {
3143    if let Value::Object(map) = &mut entry
3144        && !map.contains_key("input")
3145    {
3146        let base = Value::Object(map.clone());
3147        map.insert("input".into(), base);
3148    }
3149    entry
3150}
3151
3152fn template_context(state: &ExecutionState, prev: Value) -> Value {
3153    let entry = if state.entry.is_null() {
3154        Value::Object(JsonMap::new())
3155    } else {
3156        alias_input_to_entry(state.entry.clone())
3157    };
3158    let mut ctx = JsonMap::new();
3159    ctx.insert("entry".into(), entry.clone());
3160    ctx.insert("in".into(), entry); // alias for entry - used in flow templates
3161    ctx.insert("prev".into(), prev);
3162    ctx.insert("node".into(), Value::Object(state.outputs_map()));
3163    ctx.insert("state".into(), state.context());
3164    ctx.insert("vars".into(), Value::Object(state.vars.clone()));
3165    Value::Object(ctx)
3166}
3167
3168impl From<Flow> for HostFlow {
3169    fn from(value: Flow) -> Self {
3170        let mut nodes = IndexMap::new();
3171        for (id, node) in value.nodes {
3172            nodes.insert(id.clone(), HostNode::from(node));
3173        }
3174        let start = value
3175            .entrypoints
3176            .get("default")
3177            .and_then(Value::as_str)
3178            .and_then(|id| NodeId::from_str(id).ok())
3179            .or_else(|| nodes.keys().next().cloned());
3180        // Extract flow-level slot_schema from metadata.extra (Phase D).
3181        // The producer side (greentic-flow compile_flow) stores it under
3182        // "greentic.slot_schema" when the FlowDoc has a `slot_schema` field.
3183        let slot_schema = value
3184            .metadata
3185            .extra
3186            .get(SLOT_SCHEMA_METADATA_KEY)
3187            .filter(|v| !v.is_null())
3188            .cloned();
3189        let vars_init = value
3190            .metadata
3191            .extra
3192            .get("vars_init")
3193            .and_then(|v| v.as_object())
3194            .map(|decls| {
3195                decls
3196                    .iter()
3197                    .filter_map(|(name, decl)| {
3198                        decl.get("default").map(|d| (name.clone(), d.clone()))
3199                    })
3200                    .collect::<JsonMap<String, Value>>()
3201            })
3202            .unwrap_or_default();
3203        Self {
3204            id: value.id.as_str().to_string(),
3205            start,
3206            nodes,
3207            slot_schema,
3208            vars_init,
3209        }
3210    }
3211}
3212
3213impl From<Node> for HostNode {
3214    fn from(node: Node) -> Self {
3215        let full_ref = node.component.id.as_str().to_string();
3216        let operation_in_mapping = extract_operation_from_mapping(&node.input.mapping);
3217        // A dotted component id is only a packed "<component>.<operation>" string
3218        // when the operation isn't carried structurally elsewhere. greentic-pack
3219        // resolves a component node to a bare component symbol (e.g.
3220        // `ai.greentic.component-templates`) and keeps the operation in the input
3221        // mapping, so splitting on the last dot here would corrupt the reference
3222        // (→ `ai.greentic`, "not found in pack"). Prefer the structured operation —
3223        // from `component.operation` or the input mapping — and only fall back to
3224        // the legacy single-ID split when neither is present.
3225        let is_builtin = full_ref.starts_with("component.exec")
3226            || full_ref.starts_with("flow.")
3227            || full_ref.starts_with("emit.")
3228            || full_ref.starts_with("session.")
3229            || full_ref.starts_with("provider.")
3230            || full_ref.starts_with("dw.")
3231            || full_ref.starts_with("sorla.")
3232            || full_ref.starts_with("operala.")
3233            || full_ref.starts_with("agentic.")
3234            || full_ref.starts_with("var.")
3235            // `mcp:<server>/<tool>` is a self-contained ref; never dot-split it
3236            // into a `component.operation` pair.
3237            || full_ref.starts_with("mcp:");
3238        let (component_ref, raw_operation) =
3239            if node.component.operation.is_some() || is_builtin || operation_in_mapping.is_some() {
3240                (full_ref, node.component.operation.clone())
3241            } else if let Some(dot) = full_ref.rfind('.') {
3242                let comp = full_ref[..dot].to_string();
3243                let op = full_ref[dot + 1..].to_string();
3244                (comp, Some(op))
3245            } else {
3246                (full_ref, None)
3247            };
3248        let operation_is_component_exec = raw_operation.as_deref() == Some("component.exec");
3249        let operation_is_emit = raw_operation
3250            .as_deref()
3251            .map(|op| op.starts_with("emit."))
3252            .unwrap_or(false);
3253        let is_component_exec = component_ref == "component.exec" || operation_is_component_exec;
3254
3255        let kind = if is_component_exec {
3256            let target = if component_ref == "component.exec" {
3257                if let Some(op) = raw_operation
3258                    .as_deref()
3259                    .filter(|op| op.starts_with("emit."))
3260                {
3261                    op.to_string()
3262                } else {
3263                    extract_target_component(&node.input.mapping)
3264                        .unwrap_or_else(|| "component.exec".to_string())
3265                }
3266            } else {
3267                extract_target_component(&node.input.mapping)
3268                    .unwrap_or_else(|| component_ref.clone())
3269            };
3270            if target.starts_with("emit.") {
3271                NodeKind::BuiltinEmit {
3272                    kind: emit_kind_from_ref(&target),
3273                }
3274            } else {
3275                NodeKind::Exec {
3276                    target_component: target,
3277                }
3278            }
3279        } else if operation_is_emit {
3280            NodeKind::BuiltinEmit {
3281                kind: emit_kind_from_ref(raw_operation.as_deref().unwrap_or("emit.log")),
3282            }
3283        } else {
3284            match component_ref.as_str() {
3285                "flow.call" => NodeKind::FlowCall,
3286                "flow.goto" => NodeKind::FlowGoto,
3287                "provider.invoke" => NodeKind::ProviderInvoke,
3288                "session.wait" => NodeKind::Wait,
3289                "state.get" => NodeKind::BuiltinStateGet,
3290                "state.set" => NodeKind::BuiltinStateSet,
3291                "var.set" => {
3292                    let name = node
3293                        .input
3294                        .mapping
3295                        .get("name")
3296                        .and_then(Value::as_str)
3297                        .unwrap_or("")
3298                        .to_string();
3299                    let value = node
3300                        .input
3301                        .mapping
3302                        .get("value")
3303                        .cloned()
3304                        .unwrap_or(Value::Null);
3305                    NodeKind::VarSet { name, value }
3306                }
3307                "dw.agent" => NodeKind::DwAgent {
3308                    agent_id: raw_operation.clone().unwrap_or_default(),
3309                },
3310                "dw.agent_graph" => NodeKind::DwAgentGraph {
3311                    graph_id: raw_operation.clone().unwrap_or_default(),
3312                },
3313                "sorla.call" => NodeKind::SorlaCall {
3314                    target: raw_operation.clone().unwrap_or_default(),
3315                },
3316                "operala.call" => NodeKind::OperalaCall {
3317                    target: raw_operation.clone().unwrap_or_default(),
3318                },
3319                "agentic.call" => NodeKind::AgenticCall {
3320                    target: raw_operation.clone().unwrap_or_default(),
3321                },
3322                "telco-x.call" => NodeKind::TelcoXCall {
3323                    target: raw_operation.clone().unwrap_or_default(),
3324                },
3325                "approval.call" => NodeKind::ApprovalCall {
3326                    target: raw_operation.clone().unwrap_or_default(),
3327                },
3328                comp if comp.starts_with("emit.") => NodeKind::BuiltinEmit {
3329                    kind: emit_kind_from_ref(comp),
3330                },
3331                // LOCKED ENCODING v2 (shared with greentic-flow + designer):
3332                // `component == "mcp"` (a valid `ComponentId`) with `server` and
3333                // `tool` carried in the node PAYLOAD/config:
3334                //   payload = { server, tool, arguments, output? }.
3335                // The payload is the source of truth. A legacy
3336                // `operation = "<server>/<tool>"` (or an `mcp:<server>/<tool>`
3337                // component ref) is honored only as a defensive fallback when the
3338                // payload lacks the fields, so older packs keep loading.
3339                "mcp" => mcp_node_kind(&node.input.mapping, raw_operation.as_deref()),
3340                // `mcp:<server>/<tool>` carried verbatim in `component.id`.
3341                // `greentic_types::ComponentId` rejects `:`/`/`, so this form
3342                // only survives when the node-type string bypasses ComponentId
3343                // validation; it is still recognized as a fallback for older
3344                // packs.
3345                comp if comp.starts_with("mcp:") => mcp_node_kind(&node.input.mapping, Some(comp)),
3346                other => NodeKind::PackComponent {
3347                    component_ref: other.to_string(),
3348                },
3349            }
3350        };
3351        let component_label = match &kind {
3352            NodeKind::Exec { .. } => "component.exec".to_string(),
3353            NodeKind::PackComponent { component_ref } => component_ref.clone(),
3354            NodeKind::ProviderInvoke => "provider.invoke".to_string(),
3355            NodeKind::FlowCall => "flow.call".to_string(),
3356            NodeKind::FlowGoto => "flow.goto".to_string(),
3357            NodeKind::BuiltinEmit { kind } => emit_ref_from_kind(kind),
3358            NodeKind::BuiltinStateGet => "state.get".to_string(),
3359            NodeKind::BuiltinStateSet => "state.set".to_string(),
3360            NodeKind::VarSet { .. } => "var.set".to_string(),
3361            NodeKind::Wait => "session.wait".to_string(),
3362            NodeKind::DwAgent { .. } => "dw.agent".to_string(),
3363            NodeKind::DwAgentGraph { .. } => "dw.agent_graph".to_string(),
3364            NodeKind::SorlaCall { .. } => "sorla.call".to_string(),
3365            NodeKind::OperalaCall { .. } => "operala.call".to_string(),
3366            NodeKind::AgenticCall { .. } => "agentic.call".to_string(),
3367            NodeKind::TelcoXCall { .. } => "telco-x.call".to_string(),
3368            NodeKind::ApprovalCall { .. } => "approval.call".to_string(),
3369            NodeKind::Mcp { server_id, tool } => format!("mcp:{server_id}/{tool}"),
3370        };
3371        let operation_name = if is_component_exec && operation_is_component_exec {
3372            None
3373        } else {
3374            raw_operation.clone()
3375        };
3376        // Extract per-node output bindings before the mapping is consumed by
3377        // `payload_expr`. Stored as raw (unrendered) templates so they can be
3378        // applied after the node runs, using the node's own output as `prev`.
3379        let vars_out = node
3380            .input
3381            .mapping
3382            .get("vars_out")
3383            .and_then(Value::as_object)
3384            .cloned();
3385        let payload_expr = match kind {
3386            NodeKind::BuiltinEmit { .. } => extract_emit_payload(&node.input.mapping),
3387            // VarSet dispatch re-reads name/value from NodeKind::VarSet directly;
3388            // the payload render is redundant and must not be forwarded as node input.
3389            NodeKind::VarSet { .. } => Value::Null,
3390            _ => {
3391                // Strip the internal `vars_out` meta-key so it is never
3392                // forwarded as an input field to wasm components or other
3393                // non-emit node kinds (which may have strict schemas).
3394                let mut mapping = node.input.mapping.clone();
3395                if let Some(obj) = mapping.as_object_mut() {
3396                    obj.remove("vars_out");
3397                }
3398                mapping
3399            }
3400        };
3401        Self {
3402            kind,
3403            component: component_label,
3404            component_id: if is_component_exec {
3405                "component.exec".to_string()
3406            } else {
3407                component_ref
3408            },
3409            operation_name,
3410            operation_in_mapping,
3411            payload_expr,
3412            routing: node.routing,
3413            vars_out,
3414        }
3415    }
3416}
3417
3418/// Classify a `component == "mcp"` node into [`NodeKind::Mcp`].
3419///
3420/// LOCKED ENCODING v2: `server` and `tool` are read from the node
3421/// `payload`/config object (the source of truth). When the payload omits them,
3422/// a legacy `operation = "<server>/<tool>"` string (or an
3423/// `mcp:<server>/<tool>` component ref) is parsed as a defensive fallback for
3424/// older packs.
3425///
3426/// When neither source yields a usable `(server, tool)` pair the node falls
3427/// back to an ordinary [`NodeKind::PackComponent`], so a malformed MCP node
3428/// surfaces as a normal unknown-component error at run time rather than
3429/// panicking at load. Flow loading stays total.
3430fn mcp_node_kind(payload: &Value, legacy_ref: Option<&str>) -> NodeKind {
3431    if let Some((server_id, tool)) = crate::runner::mcp_node::server_tool_from_payload(payload) {
3432        return NodeKind::Mcp { server_id, tool };
3433    }
3434    if let Some((server_id, tool)) = legacy_ref.and_then(parse_legacy_mcp_ref) {
3435        return NodeKind::Mcp { server_id, tool };
3436    }
3437    NodeKind::PackComponent {
3438        component_ref: "mcp".to_string(),
3439    }
3440}
3441
3442/// Parse a legacy MCP server/tool reference, accepting either the bare
3443/// `"<server>/<tool>"` operation form or the prefixed `mcp:<server>/<tool>`
3444/// component-ref form. Returns `None` when either part is missing or empty.
3445fn parse_legacy_mcp_ref(reference: &str) -> Option<(String, String)> {
3446    let rest = reference.strip_prefix("mcp:").unwrap_or(reference);
3447    let (server, tool) = rest.split_once('/')?;
3448    if server.is_empty() || tool.is_empty() {
3449        return None;
3450    }
3451    Some((server.to_string(), tool.to_string()))
3452}
3453
3454fn extract_target_component(payload: &Value) -> Option<String> {
3455    match payload {
3456        Value::Object(map) => map
3457            .get("component")
3458            .or_else(|| map.get("component_ref"))
3459            .and_then(Value::as_str)
3460            .map(|s| s.to_string()),
3461        _ => None,
3462    }
3463}
3464
3465fn extract_operation_from_mapping(payload: &Value) -> Option<String> {
3466    match payload {
3467        Value::Object(map) => map
3468            .get("operation")
3469            .or_else(|| map.get("op"))
3470            .and_then(Value::as_str)
3471            .map(str::trim)
3472            .filter(|value| !value.is_empty())
3473            .map(|value| value.to_string()),
3474        _ => None,
3475    }
3476}
3477
3478fn extract_emit_payload(payload: &Value) -> Value {
3479    if let Value::Object(map) = payload {
3480        if let Some(input) = map.get("input") {
3481            return input.clone();
3482        }
3483        if let Some(inner) = map.get("payload") {
3484            return inner.clone();
3485        }
3486    }
3487    payload.clone()
3488}
3489
3490fn split_operation_payload(payload: Value) -> (Value, Value) {
3491    if let Value::Object(mut map) = payload.clone()
3492        && map.contains_key("input")
3493    {
3494        let input = map.remove("input").unwrap_or(Value::Null);
3495        let config = map.remove("config").unwrap_or(Value::Null);
3496        let legacy_only = map.keys().all(|key| {
3497            matches!(
3498                key.as_str(),
3499                "operation" | "op" | "component" | "component_ref"
3500            )
3501        });
3502        if legacy_only {
3503            return (input, config);
3504        }
3505    }
3506    (payload, Value::Null)
3507}
3508
3509fn resolve_component_operation(
3510    node_id: &str,
3511    component_label: &str,
3512    payload_operation: Option<String>,
3513    operation_override: Option<&str>,
3514    operation_in_mapping: Option<&str>,
3515) -> Result<String> {
3516    if let Some(op) = operation_override
3517        .map(str::trim)
3518        .filter(|value| !value.is_empty())
3519    {
3520        return Ok(op.to_string());
3521    }
3522
3523    if let Some(op) = payload_operation
3524        .as_deref()
3525        .map(str::trim)
3526        .filter(|value| !value.is_empty())
3527    {
3528        return Ok(op.to_string());
3529    }
3530
3531    let mut message = format!(
3532        "missing operation for node `{}` (component `{}`); expected node.component.operation to be set",
3533        node_id, component_label,
3534    );
3535    if let Some(found) = operation_in_mapping {
3536        message.push_str(&format!(
3537            ". Found operation in input.mapping (`{}`) but this is not used; pack compiler must preserve node.component.operation.",
3538            found
3539        ));
3540    }
3541    bail!(message);
3542}
3543
3544fn emit_kind_from_ref(component_ref: &str) -> EmitKind {
3545    match component_ref {
3546        "emit.log" => EmitKind::Log,
3547        "emit.response" => EmitKind::Response,
3548        other => EmitKind::Other(other.to_string()),
3549    }
3550}
3551
3552fn emit_ref_from_kind(kind: &EmitKind) -> String {
3553    match kind {
3554        EmitKind::Log => "emit.log".to_string(),
3555        EmitKind::Response => "emit.response".to_string(),
3556        EmitKind::Other(other) => other.clone(),
3557    }
3558}
3559
3560/// Returns `true` when `input` looks like an Adaptive Card invocation
3561/// (contains `card_source` or `card_spec` at the top level).
3562fn is_card_invocation(input: &Value) -> bool {
3563    if let Value::Object(map) = input {
3564        return map.contains_key("card_source") || map.contains_key("card_spec");
3565    }
3566    false
3567}
3568
3569/// When the node config declares adaptive-card defaults (`default_card_asset`,
3570/// `default_card_inline`, or `default_source`) but the runtime invocation has
3571/// no `card_source`/`card_spec` yet, lift those defaults into the invocation.
3572/// This produces a schema-valid invocation envelope so the component does not
3573/// fall back to its generic "Welcome" placeholder.
3574///
3575/// Adaptive-card defaults can arrive in either of two places depending on how
3576/// the pack was compiled:
3577/// - top-level `call.config` (post `split_operation_payload`)
3578/// - nested `call.input.config` (when the node mapping kept the
3579///   `{component, config}` shape and `split_operation_payload` left it intact)
3580fn promote_card_config_to_invocation(input: &mut Value, config: &Value) {
3581    if is_card_invocation(input) {
3582        return;
3583    }
3584
3585    let cfg_map = card_defaults_source(input, config);
3586    let Some(cfg) = cfg_map else { return };
3587
3588    let default_asset = cfg
3589        .get("default_card_asset")
3590        .and_then(Value::as_str)
3591        .map(str::trim)
3592        .filter(|value| !value.is_empty())
3593        .map(str::to_string);
3594    let default_inline = cfg
3595        .get("default_card_inline")
3596        .filter(|value| value.is_object() || value.is_array())
3597        .cloned();
3598    let default_source = cfg
3599        .get("default_source")
3600        .and_then(Value::as_str)
3601        .map(str::trim)
3602        .filter(|value| !value.is_empty())
3603        .map(str::to_lowercase);
3604
3605    if default_asset.is_none() && default_inline.is_none() && default_source.is_none() {
3606        return;
3607    }
3608
3609    let card_source = default_source.unwrap_or_else(|| {
3610        if default_inline.is_some() {
3611            "inline".to_string()
3612        } else {
3613            "asset".to_string()
3614        }
3615    });
3616
3617    let mut card_spec = serde_json::Map::new();
3618    match card_source.as_str() {
3619        "asset" => {
3620            if let Some(path) = default_asset {
3621                card_spec.insert("asset_path".into(), Value::String(path));
3622            }
3623        }
3624        "inline" => {
3625            if let Some(inline) = default_inline {
3626                card_spec.insert("inline_json".into(), inline);
3627            }
3628        }
3629        _ => {}
3630    }
3631
3632    if !matches!(input, Value::Object(_)) {
3633        *input = Value::Object(serde_json::Map::new());
3634    }
3635    if let Value::Object(map) = input {
3636        map.insert("card_source".into(), Value::String(card_source));
3637        map.insert("card_spec".into(), Value::Object(card_spec));
3638    }
3639}
3640
3641/// Locate the adaptive-card defaults config object, preferring the top-level
3642/// `call.config` when present, then falling back to a nested `input.config`
3643/// (the shape produced when `split_operation_payload` leaves the mapping
3644/// intact).
3645fn card_defaults_source<'a>(
3646    input: &'a Value,
3647    config: &'a Value,
3648) -> Option<&'a serde_json::Map<String, Value>> {
3649    if let Value::Object(map) = config {
3650        return Some(map);
3651    }
3652    if let Value::Object(map) = input
3653        && let Some(Value::Object(nested)) = map.get("config")
3654    {
3655        return Some(nested);
3656    }
3657    None
3658}
3659
3660fn inject_card_locale(payload: &mut Value, entry: &Value) {
3661    if !is_card_invocation(payload) {
3662        return;
3663    }
3664    let Value::Object(map) = payload else { return };
3665    if map.contains_key("locale") {
3666        return;
3667    }
3668    let locale = entry
3669        .pointer("/input/metadata/locale")
3670        .or_else(|| entry.pointer("/metadata/locale"))
3671        .and_then(Value::as_str);
3672    if let Some(locale) = locale {
3673        map.insert("locale".into(), Value::String(locale.to_string()));
3674    }
3675}
3676
3677/// Select an adaptive-card node's card from a `routeToCardId`/`toCardId`/
3678/// `nextCardId` carried on the flow entry (a card button's submit), so the flow
3679/// renders the routed card instead of the node's `default_card_asset`.
3680///
3681/// This keeps card navigation *inside* the flow — the runner sets the node's
3682/// `card_spec.asset_path` from the routing key, which makes
3683/// [`promote_card_config_to_invocation`] treat the input as an explicit card
3684/// invocation (so it does not overwrite it with the default), and
3685/// [`resolve_card_assets`] then inlines the routed card. It replaces the legacy
3686/// host-side "read the card from the pack and bypass the flow" shortcut.
3687///
3688/// No-ops (leaving the node's default card) when: the node is not the
3689/// adaptive-card component, the payload already carries an explicit
3690/// `card_source`/`card_spec` (author-set), or no routing key is present.
3691fn inject_card_route(payload: &mut Value, entry: &Value, node: &HostNode) {
3692    let is_adaptive_card =
3693        node.component_id().contains("adaptive-card") || node.component.contains("adaptive-card");
3694    if !is_adaptive_card || is_card_invocation(payload) {
3695        return;
3696    }
3697    let route = entry
3698        .pointer("/input/metadata/routeToCardId")
3699        .or_else(|| entry.pointer("/metadata/routeToCardId"))
3700        .or_else(|| entry.pointer("/input/metadata/toCardId"))
3701        .or_else(|| entry.pointer("/metadata/toCardId"))
3702        .or_else(|| entry.pointer("/input/metadata/nextCardId"))
3703        .or_else(|| entry.pointer("/metadata/nextCardId"))
3704        .and_then(Value::as_str)
3705        .map(str::trim)
3706        .filter(|value| !value.is_empty());
3707    let Some(route) = route else {
3708        return;
3709    };
3710
3711    if !matches!(payload, Value::Object(_)) {
3712        *payload = Value::Object(serde_json::Map::new());
3713    }
3714    if let Value::Object(map) = payload {
3715        let mut card_spec = serde_json::Map::new();
3716        card_spec.insert(
3717            "asset_path".into(),
3718            Value::String(format!("assets/cards/{route}.json")),
3719        );
3720        map.insert("card_source".into(), Value::String("asset".into()));
3721        map.insert("card_spec".into(), Value::Object(card_spec));
3722        tracing::debug!(route_to_card = %route, "inject_card_route: routed card asset selected");
3723    }
3724}
3725
3726/// Inject flow-level `slot_schema` as `slot_definitions` into the
3727/// slot-extractor component's input value. Skips injection when the input
3728/// already contains an explicit `slot_definitions` key (back-compat with
3729/// M2.4 NDA demo inline definitions). When the input is `Null`, promotes it
3730/// to an empty object first.
3731fn inject_slot_definitions(input: &mut Value, slot_schema: &Value, flow_id: &str, node_id: &str) {
3732    if input.is_null() {
3733        *input = Value::Object(serde_json::Map::new());
3734    }
3735    let Some(map) = input.as_object_mut() else {
3736        tracing::warn!(
3737            flow_id,
3738            node_id,
3739            "slot-extractor input is not an object; cannot inject slot_definitions"
3740        );
3741        return;
3742    };
3743    if map.contains_key("slot_definitions") {
3744        return;
3745    }
3746    let slot_count = slot_schema.as_array().map_or(0, Vec::len);
3747    tracing::debug!(
3748        flow_id,
3749        slot_count,
3750        "injecting flow-level slot_schema as slot_definitions into slot-extractor input"
3751    );
3752    map.insert("slot_definitions".to_string(), slot_schema.clone());
3753}
3754
3755/// Pre-resolve `card_source: "asset"` entries by reading the referenced JSON
3756/// file from the pack's assets directory and converting to
3757/// `card_source: "inline"` with `inline_json` populated.
3758///
3759/// This handles both top-level card fields and the nested `call.payload`
3760/// structure emitted by cards2pack.
3761fn resolve_card_assets(input: &mut Value, pack: &crate::pack::PackRuntime) {
3762    resolve_card_spec_asset(input, pack);
3763
3764    // Also resolve inside `call.payload` (cards2pack duplicates the card
3765    // invocation there).
3766    if let Value::Object(map) = input
3767        && let Some(Value::Object(call)) = map.get_mut("call")
3768        && let Some(payload) = call.get_mut("payload")
3769    {
3770        resolve_card_spec_asset(payload, pack);
3771    }
3772}
3773
3774/// Resolve a single card_spec asset_path → inline_json.
3775fn resolve_card_spec_asset(value: &mut Value, pack: &crate::pack::PackRuntime) {
3776    let Value::Object(map) = value else { return };
3777
3778    let is_asset = map
3779        .get("card_source")
3780        .and_then(Value::as_str)
3781        .map(|s| s.eq_ignore_ascii_case("asset"))
3782        .unwrap_or(false);
3783    if !is_asset {
3784        return;
3785    }
3786
3787    let asset_path = map
3788        .get("card_spec")
3789        .and_then(|spec| spec.get("asset_path"))
3790        .and_then(Value::as_str)
3791        .map(str::to_string);
3792
3793    let Some(asset_path) = asset_path else { return };
3794
3795    match pack.read_asset(&asset_path) {
3796        Ok(bytes) => {
3797            let card_json: Value = match serde_json::from_slice(&bytes) {
3798                Ok(v) => v,
3799                Err(err) => {
3800                    tracing::warn!(
3801                        asset_path,
3802                        %err,
3803                        "failed to parse card asset as JSON; leaving as asset reference"
3804                    );
3805                    return;
3806                }
3807            };
3808            tracing::debug!(asset_path, "pre-resolved card asset to inline_json");
3809            map.insert("card_source".into(), Value::String("inline".into()));
3810            if let Some(Value::Object(spec)) = map.get_mut("card_spec") {
3811                spec.insert("inline_json".into(), card_json);
3812                spec.remove("asset_path");
3813            }
3814        }
3815        Err(err) => {
3816            tracing::warn!(
3817                asset_path,
3818                %err,
3819                "card asset not found in pack; leaving as asset reference"
3820            );
3821        }
3822    }
3823
3824    // Pre-resolve i18n bundle: the WASM component cannot read pack assets
3825    // directly (no host resolver registered), so inline the i18n JSON into
3826    // the invocation under `card_spec.i18n_inline`. Defense-in-depth: when
3827    // the card omits an explicit `i18n_bundle_path` we still try the
3828    // conventional `assets/i18n/` location so cards that rely on
3829    // auto-generated i18n keys (e.g. cards2pack output) keep working.
3830    let configured_bundle_path = map
3831        .get("card_spec")
3832        .and_then(|spec| spec.get("i18n_bundle_path"))
3833        .and_then(Value::as_str)
3834        .map(|s| s.trim().trim_end_matches('/').to_string())
3835        .filter(|s| !s.is_empty());
3836
3837    let bundle_path = configured_bundle_path
3838        .clone()
3839        .unwrap_or_else(|| "assets/i18n".to_string());
3840
3841    let i18n_entries = load_i18n_bundle_entries(&bundle_path, |path| pack.read_asset(path));
3842
3843    if !i18n_entries.is_empty() {
3844        let locale_keys: Vec<_> = i18n_entries.keys().cloned().collect();
3845        if let Some(Value::Object(spec)) = map.get_mut("card_spec") {
3846            spec.insert("i18n_inline".into(), Value::Object(i18n_entries));
3847            if configured_bundle_path.is_some() {
3848                tracing::info!(%bundle_path, ?locale_keys, "pre-resolved i18n bundle into card_spec.i18n_inline");
3849            } else {
3850                tracing::info!(%bundle_path, ?locale_keys, "auto-discovered i18n bundle and inlined into card_spec.i18n_inline");
3851            }
3852        }
3853    }
3854}
3855
3856fn load_i18n_bundle_entries<F>(bundle_path: &str, mut read_asset: F) -> JsonMap<String, Value>
3857where
3858    F: FnMut(&str) -> Result<Vec<u8>>,
3859{
3860    let mut i18n_entries = JsonMap::new();
3861
3862    if bundle_path.ends_with(".json") {
3863        if let Ok(bytes) = read_asset(bundle_path)
3864            && let Ok(Value::Object(entries)) = serde_json::from_slice::<Value>(&bytes)
3865        {
3866            i18n_entries.insert("en".to_string(), Value::Object(entries));
3867        }
3868        return i18n_entries;
3869    }
3870
3871    let manifest_path = format!("{bundle_path}/_manifest.json");
3872    let locale_codes: Vec<String> = read_asset(&manifest_path)
3873        .ok()
3874        .and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
3875        .and_then(|value| {
3876            let locales = value
3877                .get("locales")
3878                .and_then(Value::as_array)
3879                .cloned()
3880                .or_else(|| value.as_array().cloned());
3881            locales.map(|items| {
3882                items
3883                    .iter()
3884                    .filter_map(Value::as_str)
3885                    .map(String::from)
3886                    .collect()
3887            })
3888        })
3889        .unwrap_or_default();
3890
3891    tracing::info!(%bundle_path, ?locale_codes, "i18n manifest discovered locales");
3892
3893    for locale in &locale_codes {
3894        let candidate = format!("{bundle_path}/{locale}.json");
3895        if let Ok(bytes) = read_asset(&candidate)
3896            && let Ok(Value::Object(entries)) = serde_json::from_slice::<Value>(&bytes)
3897        {
3898            i18n_entries.insert(locale.clone(), Value::Object(entries));
3899        }
3900    }
3901    if !i18n_entries.contains_key("en") {
3902        let en_path = format!("{bundle_path}/en.json");
3903        if let Ok(bytes) = read_asset(&en_path)
3904            && let Ok(Value::Object(entries)) = serde_json::from_slice::<Value>(&bytes)
3905        {
3906            i18n_entries.insert("en".to_string(), Value::Object(entries));
3907        }
3908    }
3909
3910    i18n_entries
3911}
3912
3913/// Outcome of `evaluate_custom_routing` for a node's `Routing::Custom` array.
3914///
3915/// `Next` advances the flow to the named target. `End` terminates the run.
3916/// `Wait` pauses the run at the current node so the next inbound activity
3917/// resumes here and re-evaluates the routing with the new context — this is
3918/// what allows messaging flows (welcome → ... → confirm) to behave like a
3919/// live conversation instead of restarting at the entry point on every
3920/// click.
3921#[derive(Debug)]
3922pub(crate) enum CustomRoutingDecision {
3923    Next(NodeId),
3924    End,
3925    Wait,
3926}
3927
3928/// Evaluate a node's `Routing::Custom` array against the current execution
3929/// context.
3930///
3931/// Parses `Routing::Custom(Value)` as an array of `{condition, to}` objects.
3932/// Conditions are simple equality expressions like `response.action == "about"`.
3933/// Falls back to the first route without a condition (default route).
3934///
3935/// The evaluation context includes:
3936/// - All fields from the node output payload (top-level)
3937/// - `entry` / `in` — the original flow entry (incoming message)
3938/// - `response` — synthesized from entry metadata for convenient condition checks
3939///   (e.g. `response.action` maps to `metadata.action` from the incoming envelope)
3940fn evaluate_custom_routing(
3941    raw: &Value,
3942    output: &NodeOutput,
3943    state: &ExecutionState,
3944    flow_ir: &HostFlow,
3945    node_id: &NodeId,
3946) -> CustomRoutingDecision {
3947    let routes = match raw.as_array() {
3948        Some(arr) => arr,
3949        None => {
3950            tracing::warn!(
3951                flow_id = %flow_ir.id,
3952                node_id = %node_id,
3953                "custom routing is not an array; terminating"
3954            );
3955            return CustomRoutingDecision::End;
3956        }
3957    };
3958
3959    // Build a rich context for condition evaluation:
3960    // Start with output payload, then overlay entry and synthesised "response".
3961    // The default `event` is chosen from the success/error-family port this node
3962    // actually routes on, so happy paths named `on_complete`/`on_submit` and
3963    // failure paths named `on_cancel`/`on_timeout` resolve instead of stalling
3964    // at `Wait`.
3965    let ctx = build_routing_context(
3966        output,
3967        state,
3968        default_success_event(routes),
3969        default_error_event(routes),
3970    );
3971
3972    let mut has_condition = false;
3973    for route in routes {
3974        let condition = route.get("condition").and_then(|v| v.as_str());
3975        let to = route.get("to").and_then(|v| v.as_str());
3976
3977        if let Some(cond) = condition {
3978            has_condition = true;
3979            if evaluate_simple_condition(cond, &ctx)
3980                && let Some(target) = to
3981                && let Ok(nid) = NodeId::new(target)
3982            {
3983                tracing::debug!(
3984                    flow_id = %flow_ir.id,
3985                    node_id = %node_id,
3986                    condition = cond,
3987                    target = target,
3988                    "conditional route matched"
3989                );
3990                return CustomRoutingDecision::Next(nid);
3991            }
3992        } else if let Some(target) = to
3993            && let Ok(nid) = NodeId::new(target)
3994        {
3995            tracing::debug!(
3996                flow_id = %flow_ir.id,
3997                node_id = %node_id,
3998                target = target,
3999                "default route taken"
4000            );
4001            return CustomRoutingDecision::Next(nid);
4002        }
4003    }
4004
4005    // Fall-through. When the routing array contained at least one
4006    // conditional entry, treat the unmatched fall-through as a pause: the
4007    // user's next submission should be re-evaluated against this same
4008    // node's routing rather than restarting the flow from the entry point.
4009    // Routing arrays with no conditions at all (pure unconditional `out`
4010    // terminators) remain true ends.
4011    if has_condition {
4012        tracing::debug!(
4013            flow_id = %flow_ir.id,
4014            node_id = %node_id,
4015            "no conditional route matched; pausing run at current node for resume"
4016        );
4017        CustomRoutingDecision::Wait
4018    } else {
4019        tracing::warn!(
4020            flow_id = %flow_ir.id,
4021            node_id = %node_id,
4022            "no route matched and no conditions present; terminating"
4023        );
4024        CustomRoutingDecision::End
4025    }
4026}
4027
4028/// Evaluate a simple condition expression used by `Routing::Custom` entries and
4029/// `conditional_branch` guards (e.g. `response.action == "about"`,
4030/// `register.q_age >= 18`, `msg.text contains "hello"`).
4031///
4032/// Dotted paths resolve against the JSON context; an unresolved path is false.
4033/// Operators (detected longest-token-first so `>=`/`<=` win over `>`/`<`):
4034/// - `== ` / `!=` — case-insensitive string equality.
4035/// - `>=` / `<=` / `>` / `<` — numeric ordering; both operands are parsed as
4036///   `f64`, and a non-numeric operand makes the condition false (never a panic).
4037/// - `contains` — case-insensitive substring of the resolved string.
4038fn evaluate_simple_condition(condition: &str, ctx: &Value) -> bool {
4039    if let Some((path, expected)) = split_condition(condition, "==") {
4040        return string_eq(ctx, path, expected, false);
4041    }
4042    if let Some((path, expected)) = split_condition(condition, "!=") {
4043        return string_eq(ctx, path, expected, true);
4044    }
4045    if let Some((path, expected)) = split_condition(condition, ">=") {
4046        return numeric_cmp(ctx, path, expected, |a, b| a >= b);
4047    }
4048    if let Some((path, expected)) = split_condition(condition, "<=") {
4049        return numeric_cmp(ctx, path, expected, |a, b| a <= b);
4050    }
4051    if let Some((path, expected)) = split_condition(condition, ">") {
4052        return numeric_cmp(ctx, path, expected, |a, b| a > b);
4053    }
4054    if let Some((path, expected)) = split_condition(condition, "<") {
4055        return numeric_cmp(ctx, path, expected, |a, b| a < b);
4056    }
4057    if let Some((path, expected)) = split_condition(condition, " contains ") {
4058        let needle = expected.to_lowercase();
4059        return resolve_dotted_path(ctx, path)
4060            .is_some_and(|actual| actual.to_lowercase().contains(&needle));
4061    }
4062    false
4063}
4064
4065/// Split a condition on the first occurrence of `op` into a trimmed
4066/// `(path, value)`, with surrounding quotes stripped from the value.
4067/// `None` when `op` is absent.
4068fn split_condition<'a>(condition: &'a str, op: &str) -> Option<(&'a str, &'a str)> {
4069    let idx = condition.find(op)?;
4070    let path = condition[..idx].trim();
4071    let value = condition[idx + op.len()..].trim().trim_matches('"');
4072    Some((path, value))
4073}
4074
4075/// Case-insensitive string equality of the resolved path against `expected`,
4076/// optionally negated. An unresolved path is treated as not-equal.
4077fn string_eq(ctx: &Value, path: &str, expected: &str, negate: bool) -> bool {
4078    let matches = resolve_dotted_path(ctx, path)
4079        .as_deref()
4080        .is_some_and(|a| a.eq_ignore_ascii_case(expected));
4081    if negate { !matches } else { matches }
4082}
4083
4084/// Numeric comparison of the resolved path against `expected`. Both sides are
4085/// parsed as `f64`; if either fails to parse the condition is false.
4086fn numeric_cmp(ctx: &Value, path: &str, expected: &str, cmp: impl Fn(f64, f64) -> bool) -> bool {
4087    let Some(actual) = resolve_dotted_path(ctx, path).and_then(|a| a.trim().parse::<f64>().ok())
4088    else {
4089        return false;
4090    };
4091    let Ok(rhs) = expected.parse::<f64>() else {
4092        return false;
4093    };
4094    cmp(actual, rhs)
4095}
4096
4097/// Resolve a dotted path like `response.action` against a JSON value.
4098fn resolve_dotted_path(value: &Value, path: &str) -> Option<String> {
4099    let parts: Vec<&str> = path.split('.').collect();
4100    let mut current = value;
4101    for part in &parts {
4102        current = current.get(part)?;
4103    }
4104    match current {
4105        Value::String(s) => Some(s.clone()),
4106        Value::Bool(b) => Some(b.to_string()),
4107        Value::Number(n) => Some(n.to_string()),
4108        _ => Some(current.to_string()),
4109    }
4110}
4111
4112/// Build a context object for routing condition evaluation.
4113///
4114/// The context merges the node output with the flow entry so that conditions
4115/// can reference both component results and incoming message data.
4116///
4117/// Layout:
4118/// ```text
4119/// {
4120///   ...output.payload...,     // top-level fields from component output
4121///   "entry": <flow entry>,
4122///   "in":    <flow entry>,    // alias
4123///   "response": {             // synthesised from envelope metadata
4124///     <key>: <value>,         // e.g. "action": "about"
4125///     ...
4126///   }
4127/// }
4128/// ```
4129/// Success-family outcome ports, in the priority order used to pick the default
4130/// success `event` for a node that succeeded without emitting an explicit
4131/// `outcome`. `on_success` is first so components whose success name is the
4132/// historical default keep routing unchanged (e.g. http).
4133const SUCCESS_EVENT_PORTS: [&str; 3] = ["on_success", "on_complete", "on_submit"];
4134
4135/// Error-family outcome ports, priority order, mirroring [`SUCCESS_EVENT_PORTS`]
4136/// for the failure (`ok == false`) branch. `on_error` is first so the historical
4137/// default is preserved; `on_cancel` / `on_timeout` let a node whose failure
4138/// port is named differently (qa cancel, http timeout) route instead of stalling.
4139const ERROR_EVENT_PORTS: [&str; 3] = ["on_error", "on_cancel", "on_timeout"];
4140
4141/// Whether a node opts into node_io error routing: a `Routing::Custom` array with
4142/// at least one route targeting an error-family port (`on_error` / `on_cancel` /
4143/// `on_timeout`), either as an explicit `event` field or via an `event == "<port>"`
4144/// condition (the form the designer emits). Such a node surfaces a component
4145/// failure as an `{errors}` output routed to that branch; every other node keeps
4146/// the historical hard-fail (`bail!`) on error — so this change is purely additive.
4147fn node_has_error_route(routing: &Routing) -> bool {
4148    let Routing::Custom(raw) = routing else {
4149        return false;
4150    };
4151    let Some(routes) = raw.as_array() else {
4152        return false;
4153    };
4154    routes.iter().any(|route| {
4155        let by_event = route
4156            .get("event")
4157            .and_then(Value::as_str)
4158            .is_some_and(|e| ERROR_EVENT_PORTS.contains(&e));
4159        let by_condition = route
4160            .get("condition")
4161            .and_then(Value::as_str)
4162            .is_some_and(|c| ERROR_EVENT_PORTS.iter().any(|port| c.contains(port)));
4163        by_event || by_condition
4164    })
4165}
4166
4167/// Derive the success `event` to default to when a node succeeds (`ok == true`)
4168/// but emits no explicit `outcome`. Designer-built nodes whose happy port is
4169/// `on_complete` (native `qa.process` / `llm.openai.chat` / `template_render`)
4170/// or `on_submit` (forms) compile to `event == "<port>"` conditions; with a
4171/// blanket `on_success` default those never match and the node stalls at
4172/// `Wait`. We instead pick the first success-family port the node actually has
4173/// an outgoing `event == "<port>"` edge for, so the happy path routes. Falls
4174/// back to `on_success` when no success-family port is referenced (preserving
4175/// the prior behaviour).
4176fn default_success_event(routes: &[Value]) -> &'static str {
4177    default_event(routes, &SUCCESS_EVENT_PORTS, "on_success")
4178}
4179
4180/// Failure-branch counterpart of [`default_success_event`]: the `event` to
4181/// default to when a node fails (`ok == false`) without an explicit `outcome`.
4182/// Picks the first error-family port the node actually routes on, falling back
4183/// to `on_error`.
4184fn default_error_event(routes: &[Value]) -> &'static str {
4185    default_event(routes, &ERROR_EVENT_PORTS, "on_error")
4186}
4187
4188/// Pick the first port in `ports` (priority order) that the node has an outgoing
4189/// `event == "<port>"` edge for; `fallback` when none is referenced.
4190fn default_event(routes: &[Value], ports: &[&'static str], fallback: &'static str) -> &'static str {
4191    let referenced: Vec<&str> = routes
4192        .iter()
4193        .filter_map(|route| route.get("condition").and_then(Value::as_str))
4194        .filter_map(condition_event_eq)
4195        .collect();
4196    ports
4197        .iter()
4198        .copied()
4199        .find(|port| referenced.contains(port))
4200        .unwrap_or(fallback)
4201}
4202
4203/// Extract `<value>` from an `event == "<value>"` condition; `None` for any
4204/// other shape (different path, `!=`, no `==`).
4205fn condition_event_eq(condition: &str) -> Option<&str> {
4206    let idx = condition.find("==")?;
4207    if condition[..idx].trim() != "event" {
4208        return None;
4209    }
4210    Some(condition[idx + 2..].trim().trim_matches('"'))
4211}
4212
4213/// Where the submit envelope carries its metadata, across both delivery paths.
4214///
4215/// `greentic-start` wraps the activity (`entry.input.metadata`); the direct
4216/// runner path does not (`entry.metadata`). Both the `response.*` synthesis in
4217/// [`build_routing_context`] and [`submitted_fields`] resolve through here, so
4218/// the two can never disagree about which object is the metadata.
4219fn resolve_entry_metadata(entry: &Value) -> Option<&Value> {
4220    entry
4221        .pointer("/input/metadata")
4222        .or_else(|| entry.pointer("/metadata"))
4223}
4224
4225/// The fields a person actually submitted, as one flat map.
4226///
4227/// The envelope root is `entry.input` when that is an object (the wrapped
4228/// `greentic-start` path) and `entry` itself otherwise (the Run Demo path,
4229/// whose inputs sit at the root — see that repo's
4230/// `flow_demo/host.rs::build_submit_payload`). Resolving it is not bookkeeping:
4231/// taking `entry`'s root keys directly on the wrapped path would make one field
4232/// named `input` holding the entire nested activity.
4233///
4234/// The rule:
4235///
4236/// > the envelope root's keys except `metadata` and `text`, unioned with the
4237/// > resolved metadata object's keys except `action`; on collision the envelope
4238/// > root wins.
4239///
4240/// `text` is the message body in both shapes and is already exposed as
4241/// `response.text`. `action` is the route discriminator, already exposed as
4242/// `response.action` — but a key named `action` at the envelope ROOT is counted,
4243/// because on the demo path that is a real keystroke at a different level from
4244/// the routing metadata.
4245///
4246/// NOTE: `response.*` must NOT be rebuilt on top of this. It shares only
4247/// [`resolve_entry_metadata`]. Feeding `response` this map would drop
4248/// `response.action`, which every parking card's conditional routing tests —
4249/// and a failed condition silently takes the false branch.
4250fn submitted_fields(entry: &Value) -> JsonMap<String, Value> {
4251    let mut fields = JsonMap::new();
4252    if let Some(Value::Object(meta)) = resolve_entry_metadata(entry) {
4253        for (key, value) in meta {
4254            if key == "action" {
4255                continue;
4256            }
4257            fields.insert(key.clone(), value.clone());
4258        }
4259    }
4260    let envelope = entry
4261        .get("input")
4262        .filter(|v| v.is_object())
4263        .unwrap_or(entry);
4264    if let Some(map) = envelope.as_object() {
4265        for (key, value) in map {
4266            if key == "metadata" || key == "text" {
4267                continue;
4268            }
4269            fields.insert(key.clone(), value.clone());
4270        }
4271    }
4272    fields
4273}
4274
4275/// Build the context a routing condition is evaluated against.
4276///
4277/// Layout:
4278/// ```text
4279/// {
4280///   ...output.payload...,     // this node's fields, spread at the top level
4281///   "entry":    <flow entry>,
4282///   "in":       <flow entry>, // alias for entry
4283///   "node":     { "<id>": <node_output_view>, ... },  // every prior node
4284///   "response": { <key>: <value>, ... },              // from envelope metadata
4285///   "event":    "<outcome>"   // the port this node routes on
4286/// }
4287/// ```
4288///
4289/// The spread comes FIRST and the named keys are inserted after, so
4290/// **`entry`, `in`, `node`, `response` and `event` are reserved**: a component
4291/// whose payload has a top-level field with one of those names has it shadowed
4292/// here. That is deliberate — the spread is what lets a guard say `q_age >= 18`
4293/// about its own node (and is what the designer's source-node prefix strip
4294/// relies on) — but it means those five names are not usable as payload fields
4295/// in a routed node.
4296///
4297/// `node` is the same `outputs_map()` projection [`template_context`] exposes,
4298/// so a condition resolves `node.<id>.<field>` exactly as a param template
4299/// resolves `{{node.<id>.<field>}}`. Note `vars` is NOT here: `vars.x` in a
4300/// condition does not resolve, whereas `{{vars.x}}` in a param does.
4301fn build_routing_context(
4302    output: &NodeOutput,
4303    state: &ExecutionState,
4304    success_event: &str,
4305    error_event: &str,
4306) -> Value {
4307    let mut ctx = match &output.payload {
4308        Value::Object(map) => map.clone(),
4309        _ => JsonMap::new(),
4310    };
4311
4312    // Alias `in.input` to the entry itself when the entry is the bare message
4313    // (env/revision path) so routing templates that read `in.input.*` resolve,
4314    // mirroring `template_context`. Legacy `{input: <message>}` entries are
4315    // left untouched.
4316    let entry = alias_input_to_entry(state.entry.clone());
4317    ctx.insert("entry".into(), entry.clone());
4318    ctx.insert("in".into(), entry.clone());
4319
4320    // Synthesise "response" from the envelope metadata.
4321    // greentic-start demo path: entry.input.metadata.*
4322    // greentic-runner direct path: entry.metadata.*
4323    let metadata = entry
4324        .pointer("/input/metadata")
4325        .or_else(|| entry.pointer("/metadata"));
4326
4327    let mut response = JsonMap::new();
4328    if let Some(Value::Object(meta)) = metadata {
4329        for (k, v) in meta {
4330            // Flatten string values; stringify others
4331            match v {
4332                Value::String(s) => {
4333                    response.insert(k.clone(), Value::String(s.clone()));
4334                }
4335                other => {
4336                    response.insert(k.clone(), other.clone());
4337                }
4338            }
4339        }
4340    }
4341    // Also pull text from the envelope for convenience
4342    if let Some(text) = entry
4343        .pointer("/input/text")
4344        .or_else(|| entry.pointer("/text"))
4345        .filter(|t| !t.is_null())
4346    {
4347        response.insert("text".into(), text.clone());
4348    }
4349    ctx.insert("response".into(), Value::Object(response));
4350
4351    // Inject the node's outcome as `event` so port-name routing
4352    // (`event == "<outcome>"`, emitted by the designer for nodes with multiple
4353    // outgoing edges) resolves. Prefer an explicit outcome the node emitted in
4354    // its output metadata; otherwise derive a default from `ok` — `success_event`
4355    // on success / `error_event` on failure (the success/error-family port the
4356    // node actually has an edge for; see `default_success_event` /
4357    // `default_error_event`). Without this, a multi-edge node falls through to
4358    // `Wait` at runtime.
4359    let event = output
4360        .meta
4361        .get("outcome")
4362        .and_then(Value::as_str)
4363        .map(str::to_string)
4364        .unwrap_or_else(|| {
4365            if output.ok {
4366                success_event
4367            } else {
4368                error_event
4369            }
4370            .to_string()
4371        });
4372    ctx.insert("event".into(), Value::String(event));
4373
4374    Value::Object(ctx)
4375}
4376
4377/// Pure autonomy-gate decision for an `approval.call` node. Returns `true` when
4378/// the request must go to a human (dispatch), `false` when it auto-approves.
4379///
4380/// The gate config fields (`mode`, `risk_threshold`, `confidence_threshold`)
4381/// are compiled by the designer as FLAT fields directly on the node input
4382/// (not nested under a `gate` object); `risk`/`confidence` are already
4383/// flat/dynamic values populated at flow render time.
4384fn approval_requires_human(input: &Value) -> bool {
4385    let mode = input
4386        .get("mode")
4387        .and_then(Value::as_str)
4388        .unwrap_or("always");
4389    match mode {
4390        "above_risk" => {
4391            let risk = input.get("risk").and_then(Value::as_f64).unwrap_or(0.0);
4392            let threshold = input
4393                .get("risk_threshold")
4394                .and_then(Value::as_f64)
4395                .unwrap_or(1.0);
4396            risk >= threshold
4397        }
4398        "above_confidence" => {
4399            let confidence = input
4400                .get("confidence")
4401                .and_then(Value::as_f64)
4402                .unwrap_or(0.0);
4403            let threshold = input
4404                .get("confidence_threshold")
4405                .and_then(Value::as_f64)
4406                .unwrap_or(1.0);
4407            confidence < threshold
4408        }
4409        // "always" and any unknown mode fail safe: require a human.
4410        _ => true,
4411    }
4412}
4413
4414#[cfg(test)]
4415mod approval_gate_tests {
4416    use super::approval_requires_human;
4417    use serde_json::json;
4418
4419    #[test]
4420    fn above_risk_auto_approves_below_threshold() {
4421        let input = json!({ "risk": 0.5, "mode": "above_risk", "risk_threshold": 0.7 });
4422        assert!(!approval_requires_human(&input));
4423    }
4424
4425    #[test]
4426    fn above_risk_requires_human_at_or_above_threshold() {
4427        let input = json!({ "risk": 0.9, "mode": "above_risk", "risk_threshold": 0.7 });
4428        assert!(approval_requires_human(&input));
4429    }
4430
4431    #[test]
4432    fn above_confidence_requires_human_when_low_confidence() {
4433        let input =
4434            json!({ "confidence": 0.4, "mode": "above_confidence", "confidence_threshold": 0.8 });
4435        assert!(approval_requires_human(&input));
4436    }
4437
4438    #[test]
4439    fn always_and_missing_gate_require_human() {
4440        assert!(approval_requires_human(&json!({ "mode": "always" })));
4441        assert!(approval_requires_human(&json!({})));
4442    }
4443}
4444
4445#[cfg(test)]
4446mod tests {
4447    use super::*;
4448
4449    /// The loader and the engine must agree on which op-keys are runner-native.
4450    ///
4451    /// `flow_adapter::NATIVE_OP_KEYS` decides which keys the LOADER preserves
4452    /// verbatim instead of wrapping in a `component.exec` node; the match in
4453    /// `component_label` above is what the ENGINE dispatches. Its doc says to
4454    /// keep the two in lockstep, and nothing enforced it — so `flow.goto`
4455    /// arrived with an engine arm the loader never routed a node to, which is
4456    /// silent: the node builds, loads as a generic component, and simply is not
4457    /// a goto any more.
4458    ///
4459    /// `native_op_key_for` exists to make that mechanical. It is an EXHAUSTIVE
4460    /// match, so adding a `NodeKind` variant fails to compile here until
4461    /// somebody says whether the loader must preserve it — which is the whole
4462    /// point; a test listing strings alone would have gone stale the same way
4463    /// the doc comment did.
4464    ///
4465    /// Ported from #686 on `research`, `var.set` arm included.
4466    fn native_op_key_for(kind: &NodeKind) -> Option<&'static str> {
4467        match kind {
4468            // Not op-keys: these ARE the generic component paths.
4469            NodeKind::Exec { .. } | NodeKind::PackComponent { .. } => None,
4470            // Prefix-matched by `is_native_op_key`, not listed in the array.
4471            NodeKind::BuiltinEmit { .. } | NodeKind::Mcp { .. } => None,
4472
4473            NodeKind::ProviderInvoke => Some("provider.invoke"),
4474            NodeKind::FlowCall => Some("flow.call"),
4475            NodeKind::FlowGoto => Some("flow.goto"),
4476            NodeKind::BuiltinStateGet => Some("state.get"),
4477            NodeKind::BuiltinStateSet => Some("state.set"),
4478            NodeKind::VarSet { .. } => Some("var.set"),
4479            NodeKind::Wait => Some("session.wait"),
4480            NodeKind::DwAgent { .. } => Some("dw.agent"),
4481            NodeKind::DwAgentGraph { .. } => Some("dw.agent_graph"),
4482            NodeKind::SorlaCall { .. } => Some("sorla.call"),
4483            NodeKind::OperalaCall { .. } => Some("operala.call"),
4484            NodeKind::AgenticCall { .. } => Some("agentic.call"),
4485            NodeKind::TelcoXCall { .. } => Some("telco-x.call"),
4486            NodeKind::ApprovalCall { .. } => Some("approval.call"),
4487        }
4488    }
4489
4490    #[test]
4491    fn every_engine_dispatched_op_key_is_native_to_the_loader() {
4492        // Mirrors `component_label`'s builtin arms. Each string here is one the
4493        // engine will dispatch itself, so the loader must hand it through
4494        // unwrapped.
4495        for key in [
4496            "provider.invoke",
4497            "flow.call",
4498            "flow.goto",
4499            "state.get",
4500            "state.set",
4501            "var.set",
4502            "session.wait",
4503            "dw.agent",
4504            "dw.agent_graph",
4505            "sorla.call",
4506            "operala.call",
4507            "agentic.call",
4508            "telco-x.call",
4509            "approval.call",
4510        ] {
4511            assert!(
4512                crate::runner::flow_adapter::is_native_op_key(key),
4513                "the engine dispatches `{key}`, but the loader does not treat it \
4514                 as native — it will be wrapped as a generic component and the \
4515                 engine arm becomes unreachable"
4516            );
4517        }
4518        // The two prefix families, which are deliberately not in the array.
4519        assert!(crate::runner::flow_adapter::is_native_op_key(
4520            "emit.response"
4521        ));
4522        assert!(crate::runner::flow_adapter::is_native_op_key(
4523            "mcp:srv/tool"
4524        ));
4525        // And a genuine pack component must NOT be native.
4526        assert!(!crate::runner::flow_adapter::is_native_op_key("mcp.exec"));
4527
4528        // Keeps `native_op_key_for` live: its exhaustiveness is the guard.
4529        assert_eq!(native_op_key_for(&NodeKind::FlowGoto), Some("flow.goto"));
4530    }
4531    use crate::validate::{ValidationConfig, ValidationMode};
4532    use greentic_types::{
4533        Flow, FlowComponentRef, FlowId, FlowKind, FlowMetadata, InputMapping, Node, NodeId,
4534        OutputMapping, Routing, TelemetryHints,
4535    };
4536    use serde_json::json;
4537    use std::collections::{BTreeMap, HashMap as StdHashMap};
4538    use std::str::FromStr;
4539    use std::sync::Mutex;
4540    use tokio::runtime::Runtime;
4541
4542    fn minimal_engine() -> FlowEngine {
4543        FlowEngine {
4544            packs: Vec::new(),
4545            flows: Vec::new(),
4546            flow_sources: HashMap::new(),
4547            messaging_provider_pack_ids: std::collections::HashSet::new(),
4548            flow_cache: RwLock::new(HashMap::new()),
4549            default_env: "local".to_string(),
4550            validation: ValidationConfig {
4551                mode: ValidationMode::Off,
4552            },
4553            cross_pack_resolver: None,
4554            rollout_ids: RolloutIds::default(),
4555            remote_dispatch_handler: None,
4556            #[cfg(feature = "agentic-worker")]
4557            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
4558            #[cfg(feature = "agentic-worker")]
4559            agent_node_handler: None,
4560            #[cfg(feature = "agentic-worker")]
4561            graph_node_handler: None,
4562            #[cfg(feature = "agentic-worker")]
4563            mcp_tool_source: None,
4564        }
4565    }
4566
4567    fn flow_desc(id: &str, pack_id: &str, flow_type: &str, entry: bool) -> FlowDescriptor {
4568        FlowDescriptor {
4569            id: id.into(),
4570            flow_type: flow_type.into(),
4571            pack_id: pack_id.into(),
4572            profile: pack_id.into(),
4573            version: "0.0.0".into(),
4574            description: None,
4575            entry,
4576        }
4577    }
4578
4579    #[test]
4580    fn entry_flow_by_type_disambiguates_entrypoint_from_internal_helpers() {
4581        // Regression: a pack with one public messaging entrypoint (`default`)
4582        // plus internal helper flows of the same type (dispatcher sub-flows)
4583        // must route an inbound, type-only provider event to the entrypoint —
4584        // NOT fail as "flow type messaging is ambiguous; pack_id is required".
4585        let mut engine = minimal_engine();
4586        engine.flows = vec![
4587            flow_desc("default", "weatherapi-pack", "messaging", true),
4588            flow_desc("flow_", "weatherapi-pack", "messaging", false),
4589            flow_desc("flow_error", "weatherapi-pack", "messaging", false),
4590            flow_desc("flow_get_weather", "weatherapi-pack", "messaging", false),
4591        ];
4592
4593        // Multiple flows of the type => the plain lookup is ambiguous...
4594        assert!(
4595            engine.flow_by_type("messaging").is_none(),
4596            "multiple messaging flows must be ambiguous for the plain lookup"
4597        );
4598        // ...but exactly one is an entrypoint, so entry-aware routing resolves.
4599        let resolved = engine
4600            .entry_flow_by_type("messaging")
4601            .expect("single entry flow must resolve");
4602        assert_eq!(resolved.id, "default");
4603        assert_eq!(resolved.pack_id, "weatherapi-pack");
4604    }
4605
4606    #[test]
4607    fn entry_flow_by_type_still_ambiguous_across_two_entrypoints() {
4608        // Two entrypoints of the same type across packs is genuinely ambiguous
4609        // and must still require a pack_id (no silent, arbitrary pick).
4610        let mut engine = minimal_engine();
4611        engine.flows = vec![
4612            flow_desc("default", "pack.a", "messaging", true),
4613            flow_desc("default", "pack.b", "messaging", true),
4614            flow_desc("helper", "pack.a", "messaging", false),
4615        ];
4616        assert!(engine.entry_flow_by_type("messaging").is_none());
4617    }
4618
4619    #[test]
4620    fn entry_flow_by_type_excludes_messaging_provider_pack_flows() {
4621        // Multi-provider bundle: the app pack's entry flow AND a messaging
4622        // *provider* pack's ingress `main` are both entry `messaging` flows.
4623        // The provider flow is that provider's plumbing, not the application
4624        // entrypoint, so a type-only webchat event must resolve to the app flow
4625        // — not bail "flow type messaging is ambiguous; pack_id is required".
4626        let mut engine = minimal_engine();
4627        engine.flows = vec![
4628            flow_desc("main", "hr-onboarding-pack", "messaging", true),
4629            flow_desc("main", "messaging-teams", "messaging", true),
4630        ];
4631        // `messaging-teams` declares a `messaging.*` provider in its manifest;
4632        // the engine records that at build time.
4633        engine
4634            .messaging_provider_pack_ids
4635            .insert("messaging-teams".to_string());
4636
4637        // Plain lookup is still ambiguous (two flows of the type)...
4638        assert!(engine.flow_by_type("messaging").is_none());
4639        // ...but only the app pack's flow is an *application* entrypoint.
4640        let resolved = engine
4641            .entry_flow_by_type("messaging")
4642            .expect("app entry flow must resolve past the provider flow");
4643        assert_eq!(resolved.id, "main");
4644        assert_eq!(resolved.pack_id, "hr-onboarding-pack");
4645    }
4646
4647    #[test]
4648    fn entry_flow_by_type_matches_plain_lookup_for_single_flow() {
4649        // Backward-compat: a lone flow of a type resolves the same way through
4650        // both paths, tagged entry or not.
4651        let mut engine = minimal_engine();
4652        engine.flows = vec![flow_desc("only", "pack.a", "messaging", true)];
4653        assert_eq!(
4654            engine.flow_by_type("messaging").map(|f| f.id.as_str()),
4655            Some("only")
4656        );
4657        assert_eq!(
4658            engine
4659                .entry_flow_by_type("messaging")
4660                .map(|f| f.id.as_str()),
4661            Some("only")
4662        );
4663    }
4664
4665    #[test]
4666    fn to_node_output_legacy_success_becomes_data() {
4667        // Legacy `{ok:true, ...fields}` (no node_io envelope) → Data{data}.
4668        let out = to_node_output(&json!({ "ok": true, "temp": "20C" }));
4669        assert!(out.is_ok(), "legacy ok:true must classify as Data");
4670        let data = out.data().expect("data present");
4671        assert_eq!(data.get("temp").and_then(Value::as_str), Some("20C"));
4672    }
4673
4674    #[test]
4675    fn to_node_output_legacy_error_becomes_errors() {
4676        // Legacy `{ok:false, error:{code,message}}` → Errors{errors:[NodeError]}.
4677        let out = to_node_output(
4678            &json!({ "ok": false, "error": { "code": "E_BAD", "message": "boom" } }),
4679        );
4680        assert!(!out.is_ok(), "legacy ok:false must classify as Errors");
4681        let errs = out.errors();
4682        assert_eq!(errs.len(), 1);
4683        assert_eq!(errs[0].code, "E_BAD");
4684        assert_eq!(errs[0].message, "boom");
4685    }
4686
4687    #[test]
4688    fn to_node_output_native_data_envelope_roundtrips() {
4689        // A node_io-native `{data:{...}}` envelope parses straight to Data.
4690        let out = to_node_output(&json!({ "data": { "x": 1 } }));
4691        assert!(out.is_ok());
4692        assert_eq!(
4693            out.data().and_then(|d| d.get("x")).and_then(Value::as_i64),
4694            Some(1)
4695        );
4696    }
4697
4698    #[test]
4699    fn to_node_output_native_errors_envelope_roundtrips() {
4700        // A node_io-native `{errors:[...]}` envelope parses straight to Errors.
4701        let out = to_node_output(&json!({
4702            "errors": [ { "code": "C", "message": "m", "kind": "validation",
4703                          "retryable": false, "details": {} } ]
4704        }));
4705        assert!(!out.is_ok());
4706        assert_eq!(out.errors()[0].code, "C");
4707        assert_eq!(
4708            out.errors()[0].kind,
4709            greentic_types::node_io::ErrorKind::Validation
4710        );
4711    }
4712
4713    #[test]
4714    fn to_node_output_bare_object_becomes_data() {
4715        // A bare result with no envelope keys → Data{data: <whole value>}.
4716        let out = to_node_output(&json!({ "foo": 1 }));
4717        assert!(out.is_ok());
4718        assert_eq!(
4719            out.data()
4720                .and_then(|d| d.get("foo"))
4721                .and_then(Value::as_i64),
4722            Some(1)
4723        );
4724    }
4725
4726    #[test]
4727    fn templating_renders_with_partials_and_data() {
4728        let mut state = ExecutionState::new(json!({ "city": "London" }));
4729        state.nodes.insert(
4730            "forecast".to_string(),
4731            NodeOutput::new(json!({ "temp": "20C" })),
4732        );
4733
4734        // templating context includes node outputs for runner-side payload rendering.
4735        let ctx = state.context();
4736        assert_eq!(ctx["nodes"]["forecast"]["payload"]["temp"], json!("20C"));
4737    }
4738
4739    #[test]
4740    fn outputs_map_exposes_node_io_data_and_errors_alongside_flat() {
4741        let mut state = ExecutionState::new(json!({}));
4742        state.nodes.insert(
4743            "forecast".to_string(),
4744            NodeOutput::new(json!({ "temp": "20C" })),
4745        );
4746        let outs = state.outputs_map();
4747        // Legacy flat ref `{{node.forecast.temp}}` keeps working.
4748        assert_eq!(outs["forecast"]["temp"], json!("20C"));
4749        // Canonical node_io ref `{{node.forecast.data.temp}}` resolves to the same.
4750        assert_eq!(outs["forecast"]["data"]["temp"], json!("20C"));
4751        // `{{node.forecast.errors}}` is present and empty for a success output.
4752        assert_eq!(outs["forecast"]["errors"], json!([]));
4753    }
4754
4755    #[test]
4756    fn finalize_wraps_emitted_payloads() {
4757        let mut state = ExecutionState::new(json!({}));
4758        state.push_egress(json!({ "text": "first" }));
4759        state.push_egress(json!({ "text": "second" }));
4760        let result = state.finalize_with(Some(json!({ "text": "final" })));
4761        assert_eq!(
4762            result,
4763            json!([
4764                { "text": "first" },
4765                { "text": "second" },
4766                { "text": "final" }
4767            ])
4768        );
4769    }
4770
4771    #[test]
4772    fn finalize_does_not_double_terminal_emit_response() {
4773        // Regression: a terminal `emit.response` node pushes its card to egress
4774        // AND returns it as the node output, which the `End` path passes as
4775        // `final_payload`. The card must appear ONCE, not twice (the webchat
4776        // "double card").
4777        let card = json!({ "renderedCard": { "type": "AdaptiveCard" } });
4778        let mut state = ExecutionState::new(json!({}));
4779        state.push_egress(card.clone());
4780        let result = state.finalize_with(Some(card.clone()));
4781        assert_eq!(result, json!([card]));
4782    }
4783
4784    #[test]
4785    fn finalize_still_appends_distinct_terminal_output() {
4786        // A terminal output that differs from the last emitted response is a
4787        // genuine additional reply and must still be appended.
4788        let mut state = ExecutionState::new(json!({}));
4789        state.push_egress(json!({ "text": "emitted" }));
4790        let result = state.finalize_with(Some(json!({ "text": "final" })));
4791        assert_eq!(result, json!([{ "text": "emitted" }, { "text": "final" }]));
4792    }
4793
4794    #[test]
4795    fn alias_input_to_entry_exposes_input_for_bare_message() {
4796        // Env/revision path: the flow entry IS the message — metadata at the
4797        // top level, no `input` wrapper. After aliasing, the pack's
4798        // `in.input.metadata.*` template resolves the same as `in.metadata.*`.
4799        let msg = json!({ "text": "hi", "metadata": { "operation": "get_weather" } });
4800        let aliased = alias_input_to_entry(msg);
4801        assert_eq!(
4802            aliased.pointer("/metadata/operation"),
4803            Some(&json!("get_weather"))
4804        );
4805        assert_eq!(
4806            aliased.pointer("/input/metadata/operation"),
4807            Some(&json!("get_weather"))
4808        );
4809    }
4810
4811    #[test]
4812    fn alias_input_to_entry_preserves_explicit_input_wrapper() {
4813        // Legacy `{input: <message>}` entries must not be double-wrapped.
4814        let wrapped = json!({ "input": { "metadata": { "operation": "x" } } });
4815        assert_eq!(alias_input_to_entry(wrapped.clone()), wrapped);
4816    }
4817
4818    #[test]
4819    fn alias_input_to_entry_ignores_non_objects() {
4820        assert_eq!(alias_input_to_entry(json!("hi")), json!("hi"));
4821        assert_eq!(alias_input_to_entry(json!(null)), json!(null));
4822    }
4823
4824    #[test]
4825    fn finalize_flattens_final_array() {
4826        let mut state = ExecutionState::new(json!({}));
4827        state.push_egress(json!({ "text": "only" }));
4828        let result = state.finalize_with(Some(json!([
4829            { "text": "extra-1" },
4830            { "text": "extra-2" }
4831        ])));
4832        assert_eq!(
4833            result,
4834            json!([
4835                { "text": "only" },
4836                { "text": "extra-1" },
4837                { "text": "extra-2" }
4838            ])
4839        );
4840    }
4841
4842    #[test]
4843    fn inject_card_locale_uses_entry_metadata_without_overwriting_payload() {
4844        let mut payload = json!({
4845            "card_source": "inline",
4846            "card_spec": { "title": "Hello" }
4847        });
4848        inject_card_locale(
4849            &mut payload,
4850            &json!({"input": {"metadata": {"locale": "nl-NL"}}}),
4851        );
4852        assert_eq!(payload["locale"], json!("nl-NL"));
4853
4854        let mut existing = json!({
4855            "card_source": "inline",
4856            "card_spec": { "title": "Hello" },
4857            "locale": "en-GB"
4858        });
4859        inject_card_locale(&mut existing, &json!({"metadata": {"locale": "nl-NL"}}));
4860        assert_eq!(existing["locale"], json!("en-GB"));
4861    }
4862
4863    #[test]
4864    fn load_i18n_bundle_entries_reads_manifest_and_falls_back_to_en() {
4865        let assets = StdHashMap::from([
4866            (
4867                "cards/i18n/_manifest.json".to_string(),
4868                br#"{"locales":["de"]}"#.to_vec(),
4869            ),
4870            (
4871                "cards/i18n/de.json".to_string(),
4872                br#"{"title":"Hallo"}"#.to_vec(),
4873            ),
4874            (
4875                "cards/i18n/en.json".to_string(),
4876                br#"{"title":"Hello"}"#.to_vec(),
4877            ),
4878        ]);
4879
4880        let entries = load_i18n_bundle_entries("cards/i18n", |path| {
4881            assets
4882                .get(path)
4883                .cloned()
4884                .with_context(|| format!("missing asset {path}"))
4885        });
4886
4887        assert_eq!(entries["de"]["title"], json!("Hallo"));
4888        assert_eq!(entries["en"]["title"], json!("Hello"));
4889    }
4890
4891    #[test]
4892    fn load_i18n_bundle_entries_reads_single_file_bundle() {
4893        let entries = load_i18n_bundle_entries("cards/i18n.json", |path| {
4894            if path == "cards/i18n.json" {
4895                Ok(br#"{"title":"Hello"}"#.to_vec())
4896            } else {
4897                bail!("unexpected asset {path}");
4898            }
4899        });
4900
4901        assert_eq!(entries["en"]["title"], json!("Hello"));
4902    }
4903
4904    struct TestCrossPackResolver;
4905
4906    impl CrossPackResolver for TestCrossPackResolver {
4907        fn invoke(
4908            &self,
4909            provider_id: &str,
4910            provider_type: Option<&str>,
4911            op: &str,
4912            input: &[u8],
4913            tenant: &str,
4914            team: Option<&str>,
4915        ) -> Result<Value> {
4916            Ok(json!({
4917                "provider_id": provider_id,
4918                "provider_type": provider_type,
4919                "op": op,
4920                "tenant": tenant,
4921                "team": team,
4922                "input": serde_json::from_slice::<Value>(input)?,
4923            }))
4924        }
4925    }
4926
4927    #[test]
4928    fn cross_pack_resolver_returns_node_output_when_present() {
4929        let mut engine = minimal_engine();
4930        engine.set_cross_pack_resolver(Arc::new(TestCrossPackResolver));
4931
4932        let output = engine
4933            .try_invoke_cross_pack_resolver(
4934                Some("mail"),
4935                Some("messaging"),
4936                "send",
4937                br#"{"subject":"hello"}"#,
4938                "demo",
4939            )
4940            .expect("resolver invocation")
4941            .expect("resolver output");
4942
4943        assert_eq!(
4944            output.payload,
4945            json!({
4946                "provider_id": "mail",
4947                "provider_type": "messaging",
4948                "op": "send",
4949                "tenant": "demo",
4950                "team": null,
4951                "input": { "subject": "hello" },
4952            })
4953        );
4954    }
4955
4956    #[test]
4957    fn flow_goto_builds_a_jump_to_the_named_flow() {
4958        let outcome = execute_flow_goto(json!({
4959            "flow_id": "support",
4960            "node": "ask_order",
4961            "input": { "order": "A-1" },
4962        }))
4963        .expect("goto builds");
4964
4965        let NodeControl::Jump(jump) = outcome.control else {
4966            panic!("flow.goto must produce a Jump, got {:?}", outcome.control);
4967        };
4968        assert_eq!(jump.flow, "support");
4969        assert_eq!(jump.node.as_deref(), Some("ask_order"));
4970        assert_eq!(jump.payload, json!({ "order": "A-1" }));
4971        // The node's own output is the payload the target receives, so a
4972        // template downstream of the goto reads what was actually handed over.
4973        assert_eq!(outcome.output.payload, json!({ "order": "A-1" }));
4974    }
4975
4976    /// `flow` is accepted alongside `flow_id`, matching `flow.call`'s payload so
4977    /// a document reads the same whichever primitive it uses.
4978    #[test]
4979    fn flow_goto_accepts_the_flow_alias_and_defaults_the_entry_node() {
4980        let outcome = execute_flow_goto(json!({ "flow": "support" })).expect("goto builds");
4981        let NodeControl::Jump(jump) = outcome.control else {
4982            panic!("expected a Jump");
4983        };
4984        assert_eq!(jump.flow, "support");
4985        assert!(
4986            jump.node.is_none(),
4987            "no node means `apply_jump` uses the target flow's start"
4988        );
4989        assert_eq!(jump.payload, Value::Null);
4990    }
4991
4992    /// An empty target is refused here rather than reaching `apply_jump`, which
4993    /// would fail later with a message about a flow named "".
4994    #[test]
4995    fn flow_goto_refuses_an_empty_flow_id() {
4996        for payload in [json!({ "flow_id": "" }), json!({ "flow_id": "   " })] {
4997            let err = execute_flow_goto(payload)
4998                .err()
4999                .expect("empty target must not build");
5000            assert!(
5001                err.to_string().contains("flow_id"),
5002                "error must name the field: {err}"
5003            );
5004        }
5005        let err = execute_flow_goto(json!({ "input": {} }))
5006            .err()
5007            .expect("missing target");
5008        assert!(err.to_string().contains("flow.goto"), "got: {err}");
5009    }
5010
5011    /// A blank `node` is the same as omitting it — otherwise `apply_jump` would
5012    /// look for a node whose id is the empty string and fail with a confusing
5013    /// "node not found".
5014    #[test]
5015    fn flow_goto_treats_a_blank_entry_node_as_absent() {
5016        let outcome =
5017            execute_flow_goto(json!({ "flow_id": "support", "node": "  " })).expect("goto builds");
5018        let NodeControl::Jump(jump) = outcome.control else {
5019            panic!("expected a Jump");
5020        };
5021        assert!(jump.node.is_none());
5022    }
5023
5024    /// The redirect ceiling is forwarded so a flow can tighten (or loosen) the
5025    /// default of 3 that `apply_jump` applies.
5026    #[test]
5027    fn flow_goto_forwards_the_redirect_ceiling_and_reason() {
5028        let outcome = execute_flow_goto(json!({
5029            "flow_id": "support",
5030            "max_redirects": 1,
5031            "reason": "menu choice",
5032        }))
5033        .expect("goto builds");
5034        let NodeControl::Jump(jump) = outcome.control else {
5035            panic!("expected a Jump");
5036        };
5037        assert_eq!(jump.max_redirects, Some(1));
5038        assert_eq!(jump.reason.as_deref(), Some("menu choice"));
5039    }
5040
5041    /// Absent a reason, one is supplied — `flow.jump.applied` logs it, and an
5042    /// empty reason there is indistinguishable from a component-emitted jump.
5043    #[test]
5044    fn flow_goto_names_itself_as_the_reason_by_default() {
5045        let outcome = execute_flow_goto(json!({ "flow_id": "support" })).expect("goto builds");
5046        let NodeControl::Jump(jump) = outcome.control else {
5047            panic!("expected a Jump");
5048        };
5049        assert_eq!(jump.reason.as_deref(), Some("flow.goto node"));
5050    }
5051
5052    #[test]
5053    fn parse_component_control_ignores_plain_payload() {
5054        let payload = json!({
5055            "flow": "not-a-control-field",
5056            "node": "n1"
5057        });
5058        let control = parse_component_control(&payload).expect("parse control");
5059        assert!(control.is_none());
5060    }
5061
5062    #[test]
5063    fn parse_component_control_parses_jump_marker() {
5064        let payload = json!({
5065            "greentic_control": {
5066                "action": "jump",
5067                "v": 1,
5068                "flow": "flow.b",
5069                "node": "node-2",
5070                "payload": { "message": "hi" },
5071                "hints": { "k": "v" },
5072                "max_redirects": 2,
5073                "reason": "handoff"
5074            }
5075        });
5076        let control = parse_component_control(&payload)
5077            .expect("parse control")
5078            .expect("missing control");
5079        match control {
5080            NodeControl::Jump(jump) => {
5081                assert_eq!(jump.flow, "flow.b");
5082                assert_eq!(jump.node.as_deref(), Some("node-2"));
5083                assert_eq!(jump.payload, json!({ "message": "hi" }));
5084                assert_eq!(jump.hints, json!({ "k": "v" }));
5085                assert_eq!(jump.max_redirects, Some(2));
5086                assert_eq!(jump.reason.as_deref(), Some("handoff"));
5087            }
5088            other => panic!("expected jump control, got {other:?}"),
5089        }
5090    }
5091
5092    #[test]
5093    fn parse_component_control_rejects_invalid_marker() {
5094        let payload = json!({
5095            "greentic_control": "bad-shape"
5096        });
5097        let err = parse_component_control(&payload).expect_err("expected invalid marker error");
5098        assert!(err.to_string().contains("greentic_control"));
5099    }
5100
5101    #[test]
5102    fn missing_operation_reports_node_and_component() {
5103        let engine = minimal_engine();
5104        let rt = Runtime::new().unwrap();
5105        let retry_config = RetryConfig {
5106            max_attempts: 1,
5107            base_delay_ms: 1,
5108        };
5109        let ctx = FlowContext {
5110            tenant: "tenant",
5111            pack_id: "test-pack",
5112            flow_id: "flow",
5113            node_id: Some("missing-op"),
5114            tool: None,
5115            action: None,
5116            session_id: None,
5117            provider_id: None,
5118            reply_scope: None,
5119            retry_config,
5120            attempt: 1,
5121            observer: None,
5122            mocks: None,
5123        };
5124        let node = HostNode {
5125            kind: NodeKind::Exec {
5126                target_component: "qa.process".into(),
5127            },
5128            component: "component.exec".into(),
5129            component_id: "component.exec".into(),
5130            operation_name: None,
5131            operation_in_mapping: None,
5132            payload_expr: Value::Null,
5133            routing: Routing::End,
5134            vars_out: None,
5135        };
5136        let _state = ExecutionState::new(Value::Null);
5137        let payload = json!({ "component": "qa.process" });
5138        let event = NodeEvent {
5139            context: &ctx,
5140            node_id: "missing-op",
5141            node: &node,
5142            payload: &payload,
5143        };
5144        let err = rt
5145            .block_on(engine.execute_component_exec(
5146                &ctx,
5147                "missing-op",
5148                &node,
5149                payload.clone(),
5150                &event,
5151                ComponentOverrides {
5152                    component: None,
5153                    operation: None,
5154                },
5155            ))
5156            .unwrap_err();
5157        let message = err.to_string();
5158        assert!(
5159            message.contains("missing operation for node `missing-op`"),
5160            "unexpected message: {message}"
5161        );
5162        assert!(
5163            message.contains("(component `component.exec`)"),
5164            "unexpected message: {message}"
5165        );
5166    }
5167
5168    #[test]
5169    fn missing_operation_mentions_mapping_hint() {
5170        let engine = minimal_engine();
5171        let rt = Runtime::new().unwrap();
5172        let retry_config = RetryConfig {
5173            max_attempts: 1,
5174            base_delay_ms: 1,
5175        };
5176        let ctx = FlowContext {
5177            tenant: "tenant",
5178            pack_id: "test-pack",
5179            flow_id: "flow",
5180            node_id: Some("missing-op-hint"),
5181            tool: None,
5182            action: None,
5183            session_id: None,
5184            provider_id: None,
5185            reply_scope: None,
5186            retry_config,
5187            attempt: 1,
5188            observer: None,
5189            mocks: None,
5190        };
5191        let node = HostNode {
5192            kind: NodeKind::Exec {
5193                target_component: "qa.process".into(),
5194            },
5195            component: "component.exec".into(),
5196            component_id: "component.exec".into(),
5197            operation_name: None,
5198            operation_in_mapping: Some("render".into()),
5199            payload_expr: Value::Null,
5200            routing: Routing::End,
5201            vars_out: None,
5202        };
5203        let _state = ExecutionState::new(Value::Null);
5204        let payload = json!({ "component": "qa.process" });
5205        let event = NodeEvent {
5206            context: &ctx,
5207            node_id: "missing-op-hint",
5208            node: &node,
5209            payload: &payload,
5210        };
5211        let err = rt
5212            .block_on(engine.execute_component_exec(
5213                &ctx,
5214                "missing-op-hint",
5215                &node,
5216                payload.clone(),
5217                &event,
5218                ComponentOverrides {
5219                    component: None,
5220                    operation: None,
5221                },
5222            ))
5223            .unwrap_err();
5224        let message = err.to_string();
5225        assert!(
5226            message.contains("missing operation for node `missing-op-hint`"),
5227            "unexpected message: {message}"
5228        );
5229        assert!(
5230            message.contains("Found operation in input.mapping (`render`)"),
5231            "unexpected message: {message}"
5232        );
5233    }
5234
5235    struct CountingObserver {
5236        starts: Mutex<Vec<String>>,
5237        ends: Mutex<Vec<Value>>,
5238    }
5239
5240    impl CountingObserver {
5241        fn new() -> Self {
5242            Self {
5243                starts: Mutex::new(Vec::new()),
5244                ends: Mutex::new(Vec::new()),
5245            }
5246        }
5247    }
5248
5249    impl ExecutionObserver for CountingObserver {
5250        fn on_node_start(&self, event: &NodeEvent<'_>) {
5251            self.starts.lock().unwrap().push(event.node_id.to_string());
5252        }
5253
5254        fn on_node_end(&self, _event: &NodeEvent<'_>, output: &Value) {
5255            self.ends.lock().unwrap().push(output.clone());
5256        }
5257
5258        fn on_node_error(&self, _event: &NodeEvent<'_>, _error: &dyn StdError) {}
5259    }
5260
5261    #[test]
5262    fn emits_end_event_for_successful_node() {
5263        let node_id = NodeId::from_str("emit").unwrap();
5264        let node = Node {
5265            id: node_id.clone(),
5266            component: FlowComponentRef {
5267                id: "emit.log".parse().unwrap(),
5268                pack_alias: None,
5269                operation: None,
5270            },
5271            input: InputMapping {
5272                mapping: json!({ "message": "logged" }),
5273            },
5274            output: OutputMapping {
5275                mapping: Value::Null,
5276            },
5277            err_map: None,
5278            routing: Routing::End,
5279            telemetry: TelemetryHints::default(),
5280            conversational: false,
5281        };
5282        let mut nodes = indexmap::IndexMap::default();
5283        nodes.insert(node_id.clone(), node);
5284        let flow = Flow {
5285            schema_version: "1.0".into(),
5286            id: FlowId::from_str("emit.flow").unwrap(),
5287            kind: FlowKind::Messaging,
5288            entrypoints: BTreeMap::from([(
5289                "default".to_string(),
5290                Value::String(node_id.to_string()),
5291            )]),
5292            nodes,
5293            metadata: Default::default(),
5294        };
5295        let host_flow = HostFlow::from(flow);
5296
5297        let engine = FlowEngine {
5298            packs: Vec::new(),
5299            flows: Vec::new(),
5300            flow_sources: HashMap::new(),
5301            messaging_provider_pack_ids: std::collections::HashSet::new(),
5302            flow_cache: RwLock::new(HashMap::from([(
5303                FlowKey {
5304                    pack_id: "test-pack".to_string(),
5305                    flow_id: "emit.flow".to_string(),
5306                },
5307                host_flow,
5308            )])),
5309            default_env: "local".to_string(),
5310            validation: ValidationConfig {
5311                mode: ValidationMode::Off,
5312            },
5313            cross_pack_resolver: None,
5314            rollout_ids: RolloutIds::default(),
5315            remote_dispatch_handler: None,
5316            #[cfg(feature = "agentic-worker")]
5317            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
5318            #[cfg(feature = "agentic-worker")]
5319            agent_node_handler: None,
5320            #[cfg(feature = "agentic-worker")]
5321            graph_node_handler: None,
5322            #[cfg(feature = "agentic-worker")]
5323            mcp_tool_source: None,
5324        };
5325        let observer = CountingObserver::new();
5326        let ctx = FlowContext {
5327            tenant: "demo",
5328            pack_id: "test-pack",
5329            flow_id: "emit.flow",
5330            node_id: None,
5331            tool: None,
5332            action: None,
5333            session_id: None,
5334            provider_id: None,
5335            reply_scope: None,
5336            retry_config: RetryConfig {
5337                max_attempts: 1,
5338                base_delay_ms: 1,
5339            },
5340            attempt: 1,
5341            observer: Some(&observer),
5342            mocks: None,
5343        };
5344
5345        let rt = Runtime::new().unwrap();
5346        let result = rt.block_on(engine.execute(ctx, Value::Null)).unwrap();
5347        assert!(matches!(result.status, FlowStatus::Completed));
5348
5349        let starts = observer.starts.lock().unwrap();
5350        let ends = observer.ends.lock().unwrap();
5351        assert_eq!(starts.len(), 1);
5352        assert_eq!(ends.len(), 1);
5353        assert_eq!(ends[0], json!({ "message": "logged" }));
5354    }
5355
5356    #[test]
5357    fn dotted_component_id_with_mapping_operation_is_not_split() {
5358        // greentic-pack resolves a component node to a bare component symbol and
5359        // keeps the operation in the input mapping. The runtime must NOT split the
5360        // dotted symbol on the last dot (which would yield `ai.greentic`, "not
5361        // found in pack"); the structured mapping operation makes the id a
5362        // complete reference.
5363        let node = Node {
5364            id: NodeId::from_str("render").unwrap(),
5365            component: FlowComponentRef {
5366                id: "ai.greentic.component-templates".parse().unwrap(),
5367                pack_alias: None,
5368                operation: None,
5369            },
5370            input: InputMapping {
5371                mapping: json!({ "operation": "handle_message", "input": "hi" }),
5372            },
5373            output: OutputMapping {
5374                mapping: Value::Null,
5375            },
5376            err_map: None,
5377            routing: Routing::End,
5378            telemetry: TelemetryHints::default(),
5379            conversational: false,
5380        };
5381        let host = HostNode::from(node);
5382        assert!(
5383            matches!(&host.kind, NodeKind::PackComponent { component_ref } if component_ref == "ai.greentic.component-templates"),
5384            "dotted component id must stay intact, got kind {:?}",
5385            host.kind
5386        );
5387        assert_eq!(host.component, "ai.greentic.component-templates");
5388        assert_eq!(host.operation_in_mapping(), Some("handle_message"));
5389    }
5390
5391    #[test]
5392    fn packed_component_operation_id_still_splits_without_mapping_operation() {
5393        // Legacy encoding: the operation is packed into the id as
5394        // `<component>.<operation>` and absent from the mapping. The last-dot
5395        // split must still recover it.
5396        let node = Node {
5397            id: NodeId::from_str("render").unwrap(),
5398            component: FlowComponentRef {
5399                id: "templating.handlebars".parse().unwrap(),
5400                pack_alias: None,
5401                operation: None,
5402            },
5403            input: InputMapping {
5404                mapping: json!({ "text": "hello" }),
5405            },
5406            output: OutputMapping {
5407                mapping: Value::Null,
5408            },
5409            err_map: None,
5410            routing: Routing::End,
5411            telemetry: TelemetryHints::default(),
5412            conversational: false,
5413        };
5414        let host = HostNode::from(node);
5415        assert!(
5416            matches!(&host.kind, NodeKind::PackComponent { component_ref } if component_ref == "templating"),
5417            "packed <component>.<operation> id must split, got kind {:?}",
5418            host.kind
5419        );
5420        assert_eq!(host.operation_name(), Some("handlebars"));
5421    }
5422
5423    #[cfg(feature = "agentic-worker")]
5424    #[test]
5425    fn dw_agent_node_routes_to_handler_and_returns_reply() {
5426        use crate::runner::agent_node::{AgentNodeHandler, RuntimeAgentNodeHandler};
5427        use greentic_aw_runtime::cost::MockTokenMeter;
5428        use greentic_aw_runtime::llm::LlmResponse;
5429        use greentic_aw_runtime::mock::{
5430            MockAgentStateStore, MockConfigProvider, MockLlmBackend, MockTelemetry, NoopToolLedger,
5431        };
5432        use greentic_aw_runtime::{
5433            AgentConfig, AgentLimits, AgentRuntime, LlmProviderRef, TenantContext,
5434        };
5435
5436        // --- mock-backed AgentRuntime: the LLM replies "pong" in one step ---
5437        let llm = Arc::new(MockLlmBackend::new(vec![Ok(LlmResponse {
5438            content: Some("pong".into()),
5439            tool_calls: vec![],
5440            tokens_in: 1,
5441            tokens_out: 1,
5442        })]));
5443        let store = Arc::new(MockAgentStateStore::new());
5444        let telemetry = Arc::new(MockTelemetry::new());
5445
5446        // The dispatch builds TenantContext::new(ctx.tenant, default_env) =
5447        // ("demo", "local"). MockConfigProvider keys by
5448        // `format!("{}:{agent_id}", tenant.key_prefix())` = "aw:demo:local:greeter",
5449        // so seed with the SAME tenant+env+agent_id the engine will look up.
5450        let config_provider = MockConfigProvider::new();
5451        let tenant = TenantContext::new("demo", "local");
5452        config_provider.insert(
5453            &tenant,
5454            "greeter",
5455            AgentConfig {
5456                agent_id: "greeter".into(),
5457                system_prompt: "sys".into(),
5458                tools: vec![],
5459                guardrails: vec![],
5460                llm: LlmProviderRef {
5461                    provider: "mock".into(),
5462                    model: "m".into(),
5463                    credential_ref: None,
5464                },
5465                limits: AgentLimits::default(),
5466                memory: None,
5467                knowledge: None,
5468            },
5469        );
5470        let config_provider = Arc::new(config_provider);
5471        let token_meter = Arc::new(MockTokenMeter::new(0));
5472        let ledger = Arc::new(NoopToolLedger);
5473        let ext_runtime = Arc::new(crate::runner::agent_node::test_extension_runtime());
5474        let runtime = Arc::new(AgentRuntime::new(
5475            config_provider,
5476            store,
5477            ext_runtime,
5478            llm,
5479            telemetry,
5480            token_meter,
5481            ledger,
5482            None,
5483        ));
5484        let handler: Arc<dyn AgentNodeHandler> =
5485            Arc::new(RuntimeAgentNodeHandler::new(runtime, None, None));
5486
5487        // --- flow with a single dw.agent node (operation = agent_id) ---
5488        let node_id = NodeId::from_str("agent").unwrap();
5489        let node = Node {
5490            id: node_id.clone(),
5491            component: FlowComponentRef {
5492                id: "dw.agent".parse().unwrap(),
5493                pack_alias: None,
5494                operation: Some("greeter".to_string()),
5495            },
5496            input: InputMapping {
5497                mapping: json!({ "user_text": "ping" }),
5498            },
5499            output: OutputMapping {
5500                mapping: Value::Null,
5501            },
5502            err_map: None,
5503            routing: Routing::End,
5504            telemetry: TelemetryHints::default(),
5505            conversational: false,
5506        };
5507        let mut nodes = indexmap::IndexMap::default();
5508        nodes.insert(node_id.clone(), node);
5509        let flow = Flow {
5510            schema_version: "1.0".into(),
5511            id: FlowId::from_str("dw.flow").unwrap(),
5512            kind: FlowKind::Messaging,
5513            entrypoints: BTreeMap::from([(
5514                "default".to_string(),
5515                Value::String(node_id.to_string()),
5516            )]),
5517            nodes,
5518            metadata: Default::default(),
5519        };
5520        let host_flow = HostFlow::from(flow);
5521
5522        let engine = FlowEngine {
5523            packs: Vec::new(),
5524            flows: Vec::new(),
5525            flow_sources: HashMap::new(),
5526            messaging_provider_pack_ids: std::collections::HashSet::new(),
5527            flow_cache: RwLock::new(HashMap::from([(
5528                FlowKey {
5529                    pack_id: "test-pack".to_string(),
5530                    flow_id: "dw.flow".to_string(),
5531                },
5532                host_flow,
5533            )])),
5534            default_env: "local".to_string(),
5535            validation: ValidationConfig {
5536                mode: ValidationMode::Off,
5537            },
5538            cross_pack_resolver: None,
5539            rollout_ids: RolloutIds::default(),
5540            remote_dispatch_handler: None,
5541            #[cfg(feature = "agentic-worker")]
5542            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
5543            #[cfg(feature = "agentic-worker")]
5544            agent_node_handler: Some(handler),
5545            #[cfg(feature = "agentic-worker")]
5546            graph_node_handler: None,
5547            #[cfg(feature = "agentic-worker")]
5548            mcp_tool_source: None,
5549        };
5550        let ctx = FlowContext {
5551            tenant: "demo",
5552            pack_id: "test-pack",
5553            flow_id: "dw.flow",
5554            node_id: None,
5555            tool: None,
5556            action: None,
5557            session_id: Some("sess-1"),
5558            provider_id: None,
5559            reply_scope: None,
5560            retry_config: RetryConfig {
5561                max_attempts: 1,
5562                base_delay_ms: 1,
5563            },
5564            attempt: 1,
5565            observer: None,
5566            mocks: None,
5567        };
5568
5569        let rt = Runtime::new().unwrap();
5570        let result = rt
5571            .block_on(engine.execute(ctx, json!({ "user_text": "ping" })))
5572            .unwrap();
5573        assert!(matches!(result.status, FlowStatus::Completed));
5574
5575        // The dw.agent node output is {"reply", "trail", "terminated_by"}; the
5576        // engine finalises a single-node flow's egress into an array wrapping it.
5577        let output_str = serde_json::to_string(&result.output).unwrap();
5578        assert!(
5579            output_str.contains("pong"),
5580            "expected agent reply in flow output, got: {output_str}"
5581        );
5582    }
5583
5584    /// Engine twin of [`dw_agent_node_routes_to_handler_and_returns_reply`]:
5585    /// asserts a `dw.agent_graph` node is detected, routed to the configured
5586    /// [`GraphNodeHandler`] with the engine-derived tenant/env/session and the
5587    /// node's `operation` as the `graph_id`, and its reply lands in the flow
5588    /// output. A lightweight recording stub stands in for the durable executor.
5589    #[cfg(feature = "agentic-worker")]
5590    #[test]
5591    fn dw_agent_graph_node_routes_to_handler_and_returns_reply() {
5592        use std::sync::Mutex;
5593
5594        use crate::runner::graph_node::GraphNodeHandler;
5595
5596        /// Records the dispatch arguments and returns a fixed DwAgent envelope.
5597        struct RecordingGraphHandler {
5598            seen: Mutex<Option<(String, String, String, String)>>,
5599        }
5600
5601        #[async_trait::async_trait]
5602        impl GraphNodeHandler for RecordingGraphHandler {
5603            async fn execute(
5604                &self,
5605                tenant_id: &str,
5606                env_id: &str,
5607                graph_id: &str,
5608                session_id: &str,
5609                _flow_input: &Value,
5610            ) -> Result<Value> {
5611                *self.seen.lock().unwrap() = Some((
5612                    tenant_id.to_string(),
5613                    env_id.to_string(),
5614                    graph_id.to_string(),
5615                    session_id.to_string(),
5616                ));
5617                Ok(json!({
5618                    "reply": "graph-pong",
5619                    "trail": [],
5620                    "terminated_by": "respond",
5621                }))
5622            }
5623        }
5624
5625        let handler = Arc::new(RecordingGraphHandler {
5626            seen: Mutex::new(None),
5627        });
5628        let handler_dyn: Arc<dyn GraphNodeHandler> = handler.clone();
5629
5630        // --- flow with a single dw.agent_graph node (operation = graph_id) ---
5631        let node_id = NodeId::from_str("graph").unwrap();
5632        let node = Node {
5633            id: node_id.clone(),
5634            component: FlowComponentRef {
5635                id: "dw.agent_graph".parse().unwrap(),
5636                pack_alias: None,
5637                operation: Some("triage".to_string()),
5638            },
5639            input: InputMapping {
5640                mapping: json!({ "user_text": "ping" }),
5641            },
5642            output: OutputMapping {
5643                mapping: Value::Null,
5644            },
5645            err_map: None,
5646            routing: Routing::End,
5647            telemetry: TelemetryHints::default(),
5648            conversational: false,
5649        };
5650        let mut nodes = indexmap::IndexMap::default();
5651        nodes.insert(node_id.clone(), node);
5652        let flow = Flow {
5653            schema_version: "1.0".into(),
5654            id: FlowId::from_str("dwg.flow").unwrap(),
5655            kind: FlowKind::Messaging,
5656            entrypoints: BTreeMap::from([(
5657                "default".to_string(),
5658                Value::String(node_id.to_string()),
5659            )]),
5660            nodes,
5661            metadata: Default::default(),
5662        };
5663        let host_flow = HostFlow::from(flow);
5664
5665        let engine = FlowEngine {
5666            packs: Vec::new(),
5667            flows: Vec::new(),
5668            flow_sources: HashMap::new(),
5669            messaging_provider_pack_ids: std::collections::HashSet::new(),
5670            flow_cache: RwLock::new(HashMap::from([(
5671                FlowKey {
5672                    pack_id: "test-pack".to_string(),
5673                    flow_id: "dwg.flow".to_string(),
5674                },
5675                host_flow,
5676            )])),
5677            default_env: "local".to_string(),
5678            validation: ValidationConfig {
5679                mode: ValidationMode::Off,
5680            },
5681            cross_pack_resolver: None,
5682            rollout_ids: RolloutIds::default(),
5683            remote_dispatch_handler: None,
5684            #[cfg(feature = "agentic-worker")]
5685            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
5686            #[cfg(feature = "agentic-worker")]
5687            agent_node_handler: None,
5688            #[cfg(feature = "agentic-worker")]
5689            graph_node_handler: Some(handler_dyn),
5690            #[cfg(feature = "agentic-worker")]
5691            mcp_tool_source: None,
5692        };
5693        let ctx = FlowContext {
5694            tenant: "demo",
5695            pack_id: "test-pack",
5696            flow_id: "dwg.flow",
5697            node_id: None,
5698            tool: None,
5699            action: None,
5700            session_id: Some("sess-1"),
5701            provider_id: None,
5702            reply_scope: None,
5703            retry_config: RetryConfig {
5704                max_attempts: 1,
5705                base_delay_ms: 1,
5706            },
5707            attempt: 1,
5708            observer: None,
5709            mocks: None,
5710        };
5711
5712        let rt = Runtime::new().unwrap();
5713        let result = rt
5714            .block_on(engine.execute(ctx, json!({ "user_text": "ping" })))
5715            .unwrap();
5716        assert!(matches!(result.status, FlowStatus::Completed));
5717
5718        // The handler must have been called with the engine-derived
5719        // tenant/env/session and the node's operation as graph_id.
5720        let seen = handler.seen.lock().unwrap().clone();
5721        assert_eq!(
5722            seen,
5723            Some((
5724                "demo".to_string(),
5725                "local".to_string(),
5726                "triage".to_string(),
5727                "sess-1".to_string(),
5728            )),
5729            "dw.agent_graph dispatch must mirror dw.agent's tenant/env/graph_id/session derivation"
5730        );
5731
5732        let output_str = serde_json::to_string(&result.output).unwrap();
5733        assert!(
5734            output_str.contains("graph-pong"),
5735            "expected graph reply in flow output, got: {output_str}"
5736        );
5737    }
5738
5739    /// When `GREENTIC_AW_DISPATCH=nats` is set, a `dw.agent` node must be
5740    /// rerouted through the remote-dispatch path (`"agentic"` runtime) rather
5741    /// than calling the in-process `AgentNodeHandler`. The node payload is
5742    /// wrapped as `input`, `await=true` is injected, and the engine pauses
5743    /// (returns a wait outcome, not a complete one).
5744    #[cfg(feature = "agentic-worker")]
5745    #[test]
5746    fn dw_agent_nats_mode_dispatches_remote() {
5747        use std::sync::Mutex;
5748
5749        use crate::runner::agent_node::DwAgentDispatch;
5750        use crate::runner::remote_dispatch::{
5751            RemoteDispatch, RemoteDispatchAction, RemoteDispatchHandler,
5752        };
5753
5754        /// Recording stub: captures the last dispatch and returns
5755        /// `AwaitingResponse` so the engine pauses.
5756        struct RecordingDispatcher {
5757            seen: Mutex<Option<RemoteDispatch>>,
5758        }
5759
5760        #[async_trait::async_trait]
5761        impl RemoteDispatchHandler for RecordingDispatcher {
5762            async fn dispatch(
5763                &self,
5764                request: RemoteDispatch,
5765            ) -> anyhow::Result<RemoteDispatchAction> {
5766                let corr = request.correlation_id.clone();
5767                *self.seen.lock().unwrap() = Some(request);
5768                Ok(RemoteDispatchAction::AwaitingResponse {
5769                    correlation_id: corr,
5770                })
5771            }
5772        }
5773
5774        let dispatcher = Arc::new(RecordingDispatcher {
5775            seen: Mutex::new(None),
5776        });
5777
5778        // --- two-node flow: dw.agent → emit (resume target) ---
5779        // The agent node must have Routing::Next so the engine knows where to
5780        // resume once the async response arrives (same requirement as sorla.call /
5781        // agentic.call nodes in production).
5782        let resume_id = NodeId::from_str("after-agent").unwrap();
5783        let node_id = NodeId::from_str("agent-nats").unwrap();
5784        let agent_node = Node {
5785            id: node_id.clone(),
5786            component: FlowComponentRef {
5787                id: "dw.agent".parse().unwrap(),
5788                pack_alias: None,
5789                operation: Some("greeter".to_string()),
5790            },
5791            input: InputMapping {
5792                mapping: json!({ "user_text": "hi" }),
5793            },
5794            output: OutputMapping {
5795                mapping: Value::Null,
5796            },
5797            err_map: None,
5798            routing: Routing::Next {
5799                node_id: resume_id.clone(),
5800            },
5801            telemetry: TelemetryHints::default(),
5802            conversational: false,
5803        };
5804        let resume_node = Node {
5805            id: resume_id.clone(),
5806            component: FlowComponentRef {
5807                id: "emit.log".parse().unwrap(),
5808                pack_alias: None,
5809                operation: None,
5810            },
5811            input: InputMapping {
5812                mapping: json!({ "message": "done" }),
5813            },
5814            output: OutputMapping {
5815                mapping: Value::Null,
5816            },
5817            err_map: None,
5818            routing: Routing::End,
5819            telemetry: TelemetryHints::default(),
5820            conversational: false,
5821        };
5822        let mut nodes = indexmap::IndexMap::default();
5823        nodes.insert(node_id.clone(), agent_node);
5824        nodes.insert(resume_id.clone(), resume_node);
5825        let flow = Flow {
5826            schema_version: "1.0".into(),
5827            id: FlowId::from_str("nats-agent.flow").unwrap(),
5828            kind: FlowKind::Messaging,
5829            entrypoints: BTreeMap::from([(
5830                "default".to_string(),
5831                Value::String(node_id.to_string()),
5832            )]),
5833            nodes,
5834            metadata: Default::default(),
5835        };
5836        let host_flow = HostFlow::from(flow);
5837
5838        let engine = FlowEngine {
5839            packs: Vec::new(),
5840            flows: Vec::new(),
5841            flow_sources: HashMap::new(),
5842            messaging_provider_pack_ids: std::collections::HashSet::new(),
5843            flow_cache: RwLock::new(HashMap::from([(
5844                FlowKey {
5845                    pack_id: "test-pack".to_string(),
5846                    flow_id: "nats-agent.flow".to_string(),
5847                },
5848                host_flow,
5849            )])),
5850            default_env: "local".to_string(),
5851            validation: ValidationConfig {
5852                mode: ValidationMode::Off,
5853            },
5854            cross_pack_resolver: None,
5855            rollout_ids: RolloutIds::default(),
5856            remote_dispatch_handler: Some(dispatcher.clone() as Arc<dyn crate::runner::remote_dispatch::RemoteDispatchHandler>),
5857            #[cfg(feature = "agentic-worker")]
5858            dw_agent_dispatch: DwAgentDispatch::Nats,
5859            #[cfg(feature = "agentic-worker")]
5860            // No in-process handler wired — Nats path must NOT call it.
5861            agent_node_handler: None,
5862            #[cfg(feature = "agentic-worker")]
5863            graph_node_handler: None,
5864            #[cfg(feature = "agentic-worker")]
5865            mcp_tool_source: None,
5866        };
5867
5868        let ctx = FlowContext {
5869            tenant: "demo",
5870            pack_id: "test-pack",
5871            flow_id: "nats-agent.flow",
5872            node_id: None,
5873            tool: None,
5874            action: None,
5875            session_id: Some("sess-nats"),
5876            provider_id: None,
5877            reply_scope: None,
5878            retry_config: RetryConfig {
5879                max_attempts: 1,
5880                base_delay_ms: 1,
5881            },
5882            attempt: 1,
5883            observer: None,
5884            mocks: None,
5885        };
5886
5887        let rt = Runtime::new().unwrap();
5888        let result = rt
5889            .block_on(engine.execute(ctx, json!({ "user_text": "hi" })))
5890            .unwrap();
5891
5892        // The Nats path pauses the flow (await=true → DispatchOutcome::wait).
5893        assert!(
5894            matches!(result.status, FlowStatus::Waiting(_)),
5895            "expected Waiting outcome from dw.agent Nats mode, got: {:?}",
5896            result.status
5897        );
5898
5899        // The dispatcher must have been called with runtime="agentic" and
5900        // target=<agent_id>, and the node payload wrapped as `input`.
5901        let seen = dispatcher.seen.lock().unwrap();
5902        let dispatch = seen.as_ref().expect("dispatcher was not called");
5903        assert_eq!(
5904            dispatch.runtime, "agentic",
5905            "runtime name must be 'agentic'"
5906        );
5907        assert_eq!(dispatch.target, "greeter", "target must be the agent_id");
5908        assert_eq!(
5909            dispatch.input,
5910            json!({ "user_text": "hi" }),
5911            "node payload must be forwarded as dispatch input"
5912        );
5913    }
5914
5915    fn host_flow_for_test(
5916        flow_id: &str,
5917        node_ids: &[&str],
5918        default_start: Option<&str>,
5919    ) -> HostFlow {
5920        let mut nodes = indexmap::IndexMap::default();
5921        for node_id in node_ids {
5922            let id = NodeId::from_str(node_id).unwrap();
5923            let node = Node {
5924                id: id.clone(),
5925                component: FlowComponentRef {
5926                    id: "emit.log".parse().unwrap(),
5927                    pack_alias: None,
5928                    operation: None,
5929                },
5930                input: InputMapping {
5931                    mapping: json!({ "message": node_id }),
5932                },
5933                output: OutputMapping {
5934                    mapping: Value::Null,
5935                },
5936                err_map: None,
5937                routing: Routing::End,
5938                telemetry: TelemetryHints::default(),
5939                conversational: false,
5940            };
5941            nodes.insert(id, node);
5942        }
5943        let mut entrypoints = BTreeMap::new();
5944        if let Some(start) = default_start {
5945            entrypoints.insert("default".to_string(), Value::String(start.to_string()));
5946        }
5947        HostFlow::from(Flow {
5948            schema_version: "1.0".into(),
5949            id: FlowId::from_str(flow_id).unwrap(),
5950            kind: FlowKind::Messaging,
5951            entrypoints,
5952            nodes,
5953            metadata: Default::default(),
5954        })
5955    }
5956
5957    fn jump_test_engine() -> FlowEngine {
5958        let target_flow = host_flow_for_test("flow.target", &["node-a", "node-b"], None);
5959        FlowEngine {
5960            packs: Vec::new(),
5961            flows: Vec::new(),
5962            flow_sources: HashMap::new(),
5963            messaging_provider_pack_ids: std::collections::HashSet::new(),
5964            flow_cache: RwLock::new(HashMap::from([(
5965                FlowKey {
5966                    pack_id: "test-pack".to_string(),
5967                    flow_id: "flow.target".to_string(),
5968                },
5969                target_flow,
5970            )])),
5971            default_env: "local".to_string(),
5972            validation: ValidationConfig {
5973                mode: ValidationMode::Off,
5974            },
5975            cross_pack_resolver: None,
5976            rollout_ids: RolloutIds::default(),
5977            remote_dispatch_handler: None,
5978            #[cfg(feature = "agentic-worker")]
5979            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
5980            #[cfg(feature = "agentic-worker")]
5981            agent_node_handler: None,
5982            #[cfg(feature = "agentic-worker")]
5983            graph_node_handler: None,
5984            #[cfg(feature = "agentic-worker")]
5985            mcp_tool_source: None,
5986        }
5987    }
5988
5989    fn jump_ctx<'a>(flow_id: &'a str) -> FlowContext<'a> {
5990        FlowContext {
5991            tenant: "demo",
5992            pack_id: "test-pack",
5993            flow_id,
5994            node_id: None,
5995            tool: None,
5996            action: None,
5997            session_id: None,
5998            provider_id: None,
5999            reply_scope: None,
6000            retry_config: RetryConfig {
6001                max_attempts: 1,
6002                base_delay_ms: 1,
6003            },
6004            attempt: 1,
6005            observer: None,
6006            mocks: None,
6007        }
6008    }
6009
6010    #[test]
6011    fn with_rollout_ids_binds_revision_identity() {
6012        let engine = minimal_engine().with_rollout_ids(RolloutIds {
6013            customer_id: Some("cust-acme".into()),
6014            deployment_id: Some("01JTKS".into()),
6015            bundle_id: Some("customer.support".into()),
6016            revision_id: Some("01JTKR".into()),
6017        });
6018        assert_eq!(engine.rollout_ids.revision_id.as_deref(), Some("01JTKR"));
6019        assert_eq!(engine.rollout_ids.deployment_id.as_deref(), Some("01JTKS"));
6020        // A freshly-built engine carries no rollout identity (legacy runtime).
6021        assert!(minimal_engine().rollout_ids.is_empty());
6022    }
6023
6024    /// The composition that matters: what a `flow.goto` NODE produces is a jump
6025    /// the engine actually applies. Proven end to end through `apply_jump`
6026    /// rather than by inspecting the control alone — a `JumpControl` the engine
6027    /// would reject is not a working transfer.
6028    ///
6029    /// The target flow's start node is selected, the handed-over payload
6030    /// becomes the target's input, and the redirect counter advances, which is
6031    /// what makes a goto loop terminate instead of spinning.
6032    #[test]
6033    fn a_flow_goto_node_produces_a_jump_the_engine_applies() {
6034        let outcome = execute_flow_goto(json!({
6035            "flow_id": "flow.target",
6036            "input": { "order": "A-1" },
6037        }))
6038        .expect("goto builds");
6039        let NodeControl::Jump(jump) = outcome.control else {
6040            panic!("expected a Jump");
6041        };
6042
6043        let engine = jump_test_engine();
6044        let mut state = ExecutionState::new(Value::Null);
6045        let rt = Runtime::new().unwrap();
6046        let target = rt
6047            .block_on(engine.apply_jump(&jump_ctx("flow.source"), &mut state, jump))
6048            .expect("the engine must accept the jump a flow.goto node builds");
6049
6050        assert_eq!(target.flow_id, "flow.target");
6051        assert_eq!(
6052            target.node_id.as_str(),
6053            "node-a",
6054            "no explicit node means the target flow's first node"
6055        );
6056        assert_eq!(
6057            state.redirect_count(),
6058            1,
6059            "the loop guard must have counted"
6060        );
6061    }
6062
6063    /// An explicit entry node is honoured, so a menu can hand over to the exact
6064    /// step that answers the option the user picked.
6065    #[test]
6066    fn a_flow_goto_node_can_name_the_entry_node() {
6067        let outcome = execute_flow_goto(json!({ "flow_id": "flow.target", "node": "node-b" }))
6068            .expect("goto builds");
6069        let NodeControl::Jump(jump) = outcome.control else {
6070            panic!("expected a Jump");
6071        };
6072
6073        let engine = jump_test_engine();
6074        let mut state = ExecutionState::new(Value::Null);
6075        let rt = Runtime::new().unwrap();
6076        let target = rt
6077            .block_on(engine.apply_jump(&jump_ctx("flow.source"), &mut state, jump))
6078            .expect("jump applies");
6079        assert_eq!(target.node_id.as_str(), "node-b");
6080    }
6081
6082    #[test]
6083    fn apply_jump_unknown_flow_errors() {
6084        let engine = minimal_engine();
6085        let mut state = ExecutionState::new(Value::Null);
6086        let rt = Runtime::new().unwrap();
6087        let err = rt
6088            .block_on(engine.apply_jump(
6089                &jump_ctx("flow.source"),
6090                &mut state,
6091                JumpControl {
6092                    flow: "flow.missing".into(),
6093                    node: None,
6094                    payload: json!({ "ok": true }),
6095                    hints: Value::Null,
6096                    max_redirects: None,
6097                    reason: None,
6098                },
6099            ))
6100            .unwrap_err();
6101        assert!(
6102            err.to_string().contains("unknown_flow"),
6103            "unexpected error: {err}"
6104        );
6105    }
6106
6107    #[test]
6108    fn apply_jump_unknown_node_errors() {
6109        let engine = jump_test_engine();
6110        let mut state = ExecutionState::new(Value::Null);
6111        let rt = Runtime::new().unwrap();
6112        let err = rt
6113            .block_on(engine.apply_jump(
6114                &jump_ctx("flow.source"),
6115                &mut state,
6116                JumpControl {
6117                    flow: "flow.target".into(),
6118                    node: Some("node-missing".into()),
6119                    payload: json!({ "ok": true }),
6120                    hints: Value::Null,
6121                    max_redirects: None,
6122                    reason: None,
6123                },
6124            ))
6125            .unwrap_err();
6126        assert!(
6127            err.to_string().contains("unknown_node"),
6128            "unexpected error: {err}"
6129        );
6130    }
6131
6132    #[test]
6133    fn apply_jump_uses_default_start_fallback() {
6134        let engine = jump_test_engine();
6135        let mut state = ExecutionState::new(Value::Null);
6136        let rt = Runtime::new().unwrap();
6137        let target = rt
6138            .block_on(engine.apply_jump(
6139                &jump_ctx("flow.source"),
6140                &mut state,
6141                JumpControl {
6142                    flow: "flow.target".into(),
6143                    node: None,
6144                    payload: json!({ "k": "v" }),
6145                    hints: Value::Null,
6146                    max_redirects: None,
6147                    reason: None,
6148                },
6149            ))
6150            .expect("jump target");
6151        assert_eq!(target.flow_id, "flow.target");
6152        assert_eq!(target.node_id.as_str(), "node-a");
6153    }
6154
6155    #[test]
6156    fn apply_jump_redirect_limit_enforced() {
6157        let engine = jump_test_engine();
6158        let mut state = ExecutionState::new(Value::Null);
6159        state.redirect_count = 3;
6160        let rt = Runtime::new().unwrap();
6161        let err = rt
6162            .block_on(engine.apply_jump(
6163                &jump_ctx("flow.source"),
6164                &mut state,
6165                JumpControl {
6166                    flow: "flow.target".into(),
6167                    node: None,
6168                    payload: json!({ "k": "v" }),
6169                    hints: Value::Null,
6170                    max_redirects: Some(3),
6171                    reason: None,
6172                },
6173            ))
6174            .unwrap_err();
6175        assert_eq!(err.to_string(), "redirect_limit");
6176    }
6177
6178    /// Regression: a `Routing::Custom` array containing at least one
6179    /// conditional entry must pause (return `Wait`) when no condition
6180    /// matches, instead of terminating. Concrete bug it guards against:
6181    /// every card click used to terminate the flow because the entry-card's
6182    /// routing array didn't enumerate every downstream action, so users got
6183    /// looped back to the entry on every interaction.
6184    #[test]
6185    fn evaluate_custom_routing_waits_when_conditional_falls_through() {
6186        let raw_routing = json!([
6187            { "condition": "response.action == \"go\"", "to": "next" },
6188            { "out": true }
6189        ]);
6190        let flow_ir = HostFlow {
6191            id: "flow.test".to_string(),
6192            start: None,
6193            nodes: IndexMap::new(),
6194            slot_schema: None,
6195            vars_init: JsonMap::new(),
6196        };
6197        let current_node = NodeId::from_str("current").unwrap();
6198        let output = NodeOutput::new(Value::Null);
6199
6200        // First case: empty action -> conditional does not match, must wait.
6201        let mut state_empty = ExecutionState::new(json!({ "metadata": { "action": "" } }));
6202        state_empty.entry = json!({ "metadata": { "action": "" } });
6203        let decision_empty =
6204            evaluate_custom_routing(&raw_routing, &output, &state_empty, &flow_ir, &current_node);
6205        assert!(
6206            matches!(decision_empty, CustomRoutingDecision::Wait),
6207            "expected Wait on conditional fall-through, got {decision_empty:?}"
6208        );
6209
6210        // Second case: action == "go" -> conditional matches, must advance.
6211        let mut state_go = ExecutionState::new(json!({ "metadata": { "action": "go" } }));
6212        state_go.entry = json!({ "metadata": { "action": "go" } });
6213        let decision_go =
6214            evaluate_custom_routing(&raw_routing, &output, &state_go, &flow_ir, &current_node);
6215        match decision_go {
6216            CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "next"),
6217            other => panic!("expected Next(\"next\"), got {other:?}"),
6218        }
6219    }
6220
6221    #[test]
6222    fn node_output_with_error_marks_ok_false_and_stashes_in_meta() {
6223        let err: Box<dyn std::error::Error + 'static> =
6224            Box::<dyn std::error::Error + 'static>::from("weatherapi returned 401 Unauthorized");
6225        let out = NodeOutput::with_error("call_weather", err.as_ref());
6226        assert!(!out.ok);
6227        assert_eq!(out.payload, Value::Null);
6228        assert_eq!(out.meta["error"]["kind"], "flow_node_failed");
6229        assert_eq!(out.meta["error"]["node_id"], "call_weather");
6230        assert_eq!(
6231            out.meta["error"]["message"],
6232            "weatherapi returned 401 Unauthorized"
6233        );
6234    }
6235
6236    /// A failed MCP call must mark the node not-ok so the already-wired
6237    /// `lift_first_node_error_from_nodes` has something to find.
6238    ///
6239    /// `mcp_node::invoke` is infallible — every failure arrives as
6240    /// `{"error": ...}` in the result — so the node used to report `ok: true`
6241    /// and the flow completed clean. That is what made a Digital Worker run
6242    /// show 33/33 green nodes with a blank quote.
6243    #[test]
6244    fn a_failed_mcp_call_marks_the_node_not_ok() {
6245        let result = json!({ "error": "MCP is not configured on this runner" });
6246        let bound = json!({ "quote_result_data": result.clone() });
6247
6248        let out = mcp_output(bound.clone(), &result);
6249
6250        assert!(!out.ok, "a failed MCP call must not report ok");
6251        assert_eq!(
6252            out.meta.pointer("/error/message").and_then(Value::as_str),
6253            Some("MCP is not configured on this runner"),
6254            "the message must reach meta.error where the lift reads it, got {:?}",
6255            out.meta
6256        );
6257        assert_eq!(
6258            out.payload, bound,
6259            "the bound payload must be untouched — routing and any flow \
6260             reading the bound value must behave exactly as before"
6261        );
6262    }
6263
6264    /// The success path must stay exactly as it was.
6265    #[test]
6266    fn a_successful_mcp_call_stays_ok() {
6267        let result = json!({ "annual_premium": 1234 });
6268        let bound = json!({ "quote_result_data": result.clone() });
6269
6270        let out = mcp_output(bound.clone(), &result);
6271
6272        assert!(out.ok, "a successful MCP call must report ok");
6273        assert_eq!(out.payload, bound);
6274        assert!(
6275            out.meta.get("error").is_none(),
6276            "a successful call must not stash an error, got {:?}",
6277            out.meta
6278        );
6279    }
6280
6281    #[test]
6282    fn lift_first_node_error_promotes_node_meta_to_output_metadata() {
6283        // Two nodes ran; the first failed, the second produced a default-
6284        // looking output (flow author wrote no error routing). The executor
6285        // must lift the first failure into output.metadata so the messaging
6286        // provider renders the error card without any flow-author changes.
6287        let mut nodes: HashMap<String, NodeOutput> = HashMap::new();
6288        let err: Box<dyn std::error::Error + 'static> =
6289            Box::<dyn std::error::Error + 'static>::from("weatherapi returned 401 Unauthorized");
6290        nodes.insert(
6291            "call_weather".to_string(),
6292            NodeOutput::with_error("call_weather", err.as_ref()),
6293        );
6294        nodes.insert(
6295            "render_current_card".to_string(),
6296            NodeOutput::new(json!({ "text": "message" })),
6297        );
6298
6299        let final_output = json!({ "text": "message" });
6300        let enriched = lift_first_node_error_from_nodes(final_output, &nodes);
6301        assert_eq!(
6302            enriched["metadata"]["error_kind"], "flow_node_failed",
6303            "first failing node's kind must be lifted"
6304        );
6305        assert_eq!(
6306            enriched["metadata"]["error_message"],
6307            "weatherapi returned 401 Unauthorized"
6308        );
6309        assert_eq!(enriched["metadata"]["node_id"], "call_weather");
6310        // Preserves the original payload bits so downstream renderers still
6311        // see what the flow produced.
6312        assert_eq!(enriched["text"], "message");
6313    }
6314
6315    #[test]
6316    fn lift_first_node_error_is_noop_when_all_nodes_ok() {
6317        let mut nodes: HashMap<String, NodeOutput> = HashMap::new();
6318        nodes.insert(
6319            "ok_node".to_string(),
6320            NodeOutput::new(json!({ "text": "all good" })),
6321        );
6322        let output = json!({ "text": "all good" });
6323        let lifted = lift_first_node_error_from_nodes(output.clone(), &nodes);
6324        assert_eq!(lifted, output);
6325    }
6326
6327    #[tokio::test]
6328    async fn execute_user_facing_flow_failure_returns_completed_with_error_envelope() {
6329        // Flow whose start node is missing — drive_flow will return Err on
6330        // node lookup. With session_id present, execute() must convert that
6331        // to a Completed FlowExecution carrying error_kind/error_message in
6332        // output.metadata so the chat user sees the error card.
6333        let flow_id_str = "broken.flow";
6334        let pack_id_str = "test-pack";
6335        let host_flow = host_flow_for_test(flow_id_str, &["only-node"], Some("does-not-exist"));
6336        let engine = FlowEngine {
6337            packs: Vec::new(),
6338            flows: Vec::new(),
6339            flow_sources: HashMap::new(),
6340            messaging_provider_pack_ids: std::collections::HashSet::new(),
6341            flow_cache: RwLock::new(HashMap::from([(
6342                FlowKey {
6343                    pack_id: pack_id_str.to_string(),
6344                    flow_id: flow_id_str.to_string(),
6345                },
6346                host_flow,
6347            )])),
6348            default_env: "local".to_string(),
6349            validation: ValidationConfig {
6350                mode: ValidationMode::Off,
6351            },
6352            cross_pack_resolver: None,
6353            rollout_ids: RolloutIds::default(),
6354            remote_dispatch_handler: None,
6355            #[cfg(feature = "agentic-worker")]
6356            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
6357            #[cfg(feature = "agentic-worker")]
6358            agent_node_handler: None,
6359            #[cfg(feature = "agentic-worker")]
6360            graph_node_handler: None,
6361            #[cfg(feature = "agentic-worker")]
6362            mcp_tool_source: None,
6363        };
6364        let ctx = FlowContext {
6365            tenant: "demo",
6366            pack_id: pack_id_str,
6367            flow_id: flow_id_str,
6368            node_id: None,
6369            tool: None,
6370            action: None,
6371            session_id: Some("conv-1"),
6372            provider_id: None,
6373            reply_scope: None,
6374            retry_config: RetryConfig {
6375                max_attempts: 1,
6376                base_delay_ms: 1,
6377            },
6378            attempt: 1,
6379            observer: None,
6380            mocks: None,
6381        };
6382        let result = engine
6383            .execute(ctx, Value::Null)
6384            .await
6385            .expect("must not propagate Err");
6386        assert!(matches!(result.status, FlowStatus::Completed));
6387        assert_eq!(
6388            result.output["metadata"]["error_kind"],
6389            "flow_execution_failed"
6390        );
6391        let msg = result.output["metadata"]["error_message"]
6392            .as_str()
6393            .unwrap_or("");
6394        assert!(!msg.is_empty(), "error_message must be populated");
6395        assert_eq!(result.output["metadata"]["flow_id"], "broken.flow");
6396    }
6397
6398    #[test]
6399    fn mcp_tool_error_recognises_generator_error_shape() {
6400        // greentic-mcp-generator's tool_error_with_status emits this exact
6401        // shape when the upstream HTTP call to weatherapi.com returns 401.
6402        let value = json!({
6403            "error": {
6404                "code": "tool_error",
6405                "message": "API request returned status 401",
6406                "status": 401
6407            }
6408        });
6409        let (code, message) = mcp_tool_error(&value).expect("must detect MCP error shape");
6410        assert_eq!(code, "tool_error");
6411        assert!(message.contains("API request returned status 401"));
6412        assert!(message.contains("(status 401)"));
6413    }
6414
6415    #[test]
6416    fn mcp_tool_error_skips_success_responses() {
6417        // A success response uses `result`, not `error`.
6418        let value = json!({ "result": { "current": { "temp_c": 19.0 } } });
6419        assert!(mcp_tool_error(&value).is_none());
6420    }
6421
6422    #[test]
6423    fn mcp_tool_error_skips_non_object_and_unrelated_shapes() {
6424        assert!(mcp_tool_error(&Value::Null).is_none());
6425        assert!(mcp_tool_error(&json!({"unrelated": true})).is_none());
6426        // `error` must be an object; a string isn't enough.
6427        assert!(mcp_tool_error(&json!({"error": "oops"})).is_none());
6428    }
6429
6430    #[tokio::test]
6431    async fn execute_non_user_facing_flow_failure_still_propagates() {
6432        // No session_id => internal job. Errors still propagate as Err so
6433        // operator alerting / metrics pipelines stay intact.
6434        let flow_id_str = "broken.flow";
6435        let pack_id_str = "test-pack";
6436        let host_flow = host_flow_for_test(flow_id_str, &["only-node"], Some("does-not-exist"));
6437        let engine = FlowEngine {
6438            packs: Vec::new(),
6439            flows: Vec::new(),
6440            flow_sources: HashMap::new(),
6441            messaging_provider_pack_ids: std::collections::HashSet::new(),
6442            flow_cache: RwLock::new(HashMap::from([(
6443                FlowKey {
6444                    pack_id: pack_id_str.to_string(),
6445                    flow_id: flow_id_str.to_string(),
6446                },
6447                host_flow,
6448            )])),
6449            default_env: "local".to_string(),
6450            validation: ValidationConfig {
6451                mode: ValidationMode::Off,
6452            },
6453            cross_pack_resolver: None,
6454            rollout_ids: RolloutIds::default(),
6455            remote_dispatch_handler: None,
6456            #[cfg(feature = "agentic-worker")]
6457            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
6458            #[cfg(feature = "agentic-worker")]
6459            agent_node_handler: None,
6460            #[cfg(feature = "agentic-worker")]
6461            graph_node_handler: None,
6462            #[cfg(feature = "agentic-worker")]
6463            mcp_tool_source: None,
6464        };
6465        let ctx = FlowContext {
6466            tenant: "demo",
6467            pack_id: pack_id_str,
6468            flow_id: flow_id_str,
6469            node_id: None,
6470            tool: None,
6471            action: None,
6472            session_id: None,
6473            provider_id: None,
6474            reply_scope: None,
6475            retry_config: RetryConfig {
6476                max_attempts: 1,
6477                base_delay_ms: 1,
6478            },
6479            attempt: 1,
6480            observer: None,
6481            mocks: None,
6482        };
6483        let result = engine.execute(ctx, Value::Null).await;
6484        assert!(result.is_err(), "non-user-facing flow must propagate Err");
6485    }
6486
6487    // ---- Phase D: slot_schema injection tests ----
6488
6489    #[test]
6490    fn host_flow_extracts_slot_schema_from_metadata_extra() {
6491        use greentic_types::FlowMetadata;
6492        use std::collections::BTreeSet;
6493
6494        let schema = json!([
6495            {"name": "counterparty", "slot_type": "string", "required": true},
6496            {"name": "due_date", "slot_type": "date", "required": true}
6497        ]);
6498        let flow = Flow {
6499            schema_version: "flow-v1".into(),
6500            id: FlowId::from_str("test.flow").unwrap(),
6501            kind: FlowKind::Messaging,
6502            entrypoints: BTreeMap::new(),
6503            nodes: IndexMap::default(),
6504            metadata: FlowMetadata {
6505                title: None,
6506                description: None,
6507                tags: BTreeSet::new(),
6508                extra: json!({(SLOT_SCHEMA_METADATA_KEY): schema}),
6509            },
6510        };
6511        let host = HostFlow::from(flow);
6512        assert_eq!(
6513            host.slot_schema.as_ref(),
6514            Some(&schema),
6515            "HostFlow must extract slot_schema from metadata.extra"
6516        );
6517    }
6518
6519    #[test]
6520    fn host_flow_slot_schema_is_none_when_absent() {
6521        let flow = Flow {
6522            schema_version: "flow-v1".into(),
6523            id: FlowId::from_str("test.flow").unwrap(),
6524            kind: FlowKind::Messaging,
6525            entrypoints: BTreeMap::new(),
6526            nodes: IndexMap::default(),
6527            metadata: Default::default(),
6528        };
6529        let host = HostFlow::from(flow);
6530        assert!(
6531            host.slot_schema.is_none(),
6532            "HostFlow.slot_schema must be None when metadata.extra has no greentic.slot_schema"
6533        );
6534    }
6535
6536    #[test]
6537    fn inject_slot_definitions_adds_to_object_input() {
6538        let schema = json!([
6539            {"name": "city", "slot_type": "string"}
6540        ]);
6541        let mut input = json!({"utterance": "hello"});
6542        inject_slot_definitions(&mut input, &schema, "f", "n");
6543        assert_eq!(
6544            input,
6545            json!({"utterance": "hello", "slot_definitions": schema}),
6546            "slot_definitions must be injected into existing object"
6547        );
6548    }
6549
6550    #[test]
6551    fn inject_slot_definitions_wraps_null_input() {
6552        let schema = json!([{"name": "x", "slot_type": "string"}]);
6553        let mut input = Value::Null;
6554        inject_slot_definitions(&mut input, &schema, "f", "n");
6555        assert_eq!(
6556            input,
6557            json!({"slot_definitions": schema}),
6558            "null input must become an object with slot_definitions"
6559        );
6560    }
6561
6562    #[test]
6563    fn inject_slot_definitions_preserves_explicit_inline() {
6564        let flow_schema = json!([{"name": "city", "slot_type": "string"}]);
6565        let inline_defs = json!([{"name": "country", "slot_type": "string"}]);
6566        let mut input = json!({
6567            "utterance": "hello",
6568            "slot_definitions": inline_defs
6569        });
6570        inject_slot_definitions(&mut input, &flow_schema, "f", "n");
6571        assert_eq!(
6572            input["slot_definitions"], inline_defs,
6573            "explicit inline slot_definitions must not be overwritten"
6574        );
6575    }
6576
6577    #[test]
6578    fn inject_slot_definitions_skips_non_object_input() {
6579        let schema = json!([{"name": "x", "slot_type": "string"}]);
6580        let mut input = json!("a string");
6581        inject_slot_definitions(&mut input, &schema, "f", "n");
6582        assert_eq!(
6583            input,
6584            json!("a string"),
6585            "non-object input must be left unchanged"
6586        );
6587    }
6588
6589    fn make_flow_doc_for_test(
6590        id: &str,
6591        node_name: &str,
6592        component: &str,
6593        slot_schema: Option<Value>,
6594    ) -> greentic_flow::model::FlowDoc {
6595        use greentic_flow::model::{FlowDoc, NodeDoc};
6596
6597        let mut nodes = IndexMap::new();
6598        nodes.insert(
6599            node_name.to_string(),
6600            NodeDoc {
6601                raw: {
6602                    let mut m = IndexMap::new();
6603                    m.insert(
6604                        "component.exec".to_string(),
6605                        json!({ "component": component }),
6606                    );
6607                    m
6608                },
6609                routing: json!([{ "out": true }]),
6610                ..Default::default()
6611            },
6612        );
6613
6614        FlowDoc {
6615            id: id.into(),
6616            title: None,
6617            description: None,
6618            flow_type: "messaging".into(),
6619            start: Some(node_name.into()),
6620            parameters: json!({}),
6621            tags: Vec::new(),
6622            schema_version: None,
6623            entrypoints: IndexMap::new(),
6624            meta: None,
6625            slot_schema,
6626            nodes,
6627        }
6628    }
6629
6630    /// Integration test: exercises the real `greentic_flow::compile_flow`
6631    /// producer path with a `FlowDoc` carrying `slot_schema`, then converts
6632    /// through `HostFlow::from` and verifies the runtime-side `slot_schema`
6633    /// field is populated — closing the gap Codex flagged where the existing
6634    /// unit tests constructed `FlowMetadata` directly.
6635    #[test]
6636    fn compile_flow_round_trips_slot_schema_into_host_flow() {
6637        let slot_defs = json!([
6638            { "name": "counterparty", "slot_type": "string", "required": true,
6639              "pattern": ".+" },
6640            { "name": "due_date", "slot_type": "date", "required": true,
6641              "pattern": "\\d{4}-\\d{2}-\\d{2}" }
6642        ]);
6643        let doc = make_flow_doc_for_test(
6644            "slot-test",
6645            "extractor",
6646            "slot-extractor",
6647            Some(slot_defs.clone()),
6648        );
6649
6650        let flow = greentic_flow::compile_flow(doc).expect("compile_flow must succeed");
6651        assert_eq!(
6652            flow.metadata.extra.get(SLOT_SCHEMA_METADATA_KEY),
6653            Some(&slot_defs),
6654            "compile_flow must forward slot_schema into metadata.extra"
6655        );
6656
6657        let host = HostFlow::from(flow);
6658        assert_eq!(
6659            host.slot_schema.as_ref(),
6660            Some(&slot_defs),
6661            "HostFlow.slot_schema must survive the compile_flow -> HostFlow round-trip"
6662        );
6663    }
6664
6665    /// Verify that `compile_flow` without `slot_schema` produces a `Flow`
6666    /// whose `metadata.extra` has no `greentic.slot_schema` key, and that
6667    /// `HostFlow.slot_schema` stays `None` through the real compile path.
6668    #[test]
6669    fn compile_flow_without_slot_schema_leaves_host_flow_none() {
6670        let doc = make_flow_doc_for_test("no-slots", "echo", "echo", None);
6671
6672        let flow = greentic_flow::compile_flow(doc).expect("compile_flow must succeed");
6673        assert!(
6674            flow.metadata.extra.get(SLOT_SCHEMA_METADATA_KEY).is_none(),
6675            "metadata.extra must not contain greentic.slot_schema when FlowDoc.slot_schema is None"
6676        );
6677
6678        let host = HostFlow::from(flow);
6679        assert!(
6680            host.slot_schema.is_none(),
6681            "HostFlow.slot_schema must be None when FlowDoc has no slot_schema"
6682        );
6683    }
6684
6685    #[test]
6686    fn multi_edge_node_routes_on_injected_event() {
6687        let raw_routing = json!([
6688            { "condition": "event == \"on_success\"", "to": "next" },
6689            { "condition": "event == \"on_error\"", "to": "err" }
6690        ]);
6691        let flow_ir = HostFlow {
6692            id: "flow.test".to_string(),
6693            start: None,
6694            nodes: IndexMap::new(),
6695            slot_schema: None,
6696            vars_init: JsonMap::new(),
6697        };
6698        let current = NodeId::from_str("current").unwrap();
6699        let state = ExecutionState::new(json!({}));
6700
6701        // ok:true with no explicit outcome → default event "on_success" → "next".
6702        let ok_out = NodeOutput::new(json!({ "x": 1 }));
6703        match evaluate_custom_routing(&raw_routing, &ok_out, &state, &flow_ir, &current) {
6704            CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "next"),
6705            other => panic!("expected Next(\"next\"), got {other:?}"),
6706        }
6707
6708        // An explicit outcome in the node metadata wins over the ok-default.
6709        let routed = NodeOutput::with_meta(json!({}), json!({ "outcome": "on_error" }));
6710        match evaluate_custom_routing(&raw_routing, &routed, &state, &flow_ir, &current) {
6711            CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "err"),
6712            other => panic!("expected Next(\"err\"), got {other:?}"),
6713        }
6714    }
6715
6716    /// A node whose component reports a failure (`{ok:false, error}`) and which
6717    /// has an `on_error`-family route must surface a node_io `Errors` output
6718    /// (`ok == false`) and route to that branch instead of aborting the flow.
6719    #[test]
6720    fn errored_output_routes_to_on_error_branch() {
6721        let raw_routing = json!([
6722            { "condition": "event == \"on_success\"", "to": "ok_node" },
6723            { "condition": "event == \"on_error\"", "to": "err_node" }
6724        ]);
6725        let flow_ir = HostFlow {
6726            id: "flow.test".to_string(),
6727            start: None,
6728            nodes: IndexMap::new(),
6729            slot_schema: None,
6730            vars_init: JsonMap::new(),
6731        };
6732        let current = NodeId::from_str("current").unwrap();
6733        let state = ExecutionState::new(json!({}));
6734
6735        let errored =
6736            NodeOutput::errored(json!({ "ok": false, "error": { "code": "E", "message": "m" } }));
6737        match evaluate_custom_routing(&raw_routing, &errored, &state, &flow_ir, &current) {
6738            CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "err_node"),
6739            other => panic!("expected on_error route, got {other:?}"),
6740        }
6741    }
6742
6743    #[test]
6744    fn node_has_error_route_detects_error_family_ports() {
6745        let with_err = Routing::Custom(json!([
6746            { "condition": "event == \"on_success\"", "to": "n" },
6747            { "condition": "event == \"on_error\"", "to": "e" }
6748        ]));
6749        assert!(
6750            node_has_error_route(&with_err),
6751            "on_error route must be detected"
6752        );
6753
6754        let only_success = Routing::Custom(json!([
6755            { "condition": "event == \"on_success\"", "to": "n" }
6756        ]));
6757        assert!(
6758            !node_has_error_route(&only_success),
6759            "a success-only Custom routing has no error branch"
6760        );
6761
6762        let plain = Routing::Next {
6763            node_id: NodeId::from_str("n").unwrap(),
6764        };
6765        assert!(
6766            !node_has_error_route(&plain),
6767            "Routing::Next has no error branch"
6768        );
6769    }
6770
6771    /// When a successful node emits no explicit `outcome`, the runner must
6772    /// derive the success `event` from the success-family port the node
6773    /// actually has an outgoing edge for (priority `on_success` → `on_complete`
6774    /// → `on_submit`), not blindly default to `on_success`. This is what lets
6775    /// native nodes whose happy port is `on_complete` (qa.process,
6776    /// llm.openai.chat, template_render) — or `on_submit` (forms) — route
6777    /// instead of silently stalling at `Wait`, while leaving `on_success`
6778    /// components (e.g. http) unchanged.
6779    #[test]
6780    fn success_default_matches_available_outcome_port() {
6781        let flow_ir = HostFlow {
6782            id: "flow.test".to_string(),
6783            start: None,
6784            nodes: IndexMap::new(),
6785            slot_schema: None,
6786            vars_init: JsonMap::new(),
6787        };
6788        let current = NodeId::from_str("current").unwrap();
6789        let state = ExecutionState::new(json!({}));
6790        // ok:true, no explicit outcome — the case every native happy path hits.
6791        let ok_out = NodeOutput::new(json!({ "answer": "hi" }));
6792
6793        // qa/llm/template shape: happy port is `on_complete`, no `on_success` edge.
6794        let on_complete_routing = json!([
6795            { "condition": "event == \"on_complete\"", "to": "next" },
6796            { "condition": "event == \"on_cancel\"", "to": "cancelled" }
6797        ]);
6798        match evaluate_custom_routing(&on_complete_routing, &ok_out, &state, &flow_ir, &current) {
6799            CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "next"),
6800            other => panic!("expected Next(\"next\") via on_complete default, got {other:?}"),
6801        }
6802
6803        // form shape: happy port is `on_submit`.
6804        let on_submit_routing = json!([
6805            { "condition": "event == \"on_submit\"", "to": "saved" },
6806            { "condition": "event == \"on_cancel\"", "to": "cancelled" }
6807        ]);
6808        match evaluate_custom_routing(&on_submit_routing, &ok_out, &state, &flow_ir, &current) {
6809            CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "saved"),
6810            other => panic!("expected Next(\"saved\") via on_submit default, got {other:?}"),
6811        }
6812
6813        // http shape: `on_success` present → still routes on_success (priority,
6814        // no regression for components whose success name is the old default).
6815        let on_success_routing = json!([
6816            { "condition": "event == \"on_success\"", "to": "ok" },
6817            { "condition": "event == \"on_error\"", "to": "err" }
6818        ]);
6819        match evaluate_custom_routing(&on_success_routing, &ok_out, &state, &flow_ir, &current) {
6820            CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "ok"),
6821            other => panic!("expected Next(\"ok\") via on_success default, got {other:?}"),
6822        }
6823    }
6824
6825    /// `evaluate_simple_condition` backs the user-authored `conditional_branch`
6826    /// expressions the catalog documents (e.g. `register.q_age >= 18`,
6827    /// `submit.status == "ok"`). Beyond `==`/`!=` it must handle numeric
6828    /// ordering (`>=` `<=` `>` `<`) and `contains` (case-insensitive substring);
6829    /// otherwise those conditions silently evaluate to false and route wrong.
6830    #[test]
6831    fn condition_evaluator_supports_comparisons_and_contains() {
6832        let ctx = json!({
6833            "register": { "q_age": 18 },
6834            "submit": { "status": "ok" },
6835            "msg": { "text": "Hello World" }
6836        });
6837
6838        // Numeric ordering (operands parsed as numbers).
6839        assert!(evaluate_simple_condition("register.q_age >= 18", &ctx));
6840        assert!(!evaluate_simple_condition("register.q_age > 18", &ctx));
6841        assert!(evaluate_simple_condition("register.q_age <= 18", &ctx));
6842        assert!(!evaluate_simple_condition("register.q_age < 18", &ctx));
6843
6844        // contains: case-insensitive substring over the resolved string.
6845        assert!(evaluate_simple_condition(
6846            "msg.text contains \"world\"",
6847            &ctx
6848        ));
6849        assert!(!evaluate_simple_condition(
6850            "msg.text contains \"bye\"",
6851            &ctx
6852        ));
6853
6854        // Existing equality semantics unchanged (regression guard).
6855        assert!(evaluate_simple_condition("submit.status == \"ok\"", &ctx));
6856        assert!(!evaluate_simple_condition("submit.status != \"ok\"", &ctx));
6857        // A non-numeric operand on an ordering op is false, not a panic.
6858        assert!(!evaluate_simple_condition("submit.status >= 1", &ctx));
6859    }
6860
6861    /// Symmetric to the success default: when a node FAILS (`ok == false`)
6862    /// without an explicit outcome, route to the error-family port the node
6863    /// actually has an edge for (priority `on_error` → `on_cancel` →
6864    /// `on_timeout`), not blindly `on_error`. Lets a node whose failure port is
6865    /// `on_cancel` (qa) or `on_timeout` (http) route instead of stalling.
6866    #[test]
6867    fn failure_default_matches_available_outcome_port() {
6868        let flow_ir = HostFlow {
6869            id: "flow.test".to_string(),
6870            start: None,
6871            nodes: IndexMap::new(),
6872            slot_schema: None,
6873            vars_init: JsonMap::new(),
6874        };
6875        let current = NodeId::from_str("current").unwrap();
6876        let state = ExecutionState::new(json!({}));
6877        // ok:false, no explicit outcome — the failure case.
6878        let err_out = NodeOutput {
6879            ok: false,
6880            payload: json!({}),
6881            meta: Value::Null,
6882        };
6883
6884        // qa shape: failure port is `on_cancel`, no `on_error` edge.
6885        let on_cancel_routing = json!([
6886            { "condition": "event == \"on_complete\"", "to": "next" },
6887            { "condition": "event == \"on_cancel\"", "to": "cancelled" }
6888        ]);
6889        match evaluate_custom_routing(&on_cancel_routing, &err_out, &state, &flow_ir, &current) {
6890            CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "cancelled"),
6891            other => panic!("expected Next(\"cancelled\") via on_cancel default, got {other:?}"),
6892        }
6893
6894        // http shape: `on_error` present → on_error (priority, unchanged).
6895        let on_error_routing = json!([
6896            { "condition": "event == \"on_success\"", "to": "ok" },
6897            { "condition": "event == \"on_error\"", "to": "err" }
6898        ]);
6899        match evaluate_custom_routing(&on_error_routing, &err_out, &state, &flow_ir, &current) {
6900            CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "err"),
6901            other => panic!("expected Next(\"err\") via on_error default, got {other:?}"),
6902        }
6903
6904        // on_timeout-only failure port.
6905        let on_timeout_routing = json!([
6906            { "condition": "event == \"on_success\"", "to": "ok" },
6907            { "condition": "event == \"on_timeout\"", "to": "timed_out" }
6908        ]);
6909        match evaluate_custom_routing(&on_timeout_routing, &err_out, &state, &flow_ir, &current) {
6910            CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "timed_out"),
6911            other => panic!("expected Next(\"timed_out\") via on_timeout default, got {other:?}"),
6912        }
6913    }
6914
6915    #[test]
6916    fn outcome_meta_surfaces_component_emitted_outcome() {
6917        // A component opts into outcome routing by adding `outcome` to its
6918        // output envelope; the runner surfaces it as node meta for routing.
6919        assert_eq!(
6920            outcome_meta(&json!({ "ok": true, "outcome": "on_complete" })),
6921            json!({ "outcome": "on_complete" })
6922        );
6923        // No `outcome` → null meta → engine uses the ok-derived default.
6924        assert_eq!(
6925            outcome_meta(&json!({ "ok": true, "body": {} })),
6926            Value::Null
6927        );
6928    }
6929
6930    /// Live end-to-end test: `dw.agent` NATS dispatch path.
6931    ///
6932    /// Requires a real NATS server (JetStream not needed for this test — core
6933    /// NATS pub/sub is sufficient) and an `aw-serve` consumer (or the in-process
6934    /// fake bridge below acts as one).
6935    ///
6936    /// # Run recipe
6937    ///
6938    /// ```text
6939    /// # Terminal 1 – NATS server (JetStream-enabled for prod parity, but core works too)
6940    /// nats-server -js
6941    ///
6942    /// # Terminal 2 – aw-serve test-mock (replies "pong" for any agent)
6943    /// AW_SERVE_AGENT_ID=greeter AW_SERVE_REPLY=pong \
6944    ///   GREENTIC_EVENTS_NATS_URL=nats://127.0.0.1:4222 \
6945    ///   GREENTIC_AW_JETSTREAM=off \
6946    ///   cargo run -p greentic-aw-runtime --features serve,test-mock --bin aw-serve
6947    ///
6948    /// # Terminal 3 – run this ignored test
6949    /// GREENTIC_EVENTS_NATS_URL=nats://127.0.0.1:4222 \
6950    ///   cargo test -p greentic-runner-host --lib \
6951    ///   tests::dw_agent_scale_to_zero_nats_e2e \
6952    ///   -- --nocapture --ignored
6953    /// ```
6954    ///
6955    /// When `GREENTIC_EVENTS_NATS_URL` is unset the test skips immediately.
6956    /// The test wires its own in-process fake bridge so the `aw-serve` binary is
6957    /// optional; running with the real `aw-serve` exercises the full out-of-process
6958    /// path. Both variants must produce a resumed reply of `"pong"`.
6959    #[cfg(feature = "agentic-worker")]
6960    #[tokio::test]
6961    #[ignore = "requires live NATS; run with --ignored after `nats-server -js`"]
6962    async fn dw_agent_scale_to_zero_nats_e2e() {
6963        use crate::runner::agent_node::DwAgentDispatch;
6964        use crate::runner::dispatch_listener::{SessionResumer, run_response_listener};
6965        use crate::runner::remote_dispatch::NatsDispatcher;
6966        use futures::StreamExt as _;
6967        use greentic_types::{
6968            RuntimeDispatchResponse, TenantCtx as DispatchTenantCtx, request_topic, response_topic,
6969        };
6970        use tokio::sync::Notify;
6971
6972        let nats_url = match std::env::var("GREENTIC_EVENTS_NATS_URL") {
6973            Ok(url) => url,
6974            Err(_) => {
6975                eprintln!(
6976                    "skipping dw_agent_scale_to_zero_nats_e2e: GREENTIC_EVENTS_NATS_URL not set"
6977                );
6978                return;
6979            }
6980        };
6981
6982        // ── 1. Build a two-node flow: dw.agent → emit.log (resume target) ──
6983        // The agent node must have Routing::Next so the engine knows the resume
6984        // target (same requirement as agentic.call / sorla.call in production).
6985        let resume_id = NodeId::from_str("after-agent").unwrap();
6986        let agent_node_id = NodeId::from_str("agent-e2e").unwrap();
6987        let agent_node = Node {
6988            id: agent_node_id.clone(),
6989            component: FlowComponentRef {
6990                id: "dw.agent".parse().unwrap(),
6991                pack_alias: None,
6992                operation: Some("greeter".to_string()),
6993            },
6994            input: InputMapping {
6995                mapping: json!({ "user_text": "ping" }),
6996            },
6997            output: OutputMapping {
6998                mapping: Value::Null,
6999            },
7000            err_map: None,
7001            routing: Routing::Next {
7002                node_id: resume_id.clone(),
7003            },
7004            telemetry: TelemetryHints::default(),
7005            conversational: false,
7006        };
7007        let resume_node = Node {
7008            id: resume_id.clone(),
7009            component: FlowComponentRef {
7010                id: "emit.log".parse().unwrap(),
7011                pack_alias: None,
7012                operation: None,
7013            },
7014            input: InputMapping {
7015                mapping: json!({ "message": "resumed" }),
7016            },
7017            output: OutputMapping {
7018                mapping: Value::Null,
7019            },
7020            err_map: None,
7021            routing: Routing::End,
7022            telemetry: TelemetryHints::default(),
7023            conversational: false,
7024        };
7025        let mut nodes = indexmap::IndexMap::default();
7026        nodes.insert(agent_node_id.clone(), agent_node);
7027        nodes.insert(resume_id.clone(), resume_node);
7028        let flow = greentic_types::Flow {
7029            schema_version: "1.0".into(),
7030            id: greentic_types::FlowId::from_str("e2e-agent.flow").unwrap(),
7031            kind: greentic_types::FlowKind::Messaging,
7032            entrypoints: BTreeMap::from([(
7033                "default".to_string(),
7034                Value::String(agent_node_id.to_string()),
7035            )]),
7036            nodes,
7037            metadata: Default::default(),
7038        };
7039        let host_flow = HostFlow::from(flow);
7040
7041        // ── 2. Connect NATS clients ──
7042        let dispatcher_client = async_nats::connect(&nats_url)
7043            .await
7044            .expect("NATS: dispatcher client");
7045        let bridge_client = async_nats::connect(&nats_url)
7046            .await
7047            .expect("NATS: fake bridge client");
7048        let listener_client = async_nats::connect(&nats_url)
7049            .await
7050            .expect("NATS: response listener client");
7051
7052        // ── 3. Fake bridge: subscribe to agentic request subject, reply "pong" ──
7053        let agentic_request_subject = request_topic("agentic");
7054        let agentic_response_subject = response_topic("agentic");
7055        let mut req_sub = bridge_client
7056            .subscribe(agentic_request_subject.clone())
7057            .await
7058            .expect("fake bridge: subscribe to agentic request subject");
7059        let bridge_reply_client = bridge_client.clone();
7060        let reply_subject = agentic_response_subject.clone();
7061        tokio::spawn(async move {
7062            while let Some(msg) = req_sub.next().await {
7063                let headers = msg.headers.as_ref();
7064                let get_hdr = |name: &str| {
7065                    headers
7066                        .and_then(|h| h.get(name))
7067                        .map(|v| v.as_str().to_owned())
7068                        .unwrap_or_default()
7069                };
7070                let correlation_id = get_hdr("Greentic-Correlation-Id");
7071                let tenant = get_hdr("Greentic-Tenant");
7072                let env = get_hdr("Greentic-Env");
7073
7074                let response_payload = RuntimeDispatchResponse {
7075                    ok: true,
7076                    output: json!({
7077                        "reply": "pong",
7078                        "trail": [],
7079                        "terminated_by": "final_reply"
7080                    }),
7081                    events: vec![],
7082                    error: None,
7083                };
7084                let body =
7085                    serde_json::to_vec(&response_payload).expect("serialize fake bridge response");
7086
7087                let mut resp_headers = async_nats::HeaderMap::new();
7088                resp_headers.insert("Greentic-Correlation-Id", correlation_id.as_str());
7089                resp_headers.insert("Greentic-Tenant", tenant.as_str());
7090                resp_headers.insert("Greentic-Env", env.as_str());
7091
7092                bridge_reply_client
7093                    .publish_with_headers(reply_subject.clone(), resp_headers, body.into())
7094                    .await
7095                    .expect("fake bridge: publish response");
7096            }
7097        });
7098
7099        // ── 4. Recording resumer + run_response_listener ──
7100        struct RecordingResumer {
7101            calls: std::sync::Mutex<Vec<(String, Value)>>,
7102            notify: Notify,
7103        }
7104
7105        impl RecordingResumer {
7106            fn new() -> Self {
7107                Self {
7108                    calls: std::sync::Mutex::new(vec![]),
7109                    notify: Notify::new(),
7110                }
7111            }
7112        }
7113
7114        #[async_trait::async_trait]
7115        impl SessionResumer for RecordingResumer {
7116            async fn resume(
7117                &self,
7118                _tenant: DispatchTenantCtx,
7119                correlation_id: &str,
7120                output: Value,
7121            ) -> anyhow::Result<()> {
7122                self.calls
7123                    .lock()
7124                    .unwrap()
7125                    .push((correlation_id.to_string(), output));
7126                self.notify.notify_one();
7127                Ok(())
7128            }
7129        }
7130
7131        let resumer = Arc::new(RecordingResumer::new());
7132        let resumer_for_listener = resumer.clone();
7133        tokio::spawn(async move {
7134            run_response_listener(listener_client, "agentic".to_owned(), resumer_for_listener)
7135                .await
7136                .expect("response listener exited unexpectedly");
7137        });
7138
7139        // Give subscriptions a moment to register.
7140        tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;
7141
7142        // ── 5. Build FlowEngine with NatsDispatcher + DwAgentDispatch::Nats ──
7143        let nats_engine_dispatcher = Arc::new(NatsDispatcher::new(dispatcher_client));
7144        let engine = FlowEngine {
7145            packs: Vec::new(),
7146            flows: Vec::new(),
7147            flow_sources: StdHashMap::new(),
7148            messaging_provider_pack_ids: std::collections::HashSet::new(),
7149            rollout_ids: RolloutIds::default(),
7150            flow_cache: RwLock::new(StdHashMap::from([(
7151                FlowKey {
7152                    pack_id: "e2e-pack".to_string(),
7153                    flow_id: "e2e-agent.flow".to_string(),
7154                },
7155                host_flow,
7156            )])),
7157            default_env: "local".to_string(),
7158            validation: crate::validate::ValidationConfig {
7159                mode: crate::validate::ValidationMode::Off,
7160            },
7161            cross_pack_resolver: None,
7162            remote_dispatch_handler: Some(
7163                nats_engine_dispatcher
7164                    as Arc<dyn crate::runner::remote_dispatch::RemoteDispatchHandler>,
7165            ),
7166            dw_agent_dispatch: DwAgentDispatch::Nats,
7167            agent_node_handler: None,
7168            graph_node_handler: None,
7169            mcp_tool_source: None,
7170        };
7171
7172        let ctx = FlowContext {
7173            tenant: "demo",
7174            pack_id: "e2e-pack",
7175            flow_id: "e2e-agent.flow",
7176            node_id: None,
7177            tool: None,
7178            action: None,
7179            session_id: Some("e2e-sess-1"),
7180            provider_id: None,
7181            reply_scope: None,
7182            retry_config: RetryConfig {
7183                max_attempts: 1,
7184                base_delay_ms: 1,
7185            },
7186            attempt: 1,
7187            observer: None,
7188            mocks: None,
7189        };
7190
7191        // ── 6. Execute: the dw.agent NATS path must PAUSE the flow ──
7192        let result = engine
7193            .execute(ctx, json!({ "user_text": "ping" }))
7194            .await
7195            .expect("engine.execute succeeded");
7196
7197        assert!(
7198            matches!(result.status, FlowStatus::Waiting(_)),
7199            "expected FlowStatus::Waiting from dw.agent Nats path, got: {:?}",
7200            result.status
7201        );
7202        eprintln!("dw.agent: flow paused (Waiting) — dispatch published to NATS");
7203
7204        // ── 7. Wait for the fake bridge reply to reach the resumer (up to 5 s) ──
7205        let wait = tokio::time::timeout(
7206            tokio::time::Duration::from_secs(5),
7207            resumer.notify.notified(),
7208        )
7209        .await;
7210
7211        assert!(
7212            wait.is_ok(),
7213            "timed out waiting for fake bridge reply — is NATS running? ({nats_url})"
7214        );
7215
7216        // ── 8. Assert the resumed reply == "pong" ──
7217        let calls = resumer.calls.lock().unwrap();
7218        assert_eq!(
7219            calls.len(),
7220            1,
7221            "resumer should have been called exactly once"
7222        );
7223        let (ref _corr, ref output) = calls[0];
7224        assert_eq!(
7225            output["output"]["reply"],
7226            json!("pong"),
7227            "resumed reply must match the aw-serve canned reply"
7228        );
7229        eprintln!(
7230            "PASSED: dw.agent scale-to-zero NATS e2e — reply={:?}",
7231            output["output"]["reply"]
7232        );
7233    }
7234
7235    #[test]
7236    fn execution_state_vars_survive_serde_round_trip() {
7237        // vars must persist across a park/resume, which is a serde round-trip of ExecutionState.
7238        let mut st = ExecutionState::new(json!({}));
7239        st.vars.insert("counter".into(), json!(3));
7240        st.vars.insert("region".into(), json!("us-east-1"));
7241
7242        let encoded = serde_json::to_string(&st).expect("serialize");
7243        let decoded: ExecutionState = serde_json::from_str(&encoded).expect("deserialize");
7244
7245        assert_eq!(decoded.vars.get("counter"), Some(&json!(3)));
7246        assert_eq!(decoded.vars.get("region"), Some(&json!("us-east-1")));
7247    }
7248
7249    #[test]
7250    fn execution_state_vars_default_empty_for_old_snapshots() {
7251        // A snapshot serialized before `vars` existed (no `vars` key) must still load.
7252        let legacy = r#"{"entry":{},"input":{},"nodes":{},"egress":[],"redirect_count":0}"#;
7253        let decoded: ExecutionState = serde_json::from_str(legacy).expect("legacy loads");
7254        assert!(decoded.vars.is_empty());
7255    }
7256
7257    #[test]
7258    fn template_context_exposes_vars_namespace_typed() {
7259        let mut st = ExecutionState::new(serde_json::json!({}));
7260        st.vars.insert("count".into(), serde_json::json!(5));
7261        st.vars.insert("name".into(), serde_json::json!("aws"));
7262
7263        let ctx = template_context(&st, serde_json::Value::Null);
7264        // {{vars.count}} must resolve to the JSON number 5, not the string "5".
7265        let rendered_num = render_template_value(
7266            &serde_json::json!("{{vars.count}}"),
7267            &ctx,
7268            TemplateOptions::default(),
7269        )
7270        .expect("render num");
7271        assert_eq!(rendered_num, serde_json::json!(5));
7272
7273        let rendered_str = render_template_value(
7274            &serde_json::json!("prefix-{{vars.name}}"),
7275            &ctx,
7276            TemplateOptions::default(),
7277        )
7278        .expect("render str");
7279        assert_eq!(rendered_str, serde_json::json!("prefix-aws"));
7280    }
7281
7282    #[test]
7283    fn vars_namespace_does_not_shadow_existing_namespaces() {
7284        let st = ExecutionState::new(serde_json::json!({"user": {"id": 7}}));
7285        let ctx = template_context(&st, serde_json::Value::Null);
7286        let obj = ctx.as_object().expect("ctx object");
7287        for key in ["entry", "in", "prev", "node", "state", "vars"] {
7288            assert!(obj.contains_key(key), "context must expose `{key}`");
7289        }
7290    }
7291
7292    // ── vars_init tests ────────────────────────────────────────────────────
7293
7294    /// Build a minimal flow with the given free-form `metadata.extra` value.
7295    /// Mirrors the construction used by neighbouring engine tests: a schema-1.0
7296    /// Messaging flow with no nodes and no entrypoints, only the metadata set.
7297    fn flow_with_extra(extra: serde_json::Value) -> Flow {
7298        Flow {
7299            schema_version: "1.0".into(),
7300            id: FlowId::from_str("test.flow").unwrap(),
7301            kind: FlowKind::Messaging,
7302            entrypoints: BTreeMap::new(),
7303            nodes: indexmap::IndexMap::default(),
7304            metadata: FlowMetadata {
7305                title: None,
7306                description: None,
7307                tags: Default::default(),
7308                extra,
7309            },
7310        }
7311    }
7312
7313    #[test]
7314    fn from_flow_extracts_vars_init() {
7315        let flow = flow_with_extra(serde_json::json!({
7316            "vars_init": {
7317                "region":  { "type": "string", "default": "us-east-1" },
7318                "counter": { "type": "number", "default": 0 }
7319            }
7320        }));
7321        let host: HostFlow = HostFlow::from(flow);
7322        assert_eq!(
7323            host.vars_init.get("region"),
7324            Some(&serde_json::json!("us-east-1"))
7325        );
7326        assert_eq!(host.vars_init.get("counter"), Some(&serde_json::json!(0)));
7327    }
7328
7329    #[test]
7330    fn from_flow_vars_init_absent() {
7331        let flow = flow_with_extra(serde_json::json!({}));
7332        let host: HostFlow = HostFlow::from(flow);
7333        assert!(host.vars_init.is_empty());
7334    }
7335
7336    #[test]
7337    fn execute_once_seeds_declared_vars() {
7338        // A flow with vars_init seeds state.vars before the first node runs.
7339        // We verify this by using an emit.log node whose message template
7340        // references {{vars.region}}: if the var is seeded, the rendered
7341        // output will contain "us-east-1".
7342        let node_id = NodeId::from_str("n1").unwrap();
7343        let node = Node {
7344            id: node_id.clone(),
7345            component: FlowComponentRef {
7346                id: "emit.log".parse().unwrap(),
7347                pack_alias: None,
7348                operation: None,
7349            },
7350            input: InputMapping {
7351                mapping: json!({ "message": "{{vars.region}}" }),
7352            },
7353            output: OutputMapping {
7354                mapping: Value::Null,
7355            },
7356            err_map: None,
7357            routing: Routing::End,
7358            telemetry: TelemetryHints::default(),
7359            conversational: false,
7360        };
7361        let mut nodes = indexmap::IndexMap::default();
7362        nodes.insert(node_id.clone(), node);
7363        let flow = Flow {
7364            schema_version: "1.0".into(),
7365            id: FlowId::from_str("vars.flow").unwrap(),
7366            kind: FlowKind::Messaging,
7367            entrypoints: BTreeMap::from([(
7368                "default".to_string(),
7369                Value::String(node_id.to_string()),
7370            )]),
7371            nodes,
7372            metadata: FlowMetadata {
7373                title: None,
7374                description: None,
7375                tags: Default::default(),
7376                extra: json!({
7377                    "vars_init": {
7378                        "region": { "type": "string", "default": "us-east-1" }
7379                    }
7380                }),
7381            },
7382        };
7383        let host_flow = HostFlow::from(flow);
7384
7385        let engine = FlowEngine {
7386            packs: Vec::new(),
7387            flows: Vec::new(),
7388            flow_sources: HashMap::new(),
7389            messaging_provider_pack_ids: std::collections::HashSet::new(),
7390            rollout_ids: RolloutIds::default(),
7391            flow_cache: RwLock::new(HashMap::from([(
7392                FlowKey {
7393                    pack_id: "test-pack".to_string(),
7394                    flow_id: "vars.flow".to_string(),
7395                },
7396                host_flow,
7397            )])),
7398            default_env: "local".to_string(),
7399            validation: ValidationConfig {
7400                mode: ValidationMode::Off,
7401            },
7402            cross_pack_resolver: None,
7403            remote_dispatch_handler: None,
7404            #[cfg(feature = "agentic-worker")]
7405            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
7406            #[cfg(feature = "agentic-worker")]
7407            agent_node_handler: None,
7408            #[cfg(feature = "agentic-worker")]
7409            graph_node_handler: None,
7410            #[cfg(feature = "agentic-worker")]
7411            mcp_tool_source: None,
7412        };
7413
7414        let observer = CountingObserver::new();
7415        let ctx = FlowContext {
7416            tenant: "demo",
7417            pack_id: "test-pack",
7418            flow_id: "vars.flow",
7419            node_id: None,
7420            tool: None,
7421            action: None,
7422            session_id: None,
7423            provider_id: None,
7424            reply_scope: None,
7425            retry_config: RetryConfig {
7426                max_attempts: 1,
7427                base_delay_ms: 1,
7428            },
7429            attempt: 1,
7430            observer: Some(&observer),
7431            mocks: None,
7432        };
7433
7434        let rt = Runtime::new().unwrap();
7435        let result = rt.block_on(engine.execute(ctx, Value::Null)).unwrap();
7436        assert!(matches!(result.status, FlowStatus::Completed));
7437
7438        let ends = observer.ends.lock().unwrap();
7439        assert_eq!(ends.len(), 1);
7440        assert_eq!(
7441            ends[0].get("message").and_then(Value::as_str),
7442            Some("us-east-1"),
7443            "vars.region must be seeded to its default and rendered in the node payload"
7444        );
7445    }
7446
7447    // ── var_set tests ──────────────────────────────────────────────────────
7448
7449    /// Build a two-node flow: var_set → emit.log, with optional vars_init.
7450    ///
7451    /// `var_set_input` is the raw input mapping for the var.set node,
7452    /// e.g. `json!({ "name": "greeting", "value": "hi" })`.
7453    /// `emit_input` is the input mapping for the emit.log node.
7454    /// `vars_init_extra` is optional flow-level vars_init metadata.
7455    fn var_set_flow(
7456        var_set_input: Value,
7457        emit_input: Value,
7458        vars_init_extra: Option<Value>,
7459    ) -> Flow {
7460        let set_id = NodeId::from_str("set1").unwrap();
7461        let emit_id = NodeId::from_str("emit1").unwrap();
7462
7463        let set_node = Node {
7464            id: set_id.clone(),
7465            component: FlowComponentRef {
7466                id: "var.set".parse().unwrap(),
7467                pack_alias: None,
7468                operation: None,
7469            },
7470            input: InputMapping {
7471                mapping: var_set_input,
7472            },
7473            output: OutputMapping {
7474                mapping: Value::Null,
7475            },
7476            err_map: None,
7477            routing: Routing::Next {
7478                node_id: emit_id.clone(),
7479            },
7480            telemetry: TelemetryHints::default(),
7481            conversational: false,
7482        };
7483
7484        let emit_node = Node {
7485            id: emit_id.clone(),
7486            component: FlowComponentRef {
7487                id: "emit.log".parse().unwrap(),
7488                pack_alias: None,
7489                operation: None,
7490            },
7491            input: InputMapping {
7492                mapping: emit_input,
7493            },
7494            output: OutputMapping {
7495                mapping: Value::Null,
7496            },
7497            err_map: None,
7498            routing: Routing::End,
7499            telemetry: TelemetryHints::default(),
7500            conversational: false,
7501        };
7502
7503        let mut nodes = indexmap::IndexMap::default();
7504        nodes.insert(set_id.clone(), set_node);
7505        nodes.insert(emit_id.clone(), emit_node);
7506
7507        let extra = vars_init_extra.unwrap_or(serde_json::json!({}));
7508
7509        Flow {
7510            schema_version: "1.0".into(),
7511            id: FlowId::from_str("var.set.flow").unwrap(),
7512            kind: FlowKind::Messaging,
7513            entrypoints: BTreeMap::from([(
7514                "default".to_string(),
7515                Value::String(set_id.to_string()),
7516            )]),
7517            nodes,
7518            metadata: FlowMetadata {
7519                title: None,
7520                description: None,
7521                tags: Default::default(),
7522                extra,
7523            },
7524        }
7525    }
7526
7527    fn run_var_set_flow(flow: Flow) -> (FlowStatus, Vec<Value>) {
7528        let host_flow = HostFlow::from(flow);
7529        let engine = FlowEngine {
7530            packs: Vec::new(),
7531            flows: Vec::new(),
7532            flow_sources: StdHashMap::new(),
7533            messaging_provider_pack_ids: std::collections::HashSet::new(),
7534            rollout_ids: RolloutIds::default(),
7535            flow_cache: RwLock::new(StdHashMap::from([(
7536                FlowKey {
7537                    pack_id: "test-pack".to_string(),
7538                    flow_id: "var.set.flow".to_string(),
7539                },
7540                host_flow,
7541            )])),
7542            default_env: "local".to_string(),
7543            validation: ValidationConfig {
7544                mode: ValidationMode::Off,
7545            },
7546            cross_pack_resolver: None,
7547            remote_dispatch_handler: None,
7548            #[cfg(feature = "agentic-worker")]
7549            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
7550            #[cfg(feature = "agentic-worker")]
7551            agent_node_handler: None,
7552            #[cfg(feature = "agentic-worker")]
7553            graph_node_handler: None,
7554            #[cfg(feature = "agentic-worker")]
7555            mcp_tool_source: None,
7556        };
7557        let observer = CountingObserver::new();
7558        let ctx = FlowContext {
7559            tenant: "demo",
7560            pack_id: "test-pack",
7561            flow_id: "var.set.flow",
7562            node_id: None,
7563            tool: None,
7564            action: None,
7565            session_id: None,
7566            provider_id: None,
7567            reply_scope: None,
7568            retry_config: RetryConfig {
7569                max_attempts: 1,
7570                base_delay_ms: 1,
7571            },
7572            attempt: 1,
7573            observer: Some(&observer),
7574            mocks: None,
7575        };
7576        let rt = Runtime::new().unwrap();
7577        let result = rt.block_on(engine.execute(ctx, Value::Null)).unwrap();
7578        let ends = observer.ends.lock().unwrap().clone();
7579        (result.status, ends)
7580    }
7581
7582    #[test]
7583    fn var_set_node_writes_literal_value_into_vars() {
7584        // A var_set node with a literal value: greeting="hi".
7585        // The following emit.log node uses {{vars.greeting}} and its output
7586        // proves the var was written.
7587        let flow = var_set_flow(
7588            json!({ "name": "greeting", "value": "hi" }),
7589            json!({ "message": "{{vars.greeting}}" }),
7590            None,
7591        );
7592        let (status, ends) = run_var_set_flow(flow);
7593
7594        assert!(
7595            matches!(status, FlowStatus::Completed),
7596            "flow must complete"
7597        );
7598        assert_eq!(ends.len(), 2, "both nodes must fire");
7599        // var_set node output
7600        assert_eq!(ends[0].get("ok"), Some(&json!(true)), "var_set output ok");
7601        // emit.log node output: vars.greeting was written
7602        assert_eq!(
7603            ends[1].get("message").and_then(Value::as_str),
7604            Some("hi"),
7605            "vars.greeting must be written and renderable in the next node"
7606        );
7607    }
7608
7609    #[test]
7610    fn var_set_node_writes_templated_value_with_type_preserved() {
7611        // vars_init seeds counter=1 (a number).
7612        // var_set copies it into "copy" via {{vars.counter}}.
7613        // The emit.log node uses {{vars.copy}} as the sole message template;
7614        // render_template_value returns the typed JSON number, not a string.
7615        let flow = var_set_flow(
7616            json!({ "name": "copy", "value": "{{vars.counter}}" }),
7617            json!({ "message": "{{vars.copy}}" }),
7618            Some(json!({
7619                "vars_init": {
7620                    "counter": { "type": "number", "default": 1 }
7621                }
7622            })),
7623        );
7624        let (status, ends) = run_var_set_flow(flow);
7625
7626        assert!(
7627            matches!(status, FlowStatus::Completed),
7628            "flow must complete"
7629        );
7630        assert_eq!(ends.len(), 2, "both nodes must fire");
7631        // emit.log message must be the typed number 1, not the string "1"
7632        assert_eq!(
7633            ends[1].get("message"),
7634            Some(&json!(1)),
7635            "vars.copy must preserve the JSON number type from vars.counter"
7636        );
7637    }
7638
7639    #[test]
7640    fn var_set_empty_name_is_skipped_not_written() {
7641        // A var_set node with an empty (or whitespace-only) name must complete
7642        // without panic and must NOT insert a "" key into state.vars.
7643        let engine = minimal_engine();
7644        let rt = Runtime::new().unwrap();
7645        let retry_config = RetryConfig {
7646            max_attempts: 1,
7647            base_delay_ms: 1,
7648        };
7649        let ctx = FlowContext {
7650            tenant: "demo",
7651            pack_id: "test-pack",
7652            flow_id: "var.set.flow",
7653            node_id: Some("set1"),
7654            tool: None,
7655            action: None,
7656            session_id: None,
7657            provider_id: None,
7658            reply_scope: None,
7659            retry_config,
7660            attempt: 1,
7661            observer: None,
7662            mocks: None,
7663        };
7664        let node = HostNode {
7665            kind: NodeKind::VarSet {
7666                name: "".to_string(),
7667                value: json!("garbage"),
7668            },
7669            component: "var.set".into(),
7670            component_id: "var.set".into(),
7671            operation_name: None,
7672            operation_in_mapping: None,
7673            payload_expr: Value::Null,
7674            routing: Routing::End,
7675            vars_out: None,
7676        };
7677        let mut state = ExecutionState::new(Value::Null);
7678        let payload = Value::Null;
7679        let event = NodeEvent {
7680            context: &ctx,
7681            node_id: "set1",
7682            node: &node,
7683            payload: &payload,
7684        };
7685
7686        let outcome = rt
7687            .block_on(engine.dispatch_node(
7688                &ctx,
7689                "set1",
7690                &node,
7691                &mut state,
7692                payload.clone(),
7693                &event,
7694            ))
7695            .expect("dispatch_node must not error on empty var name");
7696
7697        // Must return {ok: true} (not an error).
7698        assert_eq!(
7699            outcome.output.payload,
7700            json!({ "ok": true }),
7701            "dispatch must return ok:true even when name is empty"
7702        );
7703        // Must NOT have inserted a \"\" key into state.vars.
7704        assert!(
7705            state.vars.get("").is_none(),
7706            "empty var name must not create a \"\" key in state.vars"
7707        );
7708    }
7709
7710    #[test]
7711    fn var_set_node_has_empty_payload_expr() {
7712        // Lowering a var.set Node must yield a HostNode whose payload_expr is
7713        // Value::Null. The VarSet dispatch arm reads name/value directly from
7714        // NodeKind::VarSet, so forwarding the mapping as payload_expr is redundant.
7715        let node_id = NodeId::from_str("set1").unwrap();
7716        let node = Node {
7717            id: node_id.clone(),
7718            component: FlowComponentRef {
7719                id: "var.set".parse().unwrap(),
7720                pack_alias: None,
7721                operation: None,
7722            },
7723            input: InputMapping {
7724                mapping: json!({ "name": "greeting", "value": "hi" }),
7725            },
7726            output: OutputMapping {
7727                mapping: Value::Null,
7728            },
7729            err_map: None,
7730            routing: Routing::End,
7731            telemetry: TelemetryHints::default(),
7732            conversational: false,
7733        };
7734
7735        let host_node = HostNode::from(node);
7736
7737        // payload_expr must be Null.
7738        assert_eq!(
7739            host_node.payload_expr,
7740            Value::Null,
7741            "var.set node must have Null payload_expr after lowering"
7742        );
7743        // NodeKind::VarSet must still carry the original name and value.
7744        match &host_node.kind {
7745            NodeKind::VarSet { name, value } => {
7746                assert_eq!(name.as_str(), "greeting", "name must be preserved in kind");
7747                assert_eq!(value, &json!("hi"), "value must be preserved in kind");
7748            }
7749            other => panic!("expected NodeKind::VarSet, got {other:?}"),
7750        }
7751    }
7752
7753    // ── vars_out tests ──────────────────────────────────────────────────────
7754
7755    /// Build a two-node flow: emit.log (with vars_out) → emit.log.
7756    ///
7757    /// `emit1_input` is the raw input mapping for the first emit.log node
7758    /// (should include the `vars_out` binding).
7759    /// `emit2_input` is the input mapping for the second emit.log node
7760    /// (reads from `vars.*` to prove bindings were applied).
7761    fn vars_out_flow(emit1_input: Value, emit2_input: Value) -> Flow {
7762        let emit1_id = NodeId::from_str("emit1").unwrap();
7763        let emit2_id = NodeId::from_str("emit2").unwrap();
7764
7765        let emit1_node = Node {
7766            id: emit1_id.clone(),
7767            component: FlowComponentRef {
7768                id: "emit.log".parse().unwrap(),
7769                pack_alias: None,
7770                operation: None,
7771            },
7772            input: InputMapping {
7773                mapping: emit1_input,
7774            },
7775            output: OutputMapping {
7776                mapping: Value::Null,
7777            },
7778            err_map: None,
7779            routing: Routing::Next {
7780                node_id: emit2_id.clone(),
7781            },
7782            telemetry: TelemetryHints::default(),
7783            conversational: false,
7784        };
7785
7786        let emit2_node = Node {
7787            id: emit2_id.clone(),
7788            component: FlowComponentRef {
7789                id: "emit.log".parse().unwrap(),
7790                pack_alias: None,
7791                operation: None,
7792            },
7793            input: InputMapping {
7794                mapping: emit2_input,
7795            },
7796            output: OutputMapping {
7797                mapping: Value::Null,
7798            },
7799            err_map: None,
7800            routing: Routing::End,
7801            telemetry: TelemetryHints::default(),
7802            conversational: false,
7803        };
7804
7805        let mut nodes = indexmap::IndexMap::default();
7806        nodes.insert(emit1_id.clone(), emit1_node);
7807        nodes.insert(emit2_id.clone(), emit2_node);
7808
7809        // Reuse the same flow_id as var_set_flow so we can pass it directly to
7810        // `run_var_set_flow`, which registers the flow under that key.
7811        Flow {
7812            schema_version: "1.0".into(),
7813            id: FlowId::from_str("var.set.flow").unwrap(),
7814            kind: FlowKind::Messaging,
7815            entrypoints: BTreeMap::from([(
7816                "default".to_string(),
7817                Value::String(emit1_id.to_string()),
7818            )]),
7819            nodes,
7820            metadata: FlowMetadata {
7821                title: None,
7822                description: None,
7823                tags: Default::default(),
7824                extra: serde_json::json!({}),
7825            },
7826        }
7827    }
7828
7829    #[test]
7830    fn vars_out_binds_node_output_into_vars() {
7831        // `emit.log` outputs its rendered payload directly. Node 1 emits
7832        // `{ message: "hello" }` and declares `vars_out = { lastReply:
7833        // "{{prev.message}}" }`. After it runs, `state.vars["lastReply"]`
7834        // must equal "hello". Node 2 reads that var so the assertion is driven
7835        // from the second node's output rather than internal state.
7836        let flow = vars_out_flow(
7837            json!({
7838                "message": "hello",
7839                "vars_out": { "lastReply": "{{prev.message}}" }
7840            }),
7841            json!({ "message": "{{vars.lastReply}}" }),
7842        );
7843        let (status, ends) = run_var_set_flow(flow);
7844
7845        assert!(
7846            matches!(status, FlowStatus::Completed),
7847            "flow must complete"
7848        );
7849        assert_eq!(ends.len(), 2, "both nodes must fire");
7850        // Node 2's message must equal the value captured by vars_out in node 1.
7851        assert_eq!(
7852            ends[1].get("message").and_then(Value::as_str),
7853            Some("hello"),
7854            "vars_out binding from node 1 must be readable in node 2"
7855        );
7856    }
7857
7858    /// Build a three-node flow: var_set → session.wait → emit.log.
7859    /// `vars_init` seeds `counter = 1`; `var_set` writes `greeting = "hello"`.
7860    /// The wait parks the flow; resume runs emit.log which reads both vars.
7861    fn vars_survive_flow() -> Flow {
7862        let set_id = NodeId::from_str("set1").unwrap();
7863        let wait_id = NodeId::from_str("wait1").unwrap();
7864        let emit_id = NodeId::from_str("emit1").unwrap();
7865
7866        let set_node = Node {
7867            id: set_id.clone(),
7868            component: FlowComponentRef {
7869                id: "var.set".parse().unwrap(),
7870                pack_alias: None,
7871                operation: None,
7872            },
7873            input: InputMapping {
7874                mapping: json!({ "name": "greeting", "value": "hello" }),
7875            },
7876            output: OutputMapping {
7877                mapping: Value::Null,
7878            },
7879            err_map: None,
7880            routing: Routing::Next {
7881                node_id: wait_id.clone(),
7882            },
7883            telemetry: TelemetryHints::default(),
7884            conversational: false,
7885        };
7886
7887        let wait_node = Node {
7888            id: wait_id.clone(),
7889            component: FlowComponentRef {
7890                id: "session.wait".parse().unwrap(),
7891                pack_alias: None,
7892                operation: None,
7893            },
7894            input: InputMapping {
7895                mapping: Value::Null,
7896            },
7897            output: OutputMapping {
7898                mapping: Value::Null,
7899            },
7900            err_map: None,
7901            routing: Routing::Next {
7902                node_id: emit_id.clone(),
7903            },
7904            telemetry: TelemetryHints::default(),
7905            conversational: false,
7906        };
7907
7908        let emit_node = Node {
7909            id: emit_id.clone(),
7910            component: FlowComponentRef {
7911                id: "emit.log".parse().unwrap(),
7912                pack_alias: None,
7913                operation: None,
7914            },
7915            input: InputMapping {
7916                mapping: json!({
7917                    "greeting": "{{vars.greeting}}",
7918                    "counter": "{{vars.counter}}"
7919                }),
7920            },
7921            output: OutputMapping {
7922                mapping: Value::Null,
7923            },
7924            err_map: None,
7925            routing: Routing::End,
7926            telemetry: TelemetryHints::default(),
7927            conversational: false,
7928        };
7929
7930        let mut nodes = indexmap::IndexMap::default();
7931        nodes.insert(set_id.clone(), set_node);
7932        nodes.insert(wait_id.clone(), wait_node);
7933        nodes.insert(emit_id.clone(), emit_node);
7934
7935        Flow {
7936            schema_version: "1.0".into(),
7937            id: FlowId::from_str("vars.survive.flow").unwrap(),
7938            kind: FlowKind::Messaging,
7939            entrypoints: BTreeMap::from([(
7940                "default".to_string(),
7941                Value::String(set_id.to_string()),
7942            )]),
7943            nodes,
7944            metadata: FlowMetadata {
7945                title: None,
7946                description: None,
7947                tags: Default::default(),
7948                extra: json!({
7949                    "vars_init": {
7950                        "counter": { "type": "number", "default": 1 }
7951                    }
7952                }),
7953            },
7954        }
7955    }
7956
7957    #[test]
7958    fn vars_survive_park_and_resume_end_to_end() {
7959        // vars_init seeds counter=1; var_set writes greeting="hello"; the flow
7960        // parks at session.wait; resume drives emit.log which reads both vars.
7961        let flow = vars_survive_flow();
7962        let host_flow = HostFlow::from(flow);
7963        let flow_id = "vars.survive.flow";
7964        let pack_id = "test-pack";
7965        let engine = FlowEngine {
7966            packs: Vec::new(),
7967            flows: Vec::new(),
7968            flow_sources: StdHashMap::new(),
7969            messaging_provider_pack_ids: std::collections::HashSet::new(),
7970            rollout_ids: RolloutIds::default(),
7971            flow_cache: RwLock::new(StdHashMap::from([(
7972                FlowKey {
7973                    pack_id: pack_id.to_string(),
7974                    flow_id: flow_id.to_string(),
7975                },
7976                host_flow,
7977            )])),
7978            default_env: "local".to_string(),
7979            validation: ValidationConfig {
7980                mode: ValidationMode::Off,
7981            },
7982            cross_pack_resolver: None,
7983            remote_dispatch_handler: None,
7984            #[cfg(feature = "agentic-worker")]
7985            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
7986            #[cfg(feature = "agentic-worker")]
7987            agent_node_handler: None,
7988            #[cfg(feature = "agentic-worker")]
7989            graph_node_handler: None,
7990            #[cfg(feature = "agentic-worker")]
7991            mcp_tool_source: None,
7992        };
7993        let rt = Runtime::new().unwrap();
7994
7995        // First execution: must park at session.wait after var_set fires.
7996        let ctx1 = FlowContext {
7997            tenant: "demo",
7998            pack_id,
7999            flow_id,
8000            node_id: None,
8001            tool: None,
8002            action: None,
8003            session_id: None,
8004            provider_id: None,
8005            reply_scope: None,
8006            retry_config: RetryConfig {
8007                max_attempts: 1,
8008                base_delay_ms: 1,
8009            },
8010            attempt: 1,
8011            observer: None,
8012            mocks: None,
8013        };
8014        let result1 = rt.block_on(engine.execute(ctx1, Value::Null)).unwrap();
8015        let snapshot = match result1.status {
8016            FlowStatus::Waiting(w) => w.snapshot,
8017            other => panic!("expected Waiting after session.wait, got {other:?}"),
8018        };
8019
8020        // Both vars must be present in the snapshot before resume.
8021        assert_eq!(
8022            snapshot.state.vars.get("greeting"),
8023            Some(&json!("hello")),
8024            "greeting var must be in snapshot"
8025        );
8026        assert_eq!(
8027            snapshot.state.vars.get("counter"),
8028            Some(&json!(1)),
8029            "counter var (from vars_init) must be in snapshot"
8030        );
8031
8032        // Resume: emit.log must read both vars from the restored state.
8033        let observer2 = CountingObserver::new();
8034        let ctx2 = FlowContext {
8035            tenant: "demo",
8036            pack_id,
8037            flow_id,
8038            node_id: None,
8039            tool: None,
8040            action: None,
8041            session_id: None,
8042            provider_id: None,
8043            reply_scope: None,
8044            retry_config: RetryConfig {
8045                max_attempts: 1,
8046                base_delay_ms: 1,
8047            },
8048            attempt: 1,
8049            observer: Some(&observer2),
8050            mocks: None,
8051        };
8052        let result2 = rt
8053            .block_on(engine.resume(ctx2, snapshot, Value::Null))
8054            .unwrap();
8055        assert!(
8056            matches!(result2.status, FlowStatus::Completed),
8057            "flow must complete after resume"
8058        );
8059        let ends2 = observer2.ends.lock().unwrap().clone();
8060        assert_eq!(ends2.len(), 1, "only emit.log fires after resume");
8061        assert_eq!(
8062            ends2[0].get("greeting").and_then(Value::as_str),
8063            Some("hello"),
8064            "vars.greeting must survive the park/resume"
8065        );
8066        assert_eq!(
8067            ends2[0].get("counter"),
8068            Some(&json!(1)),
8069            "vars.counter (vars_init) must survive the park/resume"
8070        );
8071    }
8072
8073    // ── Conversational `dw.agent` park-and-loop harness — PORT PENDING ─────
8074    //
8075    // These tests encode the RESEARCH lane's behaviour: a conversational
8076    // `dw.agent` node parks after every reply and re-enters itself until the
8077    // agent emits `conversation_ended`, with `MAX_PARK_TURNS` as the safety
8078    // backstop. This lane's engine carries none of it — `NodeKind::DwAgent`
8079    // has no `conversational` flag, `dispatch_node` has no conversational
8080    // branch, and `NodeControl` has no `LoopHere`/`AwaitHere` variants for
8081    // such a branch to return. `FlowState::park_turns` survives only as
8082    // snapshot-compatibility ballast; no lib code ever bumps it.
8083    //
8084    // While `agentic-worker` was a stub these tests were invisible: nothing
8085    // here compiled, so the gap read as covered. Turning the feature back on
8086    // exposed that. They are kept verbatim behind their own off-by-default
8087    // feature so the debt is explicit and the spec survives for whoever ports
8088    // the park-loop. It is a bare cfg rather than a cargo feature because CI
8089    // builds `--all-features`, which would switch a feature on; build it with
8090    // `RUSTFLAGS="--cfg conversational_dw_agent_port"`, and expect it NOT to
8091    // compile until the port lands — that is the point.
8092    #[cfg(conversational_dw_agent_port)]
8093    mod conversational_dw_agent {
8094        use super::*;
8095
8096        #[cfg(feature = "agentic-worker")]
8097        struct StubAgentHandler {
8098            payload: serde_json::Value,
8099        }
8100        #[cfg(feature = "agentic-worker")]
8101        #[async_trait::async_trait]
8102        impl crate::runner::agent_node::AgentNodeHandler for StubAgentHandler {
8103            async fn execute(
8104                &self,
8105                _tenant_id: &str,
8106                _env_id: &str,
8107                _agent_id: &str,
8108                _session_id: &str,
8109                _flow_input: &serde_json::Value,
8110                _conversational: bool,
8111            ) -> anyhow::Result<serde_json::Value> {
8112                Ok(self.payload.clone())
8113            }
8114        }
8115
8116        /// Build a 2-node flow: a `dw.agent` node (id "agent", conversational as
8117        /// given) routing to an emit "thanks" node that ends the flow.
8118        #[cfg(feature = "agentic-worker")]
8119        fn conversational_dw_flow(conversational: bool) -> HostFlow {
8120            let mut nodes = IndexMap::new();
8121            let agent_id = NodeId::from_str("agent").unwrap();
8122            let thanks_id = NodeId::from_str("thanks").unwrap();
8123            nodes.insert(
8124                agent_id.clone(),
8125                HostNode {
8126                    kind: NodeKind::DwAgent {
8127                        agent_id: "a".to_string(),
8128                        conversational,
8129                    },
8130                    component: "dw.agent".to_string(),
8131                    component_id: "dw.agent".to_string(),
8132                    operation_name: Some("a".to_string()),
8133                    operation_in_mapping: None,
8134                    payload_expr: json!({ "user_text": "hi" }),
8135                    routing: Routing::Next {
8136                        node_id: thanks_id.clone(),
8137                    },
8138                    vars_out: None,
8139                },
8140            );
8141            nodes.insert(
8142                thanks_id.clone(),
8143                HostNode {
8144                    kind: NodeKind::BuiltinEmit {
8145                        kind: EmitKind::Response,
8146                    },
8147                    component: "emit.response".to_string(),
8148                    component_id: "emit.response".to_string(),
8149                    operation_name: None,
8150                    operation_in_mapping: None,
8151                    payload_expr: json!({ "text": "thanks" }),
8152                    routing: Routing::End,
8153                    vars_out: None,
8154                },
8155            );
8156            HostFlow {
8157                slot_schema: None,
8158                id: "conv.flow".to_string(),
8159                start: Some(agent_id),
8160                nodes,
8161                vars_init: JsonMap::new(),
8162                required_vars: Vec::new(),
8163            }
8164        }
8165
8166        /// Build an engine holding `flow` with a stub agent handler returning `payload`.
8167        /// Mirrors the FlowEngine literal in `vars_survive_park_and_resume_end_to_end`.
8168        #[cfg(feature = "agentic-worker")]
8169        fn conv_engine(flow: HostFlow, payload: serde_json::Value) -> FlowEngine {
8170            FlowEngine {
8171                rollout_ids: RolloutIds::default(),
8172                packs: Vec::new(),
8173                flows: Vec::new(),
8174                flow_sources: StdHashMap::new(),
8175                flow_cache: RwLock::new(StdHashMap::from([(
8176                    FlowKey {
8177                        pack_id: "test-pack".to_string(),
8178                        flow_id: "conv.flow".to_string(),
8179                    },
8180                    flow,
8181                )])),
8182                default_env: "local".to_string(),
8183                validation: ValidationConfig {
8184                    mode: ValidationMode::Off,
8185                },
8186                cross_pack_resolver: None,
8187                remote_dispatch_handler: None,
8188                dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
8189                agent_node_handler: Some(std::sync::Arc::new(StubAgentHandler { payload })),
8190                graph_node_handler: None,
8191                mcp_tool_source: None,
8192            }
8193        }
8194
8195        #[cfg(feature = "agentic-worker")]
8196        fn conv_ctx<'a>() -> FlowContext<'a> {
8197            FlowContext {
8198                tenant: "demo",
8199                pack_id: "test-pack",
8200                flow_id: "conv.flow",
8201                node_id: None,
8202                tool: None,
8203                action: None,
8204                session_id: Some("sess-conv"),
8205                provider_id: None,
8206                reply_scope: None,
8207                retry_config: RetryConfig {
8208                    max_attempts: 1,
8209                    base_delay_ms: 1,
8210                },
8211                attempt: 1,
8212                observer: None,
8213                mocks: None,
8214            }
8215        }
8216
8217        #[cfg(feature = "agentic-worker")]
8218        #[test]
8219        fn conversational_dw_agent_parks_and_loops_on_normal_reply() {
8220            let engine = conv_engine(
8221                conversational_dw_flow(true),
8222                json!({ "reply": "hello there", "trail": [], "terminated_by": "final_reply" }),
8223            );
8224            let rt = Runtime::new().unwrap();
8225            let result = rt
8226                .block_on(engine.execute(conv_ctx(), Value::Null))
8227                .unwrap();
8228            let snapshot = match result.status {
8229                FlowStatus::Waiting(w) => w.snapshot,
8230                other => panic!("expected Waiting (park-loop), got {other:?}"),
8231            };
8232            assert_eq!(
8233                snapshot.next_node, "agent",
8234                "must re-enter the dw.agent node itself"
8235            );
8236            // The reply is rendered in the parked output.
8237            assert!(
8238                serde_json::to_string(&result.output)
8239                    .unwrap()
8240                    .contains("hello there"),
8241                "the agent reply must be rendered before parking: {:?}",
8242                result.output
8243            );
8244        }
8245
8246        #[cfg(feature = "agentic-worker")]
8247        #[test]
8248        fn conversational_dw_agent_advances_on_conversation_ended() {
8249            let engine = conv_engine(
8250                conversational_dw_flow(true),
8251                json!({ "reply": "bye", "trail": [], "terminated_by": "conversation_ended" }),
8252            );
8253            let rt = Runtime::new().unwrap();
8254            let result = rt
8255                .block_on(engine.execute(conv_ctx(), Value::Null))
8256                .unwrap();
8257            assert!(
8258                matches!(result.status, FlowStatus::Completed),
8259                "conversation_ended must advance to the successor and complete, got {:?}",
8260                result.status
8261            );
8262        }
8263
8264        #[cfg(feature = "agentic-worker")]
8265        #[test]
8266        fn non_conversational_dw_agent_never_loops() {
8267            // Even with terminated_by == conversation_ended, a non-conversational
8268            // node just routes onward (today's one-shot behaviour) — never parks.
8269            for tb in ["final_reply", "conversation_ended"] {
8270                let engine = conv_engine(
8271                    conversational_dw_flow(false),
8272                    json!({ "reply": "x", "trail": [], "terminated_by": tb }),
8273                );
8274                let rt = Runtime::new().unwrap();
8275                let result = rt
8276                    .block_on(engine.execute(conv_ctx(), Value::Null))
8277                    .unwrap();
8278                assert!(
8279                    matches!(result.status, FlowStatus::Completed),
8280                    "non-conversational must complete (route onward) for terminated_by={tb}, got {:?}",
8281                    result.status
8282                );
8283            }
8284        }
8285
8286        /// Safety-backstop behavioral test: a conversational `dw.agent` that
8287        /// never emits `conversation_ended` must keep parking up to
8288        /// `MAX_PARK_TURNS` turns, then force-advance to the successor instead
8289        /// of trapping the flow forever.
8290        #[cfg(feature = "agentic-worker")]
8291        #[test]
8292        fn conversational_dw_agent_force_advances_after_park_loop_cap() {
8293            let engine = conv_engine(
8294                conversational_dw_flow(true),
8295                json!({ "reply": "still thinking", "trail": [], "terminated_by": "final_reply" }),
8296            );
8297            let rt = Runtime::new().unwrap();
8298
8299            let result = rt
8300                .block_on(engine.execute(conv_ctx(), Value::Null))
8301                .unwrap();
8302            let mut snapshot = match result.status {
8303                FlowStatus::Waiting(w) => w.snapshot,
8304                other => panic!("expected Waiting after turn 1, got {other:?}"),
8305            };
8306
8307            // Turns 2..MAX_PARK_TURNS (exclusive) must keep parking.
8308            for turn in 2..MAX_PARK_TURNS {
8309                let result = rt
8310                    .block_on(engine.resume(conv_ctx(), snapshot, json!({ "text": "still here" })))
8311                    .unwrap();
8312                snapshot = match result.status {
8313                    FlowStatus::Waiting(w) => w.snapshot,
8314                    other => panic!("expected Waiting at turn {turn}, got {other:?}"),
8315                };
8316            }
8317
8318            // The MAX_PARK_TURNS-th turn must force-advance instead of parking again.
8319            let result = rt
8320                .block_on(engine.resume(conv_ctx(), snapshot, json!({ "text": "still here" })))
8321                .unwrap();
8322            assert!(
8323                matches!(result.status, FlowStatus::Completed),
8324                "park-loop cap must force-advance to the successor at turn {MAX_PARK_TURNS}, got {:?}",
8325                result.status
8326            );
8327        }
8328
8329        // ── NATS conversational `dw.agent` park-loop (Task 6) ──────────────────
8330        //
8331        // These tests drive the SAME `conversational_dw_flow`/`conv_ctx` harness as
8332        // the in-process tests above, but with `DwAgentDispatch::Nats` and a stub
8333        // `RemoteDispatchHandler` that never touches a live NATS server — it just
8334        // records the dispatch and immediately returns `AwaitingResponse`, exactly
8335        // like `dw_agent_nats_mode_dispatches_remote` above. The "NATS response
8336        // arriving" half of the round trip is simulated by calling `engine.resume`
8337        // directly with a hand-built envelope `{ok, output, events, error}` — the
8338        // exact shape `dispatch_listener::decode_response` builds and that lands in
8339        // `state.entry` on a real resume (spike finding §Q2). No live NATS server is
8340        // needed or used.
8341
8342        /// Records every dispatch and immediately returns `AwaitingResponse`, so the
8343        /// engine parks without a live NATS server. Mirrors `RecordingDispatcher` in
8344        /// `dw_agent_nats_mode_dispatches_remote`, kept separate (and named for
8345        /// re-use across the tests below) since three tests share it.
8346        #[cfg(feature = "agentic-worker")]
8347        struct ScriptedNatsDispatcher {
8348            calls: Mutex<Vec<crate::runner::remote_dispatch::RemoteDispatch>>,
8349        }
8350
8351        #[cfg(feature = "agentic-worker")]
8352        #[async_trait::async_trait]
8353        impl crate::runner::remote_dispatch::RemoteDispatchHandler for ScriptedNatsDispatcher {
8354            async fn dispatch(
8355                &self,
8356                request: crate::runner::remote_dispatch::RemoteDispatch,
8357            ) -> anyhow::Result<crate::runner::remote_dispatch::RemoteDispatchAction> {
8358                let correlation_id = request.correlation_id.clone();
8359                self.calls.lock().unwrap().push(request);
8360                Ok(
8361                    crate::runner::remote_dispatch::RemoteDispatchAction::AwaitingResponse {
8362                        correlation_id,
8363                    },
8364                )
8365            }
8366        }
8367
8368        /// Build an engine holding `flow` in `DwAgentDispatch::Nats` mode, wired to
8369        /// `dispatcher`. Mirrors `conv_engine` (the in-process counterpart) so the
8370        /// two harnesses are structurally comparable.
8371        #[cfg(feature = "agentic-worker")]
8372        fn nats_conv_engine(
8373            flow: HostFlow,
8374            dispatcher: std::sync::Arc<dyn crate::runner::remote_dispatch::RemoteDispatchHandler>,
8375        ) -> FlowEngine {
8376            FlowEngine {
8377                rollout_ids: RolloutIds::default(),
8378                packs: Vec::new(),
8379                flows: Vec::new(),
8380                flow_sources: StdHashMap::new(),
8381                flow_cache: RwLock::new(StdHashMap::from([(
8382                    FlowKey {
8383                        pack_id: "test-pack".to_string(),
8384                        flow_id: "conv.flow".to_string(),
8385                    },
8386                    flow,
8387                )])),
8388                default_env: "local".to_string(),
8389                validation: ValidationConfig {
8390                    mode: ValidationMode::Off,
8391                },
8392                cross_pack_resolver: None,
8393                remote_dispatch_handler: Some(dispatcher),
8394                dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::Nats,
8395                agent_node_handler: None,
8396                graph_node_handler: None,
8397                mcp_tool_source: None,
8398            }
8399        }
8400
8401        /// Build the envelope a real NATS response resume lands in `state.entry`,
8402        /// per spike finding §Q2: `{ok, output: {reply, trail, terminated_by},
8403        /// events, error}` (mirrors `dispatch_listener::decode_response`).
8404        #[cfg(feature = "agentic-worker")]
8405        fn agent_response_envelope(reply: &str, terminated_by: &str) -> Value {
8406            json!({
8407                "ok": true,
8408                "output": { "reply": reply, "trail": [], "terminated_by": terminated_by },
8409                "events": [],
8410                "error": Value::Null,
8411            })
8412        }
8413
8414        /// Turn 1 (fresh, no prior await marker): the conversational Nats arm must
8415        /// mark the pending await, dispatch to NATS exactly once, and park via
8416        /// `NodeControl::AwaitHere` — resuming at the node itself (not the routing
8417        /// successor) with no reply surfaced yet (the response hasn't arrived).
8418        #[cfg(feature = "agentic-worker")]
8419        #[test]
8420        fn conversational_dw_agent_nats_turn1_parks_via_await_here() {
8421            let dispatcher = Arc::new(ScriptedNatsDispatcher {
8422                calls: Mutex::new(vec![]),
8423            });
8424            let engine = nats_conv_engine(conversational_dw_flow(true), dispatcher.clone());
8425            let rt = Runtime::new().unwrap();
8426
8427            let result = rt
8428                .block_on(engine.execute(conv_ctx(), Value::Null))
8429                .unwrap();
8430            let snapshot = match result.status {
8431                FlowStatus::Waiting(w) => w.snapshot,
8432                other => panic!("expected Waiting after fresh dispatch, got {other:?}"),
8433            };
8434            assert_eq!(
8435                snapshot.next_node, "agent",
8436                "AwaitHere must resume at self, not the routing successor"
8437            );
8438            assert_eq!(
8439                dispatcher.calls.lock().unwrap().len(),
8440                1,
8441                "a fresh user turn must dispatch to NATS exactly once"
8442            );
8443            assert_eq!(
8444                result.output,
8445                Value::Null,
8446                "no reply is known yet on the initial dispatch — the async response hasn't arrived"
8447            );
8448        }
8449
8450        /// Full turn cycle, behavioral: fresh dispatch → AwaitHere park; simulated
8451        /// "not ended" NATS response resume → LoopHere park (reply surfaced,
8452        /// session-keyed park awaiting the next user message); a user-reply resume
8453        /// dispatches to NATS again; a `conversation_ended` response resume →
8454        /// Completed (advanced to the successor). This is the exact turn-by-turn
8455        /// script called for in Task 6's brief.
8456        #[cfg(feature = "agentic-worker")]
8457        #[test]
8458        fn conversational_dw_agent_nats_park_loop_full_turn_cycle() {
8459            let dispatcher = Arc::new(ScriptedNatsDispatcher {
8460                calls: Mutex::new(vec![]),
8461            });
8462            let engine = nats_conv_engine(conversational_dw_flow(true), dispatcher.clone());
8463            let rt = Runtime::new().unwrap();
8464
8465            // Turn 1: fresh user turn → dispatch to NATS → AwaitHere (self, park).
8466            let result = rt
8467                .block_on(engine.execute(conv_ctx(), Value::Null))
8468                .unwrap();
8469            let snapshot = match result.status {
8470                FlowStatus::Waiting(w) => w.snapshot,
8471                other => {
8472                    panic!("expected Waiting (AwaitHere) after turn 1 dispatch, got {other:?}")
8473                }
8474            };
8475            assert_eq!(snapshot.next_node, "agent");
8476            assert_eq!(dispatcher.calls.lock().unwrap().len(), 1);
8477
8478            // Simulated NATS response resume, "not ended": LoopHere (session-keyed
8479            // park awaiting the next user message), reply surfaced.
8480            let result = rt
8481                .block_on(engine.resume(
8482                    conv_ctx(),
8483                    snapshot,
8484                    agent_response_envelope("hello there", "final_reply"),
8485                ))
8486                .unwrap();
8487            let snapshot = match result.status {
8488                FlowStatus::Waiting(w) => w.snapshot,
8489                other => {
8490                    panic!("expected Waiting (LoopHere) after not-ended response, got {other:?}")
8491                }
8492            };
8493            assert_eq!(
8494                snapshot.next_node, "agent",
8495                "LoopHere also re-enters the node itself"
8496            );
8497            assert!(
8498                serde_json::to_string(&result.output)
8499                    .unwrap()
8500                    .contains("hello there"),
8501                "the agent's reply must be surfaced once the response resume lands: {:?}",
8502                result.output
8503            );
8504            assert_eq!(
8505                dispatcher.calls.lock().unwrap().len(),
8506                1,
8507                "the response landing must not itself trigger another NATS dispatch"
8508            );
8509
8510            // User-reply resume: a fresh user turn dispatches to NATS again.
8511            let result = rt
8512                .block_on(engine.resume(conv_ctx(), snapshot, json!({ "text": "user says more" })))
8513                .unwrap();
8514            let snapshot = match result.status {
8515                FlowStatus::Waiting(w) => w.snapshot,
8516                other => {
8517                    panic!("expected Waiting (AwaitHere) after turn 2 dispatch, got {other:?}")
8518                }
8519            };
8520            assert_eq!(snapshot.next_node, "agent");
8521            assert_eq!(
8522                dispatcher.calls.lock().unwrap().len(),
8523                2,
8524                "a second fresh user turn must dispatch to NATS again"
8525            );
8526
8527            // Simulated NATS response resume, `conversation_ended`: advance to the
8528            // successor and complete.
8529            let result = rt
8530                .block_on(engine.resume(
8531                    conv_ctx(),
8532                    snapshot,
8533                    agent_response_envelope("bye", "conversation_ended"),
8534                ))
8535                .unwrap();
8536            assert!(
8537                matches!(result.status, FlowStatus::Completed),
8538                "conversation_ended response must advance to the successor and complete, got {:?}",
8539                result.status
8540            );
8541            assert_eq!(
8542                dispatcher.calls.lock().unwrap().len(),
8543                2,
8544                "conversation end must not trigger another NATS dispatch"
8545            );
8546        }
8547
8548        /// Safety-backstop parity with the in-process cap test: a NATS
8549        /// conversational `dw.agent` whose response never carries
8550        /// `conversation_ended` must keep parking (dispatch → AwaitHere →
8551        /// response-resume → LoopHere) up to `MAX_PARK_TURNS` "not ended" responses,
8552        /// then force-advance to the successor instead of trapping the flow.
8553        #[cfg(feature = "agentic-worker")]
8554        #[test]
8555        fn conversational_dw_agent_nats_force_advances_after_park_loop_cap() {
8556            let dispatcher = Arc::new(ScriptedNatsDispatcher {
8557                calls: Mutex::new(vec![]),
8558            });
8559            let engine = nats_conv_engine(conversational_dw_flow(true), dispatcher.clone());
8560            let rt = Runtime::new().unwrap();
8561
8562            // Turn 1: fresh dispatch (does not itself count toward the park cap —
8563            // the cap is bumped only on a "not ended" response, matching the
8564            // in-process semantics).
8565            let result = rt
8566                .block_on(engine.execute(conv_ctx(), Value::Null))
8567                .unwrap();
8568            let mut snapshot = match result.status {
8569                FlowStatus::Waiting(w) => w.snapshot,
8570                other => panic!("expected Waiting after turn 1 dispatch, got {other:?}"),
8571            };
8572
8573            // Responses 1..MAX_PARK_TURNS (exclusive) must keep looping: a "not
8574            // ended" response resume (LoopHere), then a user-message resume that
8575            // re-dispatches to NATS (AwaitHere) for the next response.
8576            for turn in 1..MAX_PARK_TURNS {
8577                let result = rt
8578                    .block_on(engine.resume(
8579                        conv_ctx(),
8580                        snapshot,
8581                        agent_response_envelope("still thinking", "final_reply"),
8582                    ))
8583                    .unwrap();
8584                snapshot = match result.status {
8585                    FlowStatus::Waiting(w) => w.snapshot,
8586                    other => {
8587                        panic!("expected Waiting (LoopHere) at response #{turn}, got {other:?}")
8588                    }
8589                };
8590                let result = rt
8591                    .block_on(engine.resume(conv_ctx(), snapshot, json!({ "text": "still here" })))
8592                    .unwrap();
8593                snapshot = match result.status {
8594                    FlowStatus::Waiting(w) => w.snapshot,
8595                    other => {
8596                        panic!(
8597                            "expected Waiting (AwaitHere) after user turn #{turn}, got {other:?}"
8598                        )
8599                    }
8600                };
8601            }
8602
8603            // The MAX_PARK_TURNS-th "not ended" response must force-advance instead
8604            // of parking again.
8605            let result = rt
8606                .block_on(engine.resume(
8607                    conv_ctx(),
8608                    snapshot,
8609                    agent_response_envelope("still thinking", "final_reply"),
8610                ))
8611                .unwrap();
8612            assert!(
8613                matches!(result.status, FlowStatus::Completed),
8614                "park-loop cap must force-advance to the successor at response {MAX_PARK_TURNS}, got {:?}",
8615                result.status
8616            );
8617            assert_eq!(
8618                dispatcher.calls.lock().unwrap().len(),
8619                1 + (MAX_PARK_TURNS as usize - 1),
8620                "exactly one NATS dispatch per user turn across the whole park-loop"
8621            );
8622        }
8623
8624        /// Parity: for the same scripted two-turn conversation (turn 1 replies
8625        /// "hello there", not ended; turn 2 replies "bye", `conversation_ended`),
8626        /// the NATS and in-process dispatch paths must be *observationally*
8627        /// identical — same sequence of user-visible statuses, and the same
8628        /// surfaced reply text on the parked turn.
8629        ///
8630        /// Caveat (documented, not hidden): the NATS path has one extra *internal*
8631        /// resume between user turns — the async response landing (AwaitHere →
8632        /// LoopHere) — that the in-process path does synchronously inside a single
8633        /// `execute`/`resume` call. That extra step is invisible to the flow's
8634        /// outward status/reply, which is exactly what this test asserts; it does
8635        /// NOT assert the two paths take the same number of `resume` calls.
8636        #[cfg(feature = "agentic-worker")]
8637        #[test]
8638        fn conversational_dw_agent_nats_and_inprocess_transcripts_match_for_same_script() {
8639            let rt = Runtime::new().unwrap();
8640
8641            // ── In-process transcript ──
8642            let inproc_handler = Arc::new(ScriptedAgentHandler {
8643                script: Mutex::new(std::collections::VecDeque::from(vec![
8644                    json!({ "reply": "hello there", "trail": [], "terminated_by": "final_reply" }),
8645                    json!({ "reply": "bye", "trail": [], "terminated_by": "conversation_ended" }),
8646                ])),
8647            });
8648            let inproc_engine = conv_engine_scripted(conversational_dw_flow(true), inproc_handler);
8649            let r1 = rt
8650                .block_on(inproc_engine.execute(conv_ctx(), Value::Null))
8651                .unwrap();
8652            let inproc_snapshot = match r1.status {
8653                FlowStatus::Waiting(ref w) => w.snapshot.clone(),
8654                ref other => panic!("in-process turn 1: expected Waiting, got {other:?}"),
8655            };
8656            let r2 = rt
8657                .block_on(inproc_engine.resume(
8658                    conv_ctx(),
8659                    inproc_snapshot,
8660                    json!({ "text": "more" }),
8661                ))
8662                .unwrap();
8663
8664            // ── NATS transcript, same script ──
8665            let dispatcher = Arc::new(ScriptedNatsDispatcher {
8666                calls: Mutex::new(vec![]),
8667            });
8668            let nats_engine = nats_conv_engine(conversational_dw_flow(true), dispatcher);
8669            let n1 = rt
8670                .block_on(nats_engine.execute(conv_ctx(), Value::Null))
8671                .unwrap();
8672            let n1_snapshot = match n1.status {
8673                FlowStatus::Waiting(w) => w.snapshot,
8674                other => panic!("nats turn 1 dispatch: expected Waiting, got {other:?}"),
8675            };
8676            let n1r = rt
8677                .block_on(nats_engine.resume(
8678                    conv_ctx(),
8679                    n1_snapshot,
8680                    agent_response_envelope("hello there", "final_reply"),
8681                ))
8682                .unwrap();
8683            let n1r_snapshot = match n1r.status {
8684                FlowStatus::Waiting(ref w) => w.snapshot.clone(),
8685                ref other => panic!("nats turn 1 response resume: expected Waiting, got {other:?}"),
8686            };
8687            let n2 = rt
8688                .block_on(nats_engine.resume(conv_ctx(), n1r_snapshot, json!({ "text": "more" })))
8689                .unwrap();
8690            let n2_snapshot = match n2.status {
8691                FlowStatus::Waiting(w) => w.snapshot,
8692                other => panic!("nats turn 2 dispatch: expected Waiting, got {other:?}"),
8693            };
8694            let n2r = rt
8695                .block_on(nats_engine.resume(
8696                    conv_ctx(),
8697                    n2_snapshot,
8698                    agent_response_envelope("bye", "conversation_ended"),
8699                ))
8700                .unwrap();
8701
8702            // Same user-visible status per turn.
8703            assert!(matches!(r1.status, FlowStatus::Waiting(_)));
8704            assert!(
8705                matches!(n1r.status, FlowStatus::Waiting(_)),
8706                "nats turn 1's user-visible status must also be Waiting"
8707            );
8708            assert!(matches!(r2.status, FlowStatus::Completed));
8709            assert!(
8710                matches!(n2r.status, FlowStatus::Completed),
8711                "nats turn 2 must also complete, matching the in-process transcript"
8712            );
8713
8714            // Same surfaced reply text on the parked turn.
8715            assert!(
8716                serde_json::to_string(&r1.output)
8717                    .unwrap()
8718                    .contains("hello there"),
8719                "in-process turn 1 must surface the reply: {:?}",
8720                r1.output
8721            );
8722            assert!(
8723                serde_json::to_string(&n1r.output)
8724                    .unwrap()
8725                    .contains("hello there"),
8726                "nats turn 1 must surface the identical reply once the response resume lands: {:?}",
8727                n1r.output
8728            );
8729        }
8730
8731        /// Build the error envelope shape a NATS response resume can also land in
8732        /// `state.entry`: `{ok:false, output:null, events:[], error:{code,
8733        /// message}}` (mirrors `agent_response_envelope`, but for the failure
8734        /// path — a genuine agent/transport error. This code sets no deadline of
8735        /// its own, but the same `{ok:false}` shape is also what a flow-authored
8736        /// timeout, or any other error source, would arrive as — Fix B handles it
8737        /// identically either way.
8738        #[cfg(feature = "agentic-worker")]
8739        fn agent_error_envelope(message: &str, code: Option<&str>) -> Value {
8740            json!({
8741                "ok": false,
8742                "output": Value::Null,
8743                "events": [],
8744                "error": { "code": code, "message": message },
8745            })
8746        }
8747
8748        /// Fix A (interleave guard): a user message arriving before the agent's
8749        /// NATS response must NOT be misread as that response. With the
8750        /// pending-await marker set (turn 1's fresh dispatch), a resume whose
8751        /// `state.entry` is a plain user-message shape (no `"ok"` key) must fall
8752        /// through to the fresh-dispatch branch — re-dispatching to NATS as a new
8753        /// turn and parking via `AwaitHere` again — instead of being consumed as
8754        /// a (null) agent reply. The marker must also survive: it was NOT
8755        /// consumed by the misrouted resume, only by the eventual real response.
8756        #[cfg(feature = "agentic-worker")]
8757        #[test]
8758        fn conversational_dw_agent_nats_interleaved_user_message_is_not_misread_as_response() {
8759            let dispatcher = Arc::new(ScriptedNatsDispatcher {
8760                calls: Mutex::new(vec![]),
8761            });
8762            let engine = nats_conv_engine(conversational_dw_flow(true), dispatcher.clone());
8763            let rt = Runtime::new().unwrap();
8764
8765            // Turn 1: fresh dispatch marks the pending-await and parks (AwaitHere).
8766            let result = rt
8767                .block_on(engine.execute(conv_ctx(), Value::Null))
8768                .unwrap();
8769            let snapshot = match result.status {
8770                FlowStatus::Waiting(w) => w.snapshot,
8771                other => panic!("expected Waiting after turn 1 dispatch, got {other:?}"),
8772            };
8773            assert!(
8774                snapshot.state.pending_agent_await.contains_key("agent"),
8775                "turn 1 dispatch must mark the pending await"
8776            );
8777            assert_eq!(dispatcher.calls.lock().unwrap().len(), 1);
8778
8779            // A stray user message arrives BEFORE the agent's NATS response —
8780            // same shape a real inbound activity would resume with, no `"ok"` key.
8781            let result = rt
8782                .block_on(engine.resume(
8783                    conv_ctx(),
8784                    snapshot,
8785                    json!({ "text": "are you still there?" }),
8786                ))
8787                .unwrap();
8788            let snapshot = match result.status {
8789                FlowStatus::Waiting(w) => w.snapshot,
8790                other => panic!(
8791                    "a stray user message must re-dispatch as a fresh turn (Waiting/AwaitHere), got {other:?}"
8792                ),
8793            };
8794            assert_eq!(
8795                snapshot.next_node, "agent",
8796                "the fresh re-dispatch still awaits at self"
8797            );
8798            assert_eq!(
8799                dispatcher.calls.lock().unwrap().len(),
8800                2,
8801                "the stray user message must trigger its OWN fresh NATS dispatch, not be swallowed"
8802            );
8803            assert!(
8804                snapshot.state.pending_agent_await.contains_key("agent"),
8805                "the marker must still be set for the real response to land against"
8806            );
8807            assert_eq!(
8808                result.output,
8809                Value::Null,
8810                "no reply is surfaced — this was not a misread null agent turn"
8811            );
8812            assert!(
8813                !snapshot.state.park_turns.contains_key("agent"),
8814                "a stray user message must not touch the park-loop cap"
8815            );
8816        }
8817
8818        /// Fix B (error envelope handling): a `{ok:false, ...}` response — any
8819        /// agent/transport error, or a timeout-shaped envelope from any source
8820        /// (this code no longer sets its own deadline) — must surface the error
8821        /// message as the reply, re-park via `LoopHere` (fail-safe: await the
8822        /// next user message, do not force-advance), and must NOT bump the
8823        /// park-loop turn counter. Exercises two full error cycles (error →
8824        /// user turn → error) to confirm the cap counter never advances even
8825        /// after repeated failures.
8826        #[cfg(feature = "agentic-worker")]
8827        #[test]
8828        fn conversational_dw_agent_nats_error_envelope_surfaces_and_reparks_without_cap_bump() {
8829            let dispatcher = Arc::new(ScriptedNatsDispatcher {
8830                calls: Mutex::new(vec![]),
8831            });
8832            let engine = nats_conv_engine(conversational_dw_flow(true), dispatcher.clone());
8833            let rt = Runtime::new().unwrap();
8834
8835            // Turn 1: fresh dispatch → AwaitHere.
8836            let result = rt
8837                .block_on(engine.execute(conv_ctx(), Value::Null))
8838                .unwrap();
8839            let snapshot = match result.status {
8840                FlowStatus::Waiting(w) => w.snapshot,
8841                other => panic!("expected Waiting after turn 1 dispatch, got {other:?}"),
8842            };
8843
8844            // A plain agent/transport error resumes the flow.
8845            let result = rt
8846                .block_on(engine.resume(conv_ctx(), snapshot, agent_error_envelope("boom", None)))
8847                .unwrap();
8848            let snapshot = match result.status {
8849                FlowStatus::Waiting(w) => w.snapshot,
8850                other => panic!("an error envelope must re-park (Waiting/LoopHere), got {other:?}"),
8851            };
8852            assert_eq!(
8853                snapshot.next_node, "agent",
8854                "LoopHere re-enters the node itself"
8855            );
8856            assert!(
8857                serde_json::to_string(&result.output)
8858                    .unwrap()
8859                    .contains("boom"),
8860                "the error message must be surfaced as the reply: {:?}",
8861                result.output
8862            );
8863            assert!(
8864                !snapshot.state.park_turns.contains_key("agent"),
8865                "an error response must NOT bump the park-loop cap counter"
8866            );
8867
8868            // A user turn in between re-dispatches (as usual).
8869            let result = rt
8870                .block_on(engine.resume(conv_ctx(), snapshot, json!({ "text": "hello?" })))
8871                .unwrap();
8872            let snapshot = match result.status {
8873                FlowStatus::Waiting(w) => w.snapshot,
8874                other => panic!("expected Waiting (AwaitHere) after user turn, got {other:?}"),
8875            };
8876            assert_eq!(dispatcher.calls.lock().unwrap().len(), 2);
8877
8878            // A timeout-coded envelope (this code sets no deadline of its own —
8879            // this shape would only arrive from a flow-authored deadline or some
8880            // other upstream source) behaves identically to a plain error.
8881            let result = rt
8882                .block_on(engine.resume(
8883                    conv_ctx(),
8884                    snapshot,
8885                    agent_error_envelope("timeout waiting for agent response", Some("timeout")),
8886                ))
8887                .unwrap();
8888            let snapshot = match result.status {
8889                FlowStatus::Waiting(w) => w.snapshot,
8890                other => {
8891                    panic!("a timeout envelope must also re-park (Waiting/LoopHere), got {other:?}")
8892                }
8893            };
8894            assert!(
8895                serde_json::to_string(&result.output)
8896                    .unwrap()
8897                    .contains("timeout waiting for agent response"),
8898                "the timeout message must be surfaced as the reply: {:?}",
8899                result.output
8900            );
8901            assert!(
8902                !snapshot.state.park_turns.contains_key("agent"),
8903                "two error/timeout responses in a row (with an intervening user turn) must still \
8904                 not have bumped the park-loop cap counter"
8905            );
8906        }
8907
8908        /// Scriptable `AgentNodeHandler` stub: returns the next queued payload on
8909        /// each call, so a single in-process engine can simulate a multi-turn
8910        /// conversation with a different agent output per turn (unlike
8911        /// `StubAgentHandler`, which always returns the same fixed payload).
8912        #[cfg(feature = "agentic-worker")]
8913        struct ScriptedAgentHandler {
8914            script: Mutex<std::collections::VecDeque<serde_json::Value>>,
8915        }
8916        #[cfg(feature = "agentic-worker")]
8917        #[async_trait::async_trait]
8918        impl crate::runner::agent_node::AgentNodeHandler for ScriptedAgentHandler {
8919            async fn execute(
8920                &self,
8921                _tenant_id: &str,
8922                _env_id: &str,
8923                _agent_id: &str,
8924                _session_id: &str,
8925                _flow_input: &serde_json::Value,
8926                _conversational: bool,
8927            ) -> anyhow::Result<serde_json::Value> {
8928                Ok(self
8929                    .script
8930                    .lock()
8931                    .unwrap()
8932                    .pop_front()
8933                    .expect("ScriptedAgentHandler: script exhausted"))
8934            }
8935        }
8936
8937        /// Build an in-process engine holding `flow`, wired to a `ScriptedAgentHandler`
8938        /// so each agent turn can return a different payload. Mirrors `conv_engine`
8939        /// (which uses a fixed payload for every call).
8940        #[cfg(feature = "agentic-worker")]
8941        fn conv_engine_scripted(
8942            flow: HostFlow,
8943            handler: std::sync::Arc<ScriptedAgentHandler>,
8944        ) -> FlowEngine {
8945            FlowEngine {
8946                rollout_ids: RolloutIds::default(),
8947                packs: Vec::new(),
8948                flows: Vec::new(),
8949                flow_sources: StdHashMap::new(),
8950                flow_cache: RwLock::new(StdHashMap::from([(
8951                    FlowKey {
8952                        pack_id: "test-pack".to_string(),
8953                        flow_id: "conv.flow".to_string(),
8954                    },
8955                    flow,
8956                )])),
8957                default_env: "local".to_string(),
8958                validation: ValidationConfig {
8959                    mode: ValidationMode::Off,
8960                },
8961                cross_pack_resolver: None,
8962                remote_dispatch_handler: None,
8963                dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
8964                agent_node_handler: Some(handler),
8965                graph_node_handler: None,
8966                mcp_tool_source: None,
8967            }
8968        }
8969    }
8970
8971    #[test]
8972    fn submitted_fields_reads_root_inputs_on_the_demo_path() {
8973        // Run Demo puts input ids at the entry ROOT beside `metadata`
8974        // (greentic-designer flow_demo/host.rs::build_submit_payload).
8975        let entry = json!({
8976            "amount": "500",
8977            "reason": "looks good",
8978            "metadata": { "action": "approve" }
8979        });
8980        let fields = submitted_fields(&entry);
8981        assert_eq!(fields.get("amount"), Some(&json!("500")));
8982        assert_eq!(fields.get("reason"), Some(&json!("looks good")));
8983        assert!(
8984            !fields.contains_key("metadata"),
8985            "the envelope key is not a field"
8986        );
8987        assert!(
8988            !fields.contains_key("action"),
8989            "the route discriminator is not a field"
8990        );
8991    }
8992
8993    #[test]
8994    fn submitted_fields_reads_metadata_on_the_wrapped_path() {
8995        // greentic-start wraps the activity: entry.input.metadata.*
8996        let entry = json!({
8997            "input": {
8998                "metadata": { "action": "approve", "email": "a@b.c" },
8999                "text": "hi"
9000            }
9001        });
9002        let fields = submitted_fields(&entry);
9003        assert_eq!(fields.get("email"), Some(&json!("a@b.c")));
9004        assert!(!fields.contains_key("action"));
9005        assert!(!fields.contains_key("text"), "envelope text is not a field");
9006        assert!(
9007            !fields.contains_key("input"),
9008            "the envelope root must be resolved, or `input` becomes one giant field"
9009        );
9010    }
9011
9012    #[test]
9013    fn submitted_fields_lets_the_envelope_root_win_a_collision() {
9014        let entry = json!({
9015            "email": "root@x",
9016            "metadata": { "action": "go", "email": "meta@x" }
9017        });
9018        assert_eq!(
9019            submitted_fields(&entry).get("email"),
9020            Some(&json!("root@x"))
9021        );
9022    }
9023
9024    #[test]
9025    fn submitted_fields_counts_a_root_input_named_action() {
9026        // `action` is the route discriminator ONLY in metadata. At the root it is
9027        // a real keystroke on the demo path.
9028        let entry = json!({ "action": "typed", "metadata": { "action": "approve" } });
9029        assert_eq!(
9030            submitted_fields(&entry).get("action"),
9031            Some(&json!("typed"))
9032        );
9033    }
9034
9035    #[test]
9036    fn submitted_fields_is_empty_for_a_button_with_no_inputs() {
9037        let entry = json!({ "metadata": { "action": "approve" } });
9038        assert!(submitted_fields(&entry).is_empty());
9039    }
9040
9041    /// A card node with no declared `answer_fields` — i.e. a pack built before
9042    /// this feature existed. `attach_pending_card_answers` must treat this as
9043    /// "no allow-list" (today's permissive behaviour), not "zero fields".
9044    fn plain_card_node() -> HostNode {
9045        HostNode {
9046            kind: NodeKind::Exec {
9047                target_component: "card".to_string(),
9048            },
9049            component: "component.exec".to_string(),
9050            component_id: "component.exec".to_string(),
9051            operation_name: None,
9052            operation_in_mapping: None,
9053            payload_expr: Value::Null,
9054            routing: Routing::End,
9055            vars_out: None,
9056        }
9057    }
9058
9059    #[test]
9060    fn an_old_snapshot_without_the_flag_deserializes_as_not_awaiting_submit() {
9061        // Snapshots persisted before this field existed must keep their exact
9062        // current behaviour: no answers attached.
9063        let raw = json!({
9064            "pack_id": "p",
9065            "flow_id": "f",
9066            "next_node": "card",
9067            "state": {}
9068        });
9069        let snap: FlowSnapshot = serde_json::from_value(raw).expect("legacy snapshot decodes");
9070        assert!(!snap.awaiting_submit);
9071    }
9072
9073    /// A resumed node's submitted fields must be readable from its output by a
9074    /// LATER node, through the ordinary `{{node.<id>.<field>}}` grammar.
9075    ///
9076    /// This is the whole feature, and it is also the ordering proof: the resume
9077    /// RE-DISPATCHES the parked node and `state.nodes.insert` replaces its stored
9078    /// output, so an implementation that attaches the answers before the dispatch
9079    /// makes this test fail. Do not "simplify" the merge earlier.
9080    #[test]
9081    fn submitted_answers_survive_the_resume_redispatch_and_reach_a_later_node() {
9082        let mut state = ExecutionState::new(json!({}));
9083        // The parked node has already run once; its stored output is what the
9084        // resume dispatch will REPLACE.
9085        state.nodes.insert(
9086            "card".to_string(),
9087            NodeOutput::new(json!({ "event": "rendered" })),
9088        );
9089        state.pending_card_answers = Some(PendingCardAnswers {
9090            node_id: "card".to_string(),
9091            answers: submitted_fields(&json!({
9092                "email": "a@b.c",
9093                "metadata": { "action": "submit" }
9094            })),
9095        });
9096
9097        // Simulate the re-dispatch: a FRESH output object, exactly as the loop
9098        // builds one, then the merge at its real call position.
9099        let mut fresh = NodeOutput::new(json!({ "event": "rendered" }));
9100        attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut fresh);
9101        state.nodes.insert("card".to_string(), fresh);
9102
9103        // Read it the way a downstream node's config would.
9104        let ctx = template_context(&state, Value::Null);
9105        let rendered = render_template_value(
9106            &json!("{{node.card.answers.email}}"),
9107            &ctx,
9108            TemplateOptions::default(),
9109        )
9110        .expect("render");
9111        assert_eq!(rendered, json!("a@b.c"));
9112    }
9113
9114    #[test]
9115    fn the_card_render_context_carries_the_answers_too() {
9116        // One write, two read surfaces: `outputs_map()` feeds {{node.…}} and
9117        // `context()` feeds the `state` handed to the adaptive-card component.
9118        let mut state = ExecutionState::new(json!({}));
9119        state.pending_card_answers = Some(PendingCardAnswers {
9120            node_id: "card".to_string(),
9121            answers: submitted_fields(&json!({ "email": "a@b.c" })),
9122        });
9123        let mut output = NodeOutput::new(json!({ "event": "rendered" }));
9124        attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut output);
9125        state.nodes.insert("card".to_string(), output);
9126
9127        let ctx = state.context();
9128        assert_eq!(
9129            ctx["nodes"]["card"]["payload"]["answers"]["email"],
9130            json!("a@b.c")
9131        );
9132    }
9133
9134    #[test]
9135    fn answers_are_absent_before_any_submit() {
9136        // Absent is not empty: absent means never submitted, {} means submitted
9137        // with no fields. Do not collapse the two.
9138        let mut state = ExecutionState::new(json!({}));
9139        let mut output = NodeOutput::new(json!({ "event": "rendered" }));
9140        attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut output);
9141        assert!(output.payload.get("answers").is_none());
9142    }
9143
9144    #[test]
9145    fn a_button_with_no_inputs_yields_an_empty_answers_object() {
9146        let mut state = ExecutionState::new(json!({}));
9147        state.pending_card_answers = Some(PendingCardAnswers {
9148            node_id: "card".to_string(),
9149            answers: submitted_fields(&json!({ "metadata": { "action": "approve" } })),
9150        });
9151        let mut output = NodeOutput::new(json!({ "event": "rendered" }));
9152        attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut output);
9153        assert_eq!(output.payload["answers"], json!({}));
9154    }
9155
9156    #[test]
9157    fn the_pending_answers_are_consumed_exactly_once() {
9158        // A loop back through the same node without a resume must not re-attach
9159        // stale answers.
9160        let mut state = ExecutionState::new(json!({}));
9161        state.pending_card_answers = Some(PendingCardAnswers {
9162            node_id: "card".to_string(),
9163            answers: submitted_fields(&json!({ "email": "a@b.c" })),
9164        });
9165        let mut first = NodeOutput::new(json!({ "event": "rendered" }));
9166        attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut first);
9167        assert!(first.payload.get("answers").is_some());
9168
9169        let mut second = NodeOutput::new(json!({ "event": "rendered" }));
9170        attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut second);
9171        assert!(
9172            second.payload.get("answers").is_none(),
9173            "consumed, not read"
9174        );
9175    }
9176
9177    #[test]
9178    fn answers_attach_even_when_the_redispatch_failed() {
9179        // The answers are real regardless of whether the re-render succeeded.
9180        // Dropping them would let a transient render error destroy what someone
9181        // typed.
9182        let mut state = ExecutionState::new(json!({}));
9183        state.pending_card_answers = Some(PendingCardAnswers {
9184            node_id: "card".to_string(),
9185            answers: submitted_fields(&json!({ "email": "a@b.c" })),
9186        });
9187        let mut output = NodeOutput::new(json!({ "ok": false, "error": { "code": "boom" } }));
9188        attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut output);
9189        assert_eq!(output.payload["answers"]["email"], json!("a@b.c"));
9190    }
9191
9192    #[test]
9193    fn answers_are_not_attached_to_a_different_node() {
9194        let mut state = ExecutionState::new(json!({}));
9195        state.pending_card_answers = Some(PendingCardAnswers {
9196            node_id: "card".to_string(),
9197            answers: submitted_fields(&json!({ "email": "a@b.c" })),
9198        });
9199        let mut other = NodeOutput::new(json!({ "event": "rendered" }));
9200        attach_pending_card_answers(&mut state, "next_step", &plain_card_node(), &mut other);
9201        assert!(other.payload.get("answers").is_none());
9202        assert!(
9203            state.pending_card_answers.is_some(),
9204            "still pending for its own node"
9205        );
9206    }
9207
9208    /// THE defect this feature closes. On the `greentic-start` path
9209    /// `entry.input` is a whole `ChannelMessageEnvelope` (see
9210    /// `greentic-types::messaging`), not a flat map of input ids — so without
9211    /// an allow-list, `submitted_fields` unions transport identity
9212    /// (`tenant`, `session_id`, `from`, `attachments`, ...), routing keys
9213    /// (`nextCardId`, `route`), channel keys (`env`, `team`, `locale`,
9214    /// `autoStart`), and greentic-start's own injected pack setup answers
9215    /// (`url`, `model`, `provider`, `api_key_secret` — a `secrets://...`
9216    /// reference) into `answers`, under a key documented as "the fields
9217    /// someone typed into this card". With a declared `answer_fields`
9218    /// allow-list, none of that must survive — only the two genuine card
9219    /// inputs.
9220    #[test]
9221    fn answers_intersect_the_declared_allow_list_on_a_wrapped_start_envelope() {
9222        let entry = json!({
9223            "input": {
9224                "id": "msg-1",
9225                "tenant": "acme",
9226                "channel": "webchat",
9227                "session_id": "sess-1",
9228                "from": "user-1",
9229                "to": "bot-1",
9230                "correlation_id": "corr-1",
9231                "attachments": [],
9232                "metadata": {
9233                    "action": "submit",
9234                    "nextCardId": "confirm",
9235                    "route": "default",
9236                    "env": "prod",
9237                    "team": "support",
9238                    "locale": "en-US",
9239                    "autoStart": true,
9240                    "url": "https://api.example.com",
9241                    "model": "gpt-4",
9242                    "provider": "openai",
9243                    "api_key_secret": "secrets://tenant/acme/openai_key",
9244                    "full_name": "Ada Lovelace",
9245                    "email": "ada@example.com"
9246                },
9247                "text": "submitted"
9248            }
9249        });
9250
9251        let mut state = ExecutionState::new(json!({}));
9252        state.pending_card_answers = Some(PendingCardAnswers {
9253            node_id: "card".to_string(),
9254            answers: submitted_fields(&entry),
9255        });
9256
9257        let node = HostNode {
9258            payload_expr: json!({ "answer_fields": ["full_name", "email"] }),
9259            ..plain_card_node()
9260        };
9261        let mut output = NodeOutput::new(json!({ "event": "rendered" }));
9262        attach_pending_card_answers(&mut state, "card", &node, &mut output);
9263
9264        let answers = output.payload["answers"]
9265            .as_object()
9266            .expect("answers must be an object");
9267        assert_eq!(
9268            answers,
9269            &serde_json::Map::from_iter([
9270                ("full_name".to_string(), json!("Ada Lovelace")),
9271                ("email".to_string(), json!("ada@example.com")),
9272            ]),
9273            "answers must contain exactly the two declared card inputs, and \
9274             nothing from transport identity, routing, or pack config: {answers:?}"
9275        );
9276    }
9277
9278    #[test]
9279    fn answer_fields_absent_keeps_todays_permissive_behaviour() {
9280        // Pre-upgrade packs, and any path that never runs the designer
9281        // injector, must keep exactly today's behaviour: no allow-list means
9282        // no filtering, even on a wrapped envelope carrying non-input keys.
9283        let entry = json!({
9284            "input": {
9285                "tenant": "acme",
9286                "metadata": { "action": "submit", "email": "a@b.c" },
9287                "text": "hi"
9288            }
9289        });
9290        let mut state = ExecutionState::new(json!({}));
9291        state.pending_card_answers = Some(PendingCardAnswers {
9292            node_id: "card".to_string(),
9293            answers: submitted_fields(&entry),
9294        });
9295        let mut output = NodeOutput::new(json!({ "event": "rendered" }));
9296        attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut output);
9297
9298        assert_eq!(output.payload["answers"]["tenant"], json!("acme"));
9299        assert_eq!(output.payload["answers"]["email"], json!("a@b.c"));
9300    }
9301
9302    #[test]
9303    fn answer_fields_declared_empty_yields_no_answers() {
9304        // A card that genuinely declares zero inputs must produce `{}` — an
9305        // empty allow-list is not the same as an absent one, and must not be
9306        // collapsed into the unfiltered set.
9307        let mut state = ExecutionState::new(json!({}));
9308        state.pending_card_answers = Some(PendingCardAnswers {
9309            node_id: "card".to_string(),
9310            answers: submitted_fields(&json!({
9311                "email": "a@b.c",
9312                "metadata": { "action": "approve" }
9313            })),
9314        });
9315        let node = HostNode {
9316            payload_expr: json!({ "answer_fields": [] }),
9317            ..plain_card_node()
9318        };
9319        let mut output = NodeOutput::new(json!({ "event": "rendered" }));
9320        attach_pending_card_answers(&mut state, "card", &node, &mut output);
9321
9322        assert_eq!(output.payload["answers"], json!({}));
9323    }
9324
9325    /// The three-way distinction `declared_answer_fields` exists to preserve:
9326    /// absent (ordinary pre-upgrade pack) and malformed (a designer-side bug)
9327    /// both fall back to the SAME permissive, unfiltered `answers` — that part
9328    /// is asserted here — but only the malformed case is meant to be logged
9329    /// (`tracing::warn!` in `attach_pending_card_answers`). This test can only
9330    /// assert the behavioural half: nothing in this crate's dev-dependencies
9331    /// captures `tracing` output (no `tracing-test`/subscriber-capture
9332    /// harness is wired up here), and adding one purely to assert a log line
9333    /// felt like more machinery than the assertion warrants. The `Malformed`
9334    /// arm's `tracing::warn!` call is exercised (not just present in source)
9335    /// by the "malformed" case below, since the same match arm both logs and
9336    /// returns the unfiltered map — a change that broke or removed the log
9337    /// call, if it also broke the fallback, would fail this test.
9338    #[test]
9339    fn absent_and_malformed_answer_fields_both_stay_permissive_but_declared_filters() {
9340        let entry = json!({ "email": "a@b.c", "phone": "555-1234" });
9341
9342        // Absent: no `answer_fields` key at all.
9343        let mut absent_state = ExecutionState::new(json!({}));
9344        absent_state.pending_card_answers = Some(PendingCardAnswers {
9345            node_id: "card".to_string(),
9346            answers: submitted_fields(&entry),
9347        });
9348        let mut absent_output = NodeOutput::new(json!({ "event": "rendered" }));
9349        attach_pending_card_answers(
9350            &mut absent_state,
9351            "card",
9352            &plain_card_node(),
9353            &mut absent_output,
9354        );
9355        assert_eq!(
9356            absent_output.payload["answers"],
9357            json!({ "email": "a@b.c", "phone": "555-1234" }),
9358            "absent must stay fully permissive"
9359        );
9360
9361        // Malformed: the key is present but is not an array of strings (e.g.
9362        // an unresolved template, or a wrong-typed value from a designer bug).
9363        let mut malformed_state = ExecutionState::new(json!({}));
9364        malformed_state.pending_card_answers = Some(PendingCardAnswers {
9365            node_id: "card".to_string(),
9366            answers: submitted_fields(&entry),
9367        });
9368        let malformed_node = HostNode {
9369            payload_expr: json!({ "answer_fields": "{{unresolved.template}}" }),
9370            ..plain_card_node()
9371        };
9372        let mut malformed_output = NodeOutput::new(json!({ "event": "rendered" }));
9373        attach_pending_card_answers(
9374            &mut malformed_state,
9375            "card",
9376            &malformed_node,
9377            &mut malformed_output,
9378        );
9379        assert_eq!(
9380            malformed_output.payload["answers"],
9381            json!({ "email": "a@b.c", "phone": "555-1234" }),
9382            "malformed must ALSO stay fully permissive, same as absent"
9383        );
9384
9385        // Declared: a valid, non-empty allow-list actually filters.
9386        let mut declared_state = ExecutionState::new(json!({}));
9387        declared_state.pending_card_answers = Some(PendingCardAnswers {
9388            node_id: "card".to_string(),
9389            answers: submitted_fields(&entry),
9390        });
9391        let declared_node = HostNode {
9392            payload_expr: json!({ "answer_fields": ["email"] }),
9393            ..plain_card_node()
9394        };
9395        let mut declared_output = NodeOutput::new(json!({ "event": "rendered" }));
9396        attach_pending_card_answers(
9397            &mut declared_state,
9398            "card",
9399            &declared_node,
9400            &mut declared_output,
9401        );
9402        assert_eq!(
9403            declared_output.payload["answers"],
9404            json!({ "email": "a@b.c" }),
9405            "declared must filter down to exactly the allow-listed keys"
9406        );
9407    }
9408
9409    #[test]
9410    fn pending_from_snapshot_names_next_node_when_awaiting_submit() {
9411        let snapshot = FlowSnapshot {
9412            pack_id: "p".to_string(),
9413            flow_id: "f".to_string(),
9414            next_flow: None,
9415            next_node: "card".to_string(),
9416            awaiting_submit: true,
9417            state: ExecutionState::new(json!({})),
9418        };
9419        let input = json!({ "email": "a@b.c", "metadata": { "action": "submit" } });
9420        let pending = pending_from_snapshot(&snapshot, &input).expect("should park answers");
9421        assert_eq!(pending.node_id, "card");
9422        assert_eq!(pending.answers.get("email"), Some(&json!("a@b.c")));
9423    }
9424
9425    #[test]
9426    fn pending_from_snapshot_is_none_when_not_awaiting_submit() {
9427        let snapshot = FlowSnapshot {
9428            pack_id: "p".to_string(),
9429            flow_id: "f".to_string(),
9430            next_flow: None,
9431            next_node: "successor".to_string(),
9432            awaiting_submit: false,
9433            state: ExecutionState::new(json!({})),
9434        };
9435        let input = json!({ "email": "a@b.c" });
9436        assert!(pending_from_snapshot(&snapshot, &input).is_none());
9437    }
9438
9439    /// Build a two-node flow: `card` (`Routing::Custom`, parks until
9440    /// `response.action == "submit"`) -> `next` (reads
9441    /// `{{node.card.answers.email}}`). `card`'s first pass has no
9442    /// `response.action`, so its conditional routing falls through and it
9443    /// parks with `awaiting_submit: true`.
9444    /// Two chained cards that route on the SAME action, then a terminal node.
9445    ///
9446    /// This is the shape every designer card journey has: each page's Continue
9447    /// button submits `action = "continue"`, and each page's routing tests for
9448    /// it.
9449    fn two_card_chain_flow() -> Flow {
9450        let card_node = |id: &str, to: Option<&str>| Node {
9451            id: NodeId::from_str(id).unwrap(),
9452            component: FlowComponentRef {
9453                id: "emit.log".parse().unwrap(),
9454                pack_alias: None,
9455                operation: None,
9456            },
9457            input: InputMapping {
9458                mapping: json!({ "card": id }),
9459            },
9460            output: OutputMapping {
9461                mapping: Value::Null,
9462            },
9463            err_map: None,
9464            routing: match to {
9465                Some(target) => Routing::Custom(json!([
9466                    { "condition": "response.action == \"submit\"", "to": target }
9467                ])),
9468                None => Routing::End,
9469            },
9470            telemetry: TelemetryHints::default(),
9471            conversational: false,
9472        };
9473
9474        let mut nodes = indexmap::IndexMap::default();
9475        for (id, to) in [
9476            ("card1", Some("card2")),
9477            ("card2", Some("card3")),
9478            ("card3", None),
9479        ] {
9480            nodes.insert(NodeId::from_str(id).unwrap(), card_node(id, to));
9481        }
9482
9483        Flow {
9484            schema_version: "1.0".into(),
9485            id: FlowId::from_str("two.card.flow").unwrap(),
9486            kind: FlowKind::Messaging,
9487            entrypoints: BTreeMap::from([(
9488                "default".to_string(),
9489                Value::String("card1".to_string()),
9490            )]),
9491            nodes,
9492            metadata: FlowMetadata {
9493                title: None,
9494                description: None,
9495                tags: Default::default(),
9496                extra: json!({}),
9497            },
9498        }
9499    }
9500
9501    /// One submit must advance the journey by exactly ONE card.
9502    ///
9503    /// `response.*` is synthesised from the run's entry envelope, so it is
9504    /// run-scoped: without consuming it, the action that moved `card1` matches
9505    /// again at `card2` and the run walks straight past it. Measured on the
9506    /// meridian quote journey: page 1's Continue landed the user on page 3.
9507    ///
9508    /// The submit belongs to the node it was delivered to. Once that node has
9509    /// routed on it, later nodes in the same run must see a fresh card with no
9510    /// pending action, park, and wait for the user.
9511    #[test]
9512    fn one_submit_advances_exactly_one_card() {
9513        let host_flow = HostFlow::from(two_card_chain_flow());
9514        let (flow_id, pack_id) = ("two.card.flow", "test-pack");
9515        let engine = FlowEngine {
9516            rollout_ids: RolloutIds::default(),
9517            packs: Vec::new(),
9518            flows: Vec::new(),
9519            flow_sources: StdHashMap::new(),
9520            messaging_provider_pack_ids: std::collections::HashSet::new(),
9521            flow_cache: RwLock::new(StdHashMap::from([(
9522                FlowKey {
9523                    pack_id: pack_id.to_string(),
9524                    flow_id: flow_id.to_string(),
9525                },
9526                host_flow,
9527            )])),
9528            default_env: "local".to_string(),
9529            validation: ValidationConfig {
9530                mode: ValidationMode::Off,
9531            },
9532            cross_pack_resolver: None,
9533            remote_dispatch_handler: None,
9534            #[cfg(feature = "agentic-worker")]
9535            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
9536            #[cfg(feature = "agentic-worker")]
9537            agent_node_handler: None,
9538            #[cfg(feature = "agentic-worker")]
9539            graph_node_handler: None,
9540            #[cfg(feature = "agentic-worker")]
9541            mcp_tool_source: None,
9542        };
9543        let rt = Runtime::new().unwrap();
9544        let ctx = || FlowContext {
9545            tenant: "demo",
9546            pack_id,
9547            flow_id,
9548            node_id: None,
9549            tool: None,
9550            action: None,
9551            session_id: None,
9552            provider_id: None,
9553            reply_scope: None,
9554            retry_config: RetryConfig {
9555                max_attempts: 1,
9556                base_delay_ms: 1,
9557            },
9558            attempt: 1,
9559            observer: None,
9560            mocks: None,
9561        };
9562
9563        // Turn 1: no action yet, so `card1` falls through and parks.
9564        let first = rt.block_on(engine.execute(ctx(), Value::Null)).unwrap();
9565        let snapshot = match first.status {
9566            FlowStatus::Waiting(w) => w.snapshot,
9567            other => panic!("expected Waiting at card1, got {other:?}"),
9568        };
9569        assert_eq!(snapshot.next_node, "card1");
9570
9571        // Turn 2: ONE submit. `card1` routes on it; `card2` must not.
9572        let submit = json!({ "input": { "metadata": { "action": "submit" } } });
9573        let second = rt.block_on(engine.resume(ctx(), snapshot, submit)).unwrap();
9574        match second.status {
9575            FlowStatus::Waiting(w) => assert_eq!(
9576                w.snapshot.next_node, "card2",
9577                "one submit must advance exactly one card and park at the next"
9578            ),
9579            FlowStatus::Completed => panic!(
9580                "the run reached the terminal node: the consumed action re-fired \
9581                 at card2 and skipped it"
9582            ),
9583        }
9584    }
9585
9586    fn card_answers_flow() -> Flow {
9587        let card_id = NodeId::from_str("card").unwrap();
9588        let next_id = NodeId::from_str("next").unwrap();
9589
9590        let card_node = Node {
9591            id: card_id.clone(),
9592            component: FlowComponentRef {
9593                id: "emit.log".parse().unwrap(),
9594                pack_alias: None,
9595                operation: None,
9596            },
9597            input: InputMapping {
9598                mapping: json!({ "event": "rendered" }),
9599            },
9600            output: OutputMapping {
9601                mapping: Value::Null,
9602            },
9603            err_map: None,
9604            routing: Routing::Custom(json!([
9605                { "condition": "response.action == \"submit\"", "to": next_id.to_string() }
9606            ])),
9607            telemetry: TelemetryHints::default(),
9608            conversational: false,
9609        };
9610
9611        let next_node = Node {
9612            id: next_id.clone(),
9613            component: FlowComponentRef {
9614                id: "emit.response".parse().unwrap(),
9615                pack_alias: None,
9616                operation: None,
9617            },
9618            input: InputMapping {
9619                mapping: json!({ "text": "{{node.card.answers.email}}" }),
9620            },
9621            output: OutputMapping {
9622                mapping: Value::Null,
9623            },
9624            err_map: None,
9625            routing: Routing::End,
9626            telemetry: TelemetryHints::default(),
9627            conversational: false,
9628        };
9629
9630        let mut nodes = indexmap::IndexMap::default();
9631        nodes.insert(card_id.clone(), card_node);
9632        nodes.insert(next_id.clone(), next_node);
9633
9634        Flow {
9635            schema_version: "1.0".into(),
9636            id: FlowId::from_str("card.answers.flow").unwrap(),
9637            kind: FlowKind::Messaging,
9638            entrypoints: BTreeMap::from([(
9639                "default".to_string(),
9640                Value::String(card_id.to_string()),
9641            )]),
9642            nodes,
9643            metadata: FlowMetadata {
9644                title: None,
9645                description: None,
9646                tags: Default::default(),
9647                extra: json!({}),
9648            },
9649        }
9650    }
9651
9652    /// The covering test for both findings from Task 3 code review: this
9653    /// drives the REAL `FlowEngine::resume` -> `drive_flow` -> dispatch-loop
9654    /// path, not a hand-simulated `ExecutionState`. It pins BOTH the `resume`
9655    /// wiring (`state.pending_card_answers = pending_card_answers;`, engine.rs
9656    /// near `resume`) AND the merge position immediately before
9657    /// `state.nodes.insert` in the dispatch loop: deleting or moving either
9658    /// makes this test fail, unlike the hand-simulated
9659    /// `submitted_answers_survive_the_resume_redispatch_and_reach_a_later_node`,
9660    /// whose ordering sensitivity is a property of its own three
9661    /// hand-written statements rather than of the engine.
9662    #[test]
9663    fn card_answers_survive_a_real_park_and_resume_and_reach_a_later_node() {
9664        let flow = card_answers_flow();
9665        let host_flow = HostFlow::from(flow);
9666        let flow_id = "card.answers.flow";
9667        let pack_id = "test-pack";
9668        let engine = FlowEngine {
9669            rollout_ids: RolloutIds::default(),
9670            packs: Vec::new(),
9671            flows: Vec::new(),
9672            flow_sources: StdHashMap::new(),
9673            messaging_provider_pack_ids: std::collections::HashSet::new(),
9674            flow_cache: RwLock::new(StdHashMap::from([(
9675                FlowKey {
9676                    pack_id: pack_id.to_string(),
9677                    flow_id: flow_id.to_string(),
9678                },
9679                host_flow,
9680            )])),
9681            default_env: "local".to_string(),
9682            validation: ValidationConfig {
9683                mode: ValidationMode::Off,
9684            },
9685            cross_pack_resolver: None,
9686            remote_dispatch_handler: None,
9687            #[cfg(feature = "agentic-worker")]
9688            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
9689            #[cfg(feature = "agentic-worker")]
9690            agent_node_handler: None,
9691            #[cfg(feature = "agentic-worker")]
9692            graph_node_handler: None,
9693            #[cfg(feature = "agentic-worker")]
9694            mcp_tool_source: None,
9695        };
9696        let rt = Runtime::new().unwrap();
9697
9698        let ctx1 = FlowContext {
9699            tenant: "demo",
9700            pack_id,
9701            flow_id,
9702            node_id: None,
9703            tool: None,
9704            action: None,
9705            session_id: None,
9706            provider_id: None,
9707            reply_scope: None,
9708            retry_config: RetryConfig {
9709                max_attempts: 1,
9710                base_delay_ms: 1,
9711            },
9712            attempt: 1,
9713            observer: None,
9714            mocks: None,
9715        };
9716        // First turn: no `response.action` yet, so `card`'s conditional
9717        // routing falls through and it parks awaiting the submit.
9718        let result1 = rt.block_on(engine.execute(ctx1, Value::Null)).unwrap();
9719        let snapshot = match result1.status {
9720            FlowStatus::Waiting(w) => w.snapshot,
9721            other => panic!("expected Waiting at the card node, got {other:?}"),
9722        };
9723        assert!(
9724            snapshot.awaiting_submit,
9725            "the card's conditional fall-through must set awaiting_submit"
9726        );
9727        assert_eq!(snapshot.next_node, "card");
9728
9729        // Resume with the submitted fields. `resume` must park them, the
9730        // dispatch loop must re-run `card`, and `attach_pending_card_answers`
9731        // must merge them into its FRESH re-dispatched output before `next`
9732        // runs.
9733        let ctx2 = FlowContext {
9734            tenant: "demo",
9735            pack_id,
9736            flow_id,
9737            node_id: None,
9738            tool: None,
9739            action: None,
9740            session_id: None,
9741            provider_id: None,
9742            reply_scope: None,
9743            retry_config: RetryConfig {
9744                max_attempts: 1,
9745                base_delay_ms: 1,
9746            },
9747            attempt: 1,
9748            observer: None,
9749            mocks: None,
9750        };
9751        let input = json!({ "email": "a@b.c", "metadata": { "action": "submit" } });
9752        let result2 = rt.block_on(engine.resume(ctx2, snapshot, input)).unwrap();
9753        assert!(
9754            matches!(result2.status, FlowStatus::Completed),
9755            "the submit must route past the card to `next`"
9756        );
9757        // This lane has no `FlowExecution::node_outputs` to inspect, so the
9758        // later node emits the value as a response and we assert on the flow
9759        // output. Same claim: `{{node.card.answers.email}}` resolved, which it
9760        // can only do if the resumed card attached the submitted answers.
9761        let rendered = serde_json::to_string(&result2.output).expect("encode output");
9762        assert!(
9763            rendered.contains("a@b.c"),
9764            "`next`, a LATER node, must read the answers through \
9765             {{node.card.answers.email}}; got {rendered}"
9766        );
9767    }
9768}
9769
9770use tracing::Instrument;
9771
9772pub struct FlowContext<'a> {
9773    pub tenant: &'a str,
9774    pub pack_id: &'a str,
9775    pub flow_id: &'a str,
9776    pub node_id: Option<&'a str>,
9777    pub tool: Option<&'a str>,
9778    pub action: Option<&'a str>,
9779    pub session_id: Option<&'a str>,
9780    pub provider_id: Option<&'a str>,
9781    /// Reply scope of the originating inbound activity, when known.
9782    ///
9783    /// Carried so async-dispatch nodes (`sorla.call await`) can encode the
9784    /// inbound `thread`/`reply_to` into the published correlation id. Without
9785    /// it, a wait saved against a threaded scope cannot be re-keyed on resume
9786    /// (the resumer would synthesize an empty thread/reply_to and miss the
9787    /// saved wait). See `execute_sorla_call` and `RuntimeSessionResumer`.
9788    pub reply_scope: Option<&'a greentic_types::ReplyScope>,
9789    pub retry_config: RetryConfig,
9790    pub attempt: u32,
9791    pub observer: Option<&'a dyn ExecutionObserver>,
9792    pub mocks: Option<&'a MockLayer>,
9793}
9794
9795#[derive(Copy, Clone)]
9796pub struct RetryConfig {
9797    pub max_attempts: u32,
9798    pub base_delay_ms: u64,
9799}
9800
9801/// Look across all node outputs, find the first one that finished with
9802/// `ok=false`, and lift its `meta.error` fields into
9803/// `output.metadata.error_kind` / `.error_message` / `.node_id`. Returns the
9804/// (possibly enriched) output unchanged otherwise.
9805///
9806/// This is how the executor "shows" an unhandled flow-node failure to the
9807/// caller without the flow author having to add error routing: the chat-side
9808/// provider (messaging-providers `extract_error_envelope`) picks the lifted
9809/// fields off `output.metadata` and renders a styled error card.
9810///
9811/// Takes a borrow of the node-output map rather than the whole
9812/// `ExecutionState` because the callers have already consumed `state` via
9813/// `state.finalize_with(...)`; we capture a cheap clone of `state.nodes` up
9814/// front and pass it in here.
9815/// Build an MCP node's output, marking it failed when the tool did not run.
9816///
9817/// `mcp_node::invoke` is infallible by contract: a runner without MCP
9818/// credentials, a tool missing from the tenant catalog, and a dead endpoint all
9819/// arrive as `{"error": ...}` inside `result`. Reporting `ok: true` for those
9820/// left `lift_first_node_error_from_nodes` with nothing to find, so the flow
9821/// completed clean — a Digital Worker run showed every node green and rendered
9822/// its quote card with blank fields, because the MCP call had silently done
9823/// nothing.
9824///
9825/// Only the status changes. `bound` is passed through untouched, so routing,
9826/// `node.<id>.payload`, and any flow reading the bound value behave exactly as
9827/// before; the node is simply no longer claiming success. `meta.error` uses the
9828/// shape the lift reads (`kind` + `message`).
9829fn mcp_output(bound: Value, result: &Value) -> NodeOutput {
9830    let Some(message) = result.get("error") else {
9831        return NodeOutput::new(bound);
9832    };
9833    let message = message
9834        .as_str()
9835        .map(str::to_string)
9836        .unwrap_or_else(|| message.to_string());
9837    NodeOutput {
9838        ok: false,
9839        payload: bound,
9840        meta: json!({
9841            "error": {
9842                "kind": "mcp_call_failed",
9843                "message": message,
9844            }
9845        }),
9846    }
9847}
9848
9849fn lift_first_node_error_from_nodes(output: Value, nodes: &HashMap<String, NodeOutput>) -> Value {
9850    let Some((node_id, failed)) = nodes.iter().find(|(_, out)| !out.ok) else {
9851        return output;
9852    };
9853    let err_meta = failed.meta.get("error");
9854    let message = err_meta
9855        .and_then(|e| e.get("message"))
9856        .and_then(|v| v.as_str())
9857        .unwrap_or("flow node failed");
9858    let kind = err_meta
9859        .and_then(|e| e.get("kind"))
9860        .and_then(|v| v.as_str())
9861        .unwrap_or("flow_node_failed");
9862
9863    let mut output = match output {
9864        Value::Object(map) => map,
9865        Value::Null => JsonMap::new(),
9866        other => {
9867            let mut wrap = JsonMap::new();
9868            wrap.insert("payload".to_string(), other);
9869            wrap
9870        }
9871    };
9872    let metadata_entry = output
9873        .entry("metadata".to_string())
9874        .or_insert_with(|| Value::Object(JsonMap::new()));
9875    let metadata_map = match metadata_entry {
9876        Value::Object(map) => map,
9877        _ => {
9878            *metadata_entry = Value::Object(JsonMap::new());
9879            metadata_entry.as_object_mut().unwrap()
9880        }
9881    };
9882    metadata_map
9883        .entry("error_kind".to_string())
9884        .or_insert(Value::String(kind.to_string()));
9885    metadata_map
9886        .entry("error_message".to_string())
9887        .or_insert(Value::String(message.to_string()));
9888    metadata_map
9889        .entry("node_id".to_string())
9890        .or_insert(Value::String(node_id.clone()));
9891    Value::Object(output)
9892}
9893
9894fn should_retry(err: &anyhow::Error) -> bool {
9895    let lower = err.to_string().to_lowercase();
9896    lower.contains("transient")
9897        || lower.contains("unavailable")
9898        || lower.contains("internal")
9899        || lower.contains("timeout")
9900}
9901
9902impl From<FlowRetryConfig> for RetryConfig {
9903    fn from(value: FlowRetryConfig) -> Self {
9904        Self {
9905            max_attempts: value.max_attempts.max(1),
9906            base_delay_ms: value.base_delay_ms.max(50),
9907        }
9908    }
9909}