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
3152/// Merge the submitted form fields into the entry object's own ROOT, so a node
3153/// parameter can read `{{entry.<field>}}` on either delivery path.
3154///
3155/// One submit is normalised in three independent places, and until this merge
3156/// existed `template_context` was the only one of them doing none of it:
3157///
3158/// * [`attach_pending_card_answers`] calls [`submitted_fields`] and exposes the
3159///   flat view as `node.<card>.answers.<field>`;
3160/// * [`build_routing_context`] does **not** call it — `response.*` is built
3161///   straight from [`resolve_entry_metadata`] (the metadata object, plus the
3162///   envelope `text`), which is exactly why `response.action` survives there;
3163/// * node parameters received the envelope verbatim. So `{{entry.<field>}}`
3164///   rendered the empty string on the wrapped `greentic-start` path while the
3165///   same card resolved under Run Demo, whose inputs already sit at the entry
3166///   root — one card, correct in the editor preview and blank in production.
3167///
3168/// Additive only, and **a key already present at the entry root wins**: `input`,
3169/// `tenant`, `team`, `correlation_id` and every other envelope key keep their
3170/// exact meaning, so no digest-pinned pack reading `entry.input.metadata.*` or
3171/// `in.input.metadata.*` can observe a change.
3172///
3173/// The keys [`submitted_fields`] drops stay dropped, and for node config the
3174/// reason is its own rather than inherited from the answers map: **each one is
3175/// still reachable by its raw path, so dropping it costs nothing.** `metadata`
3176/// and `text` are the envelope's bookkeeping and its message body, still at
3177/// `entry.metadata` / `entry.input.metadata` and `entry.text` /
3178/// `entry.input.text`. `action` is dropped only from INSIDE the metadata, where
3179/// it is the route discriminator — `entry.input.metadata.action` still resolves
3180/// and `response.action` is untouched — while a key named `action` at the
3181/// envelope ROOT is a real keystroke on the demo path and merges like any other
3182/// field.
3183fn merge_submitted_fields_into_entry(mut entry: Value, fields: JsonMap<String, Value>) -> Value {
3184    if let Value::Object(map) = &mut entry {
3185        for (key, value) in fields {
3186            map.entry(key).or_insert(value);
3187        }
3188    }
3189    entry
3190}
3191
3192fn template_context(state: &ExecutionState, prev: Value) -> Value {
3193    let entry = if state.entry.is_null() {
3194        Value::Object(JsonMap::new())
3195    } else {
3196        // `submitted_fields` is defined over the RAW envelope — the same value
3197        // the other two normalisations read — so compute it before aliasing,
3198        // then alias so `in.input.*` keeps resolving on the bare-message path.
3199        let fields = submitted_fields(&state.entry);
3200        merge_submitted_fields_into_entry(alias_input_to_entry(state.entry.clone()), fields)
3201    };
3202    let mut ctx = JsonMap::new();
3203    ctx.insert("entry".into(), entry.clone());
3204    ctx.insert("in".into(), entry); // alias for entry - used in flow templates
3205    ctx.insert("prev".into(), prev);
3206    ctx.insert("node".into(), Value::Object(state.outputs_map()));
3207    ctx.insert("state".into(), state.context());
3208    ctx.insert("vars".into(), Value::Object(state.vars.clone()));
3209    Value::Object(ctx)
3210}
3211
3212impl From<Flow> for HostFlow {
3213    fn from(value: Flow) -> Self {
3214        let mut nodes = IndexMap::new();
3215        for (id, node) in value.nodes {
3216            nodes.insert(id.clone(), HostNode::from(node));
3217        }
3218        let start = value
3219            .entrypoints
3220            .get("default")
3221            .and_then(Value::as_str)
3222            .and_then(|id| NodeId::from_str(id).ok())
3223            .or_else(|| nodes.keys().next().cloned());
3224        // Extract flow-level slot_schema from metadata.extra (Phase D).
3225        // The producer side (greentic-flow compile_flow) stores it under
3226        // "greentic.slot_schema" when the FlowDoc has a `slot_schema` field.
3227        let slot_schema = value
3228            .metadata
3229            .extra
3230            .get(SLOT_SCHEMA_METADATA_KEY)
3231            .filter(|v| !v.is_null())
3232            .cloned();
3233        let vars_init = value
3234            .metadata
3235            .extra
3236            .get("vars_init")
3237            .and_then(|v| v.as_object())
3238            .map(|decls| {
3239                decls
3240                    .iter()
3241                    .filter_map(|(name, decl)| {
3242                        decl.get("default").map(|d| (name.clone(), d.clone()))
3243                    })
3244                    .collect::<JsonMap<String, Value>>()
3245            })
3246            .unwrap_or_default();
3247        Self {
3248            id: value.id.as_str().to_string(),
3249            start,
3250            nodes,
3251            slot_schema,
3252            vars_init,
3253        }
3254    }
3255}
3256
3257impl From<Node> for HostNode {
3258    fn from(node: Node) -> Self {
3259        let full_ref = node.component.id.as_str().to_string();
3260        let operation_in_mapping = extract_operation_from_mapping(&node.input.mapping);
3261        // A dotted component id is only a packed "<component>.<operation>" string
3262        // when the operation isn't carried structurally elsewhere. greentic-pack
3263        // resolves a component node to a bare component symbol (e.g.
3264        // `ai.greentic.component-templates`) and keeps the operation in the input
3265        // mapping, so splitting on the last dot here would corrupt the reference
3266        // (→ `ai.greentic`, "not found in pack"). Prefer the structured operation —
3267        // from `component.operation` or the input mapping — and only fall back to
3268        // the legacy single-ID split when neither is present.
3269        let is_builtin = full_ref.starts_with("component.exec")
3270            || full_ref.starts_with("flow.")
3271            || full_ref.starts_with("emit.")
3272            || full_ref.starts_with("session.")
3273            || full_ref.starts_with("provider.")
3274            || full_ref.starts_with("dw.")
3275            || full_ref.starts_with("sorla.")
3276            || full_ref.starts_with("operala.")
3277            || full_ref.starts_with("agentic.")
3278            || full_ref.starts_with("var.")
3279            // `mcp:<server>/<tool>` is a self-contained ref; never dot-split it
3280            // into a `component.operation` pair.
3281            || full_ref.starts_with("mcp:");
3282        let (component_ref, raw_operation) =
3283            if node.component.operation.is_some() || is_builtin || operation_in_mapping.is_some() {
3284                (full_ref, node.component.operation.clone())
3285            } else if let Some(dot) = full_ref.rfind('.') {
3286                let comp = full_ref[..dot].to_string();
3287                let op = full_ref[dot + 1..].to_string();
3288                (comp, Some(op))
3289            } else {
3290                (full_ref, None)
3291            };
3292        let operation_is_component_exec = raw_operation.as_deref() == Some("component.exec");
3293        let operation_is_emit = raw_operation
3294            .as_deref()
3295            .map(|op| op.starts_with("emit."))
3296            .unwrap_or(false);
3297        let is_component_exec = component_ref == "component.exec" || operation_is_component_exec;
3298
3299        let kind = if is_component_exec {
3300            let target = if component_ref == "component.exec" {
3301                if let Some(op) = raw_operation
3302                    .as_deref()
3303                    .filter(|op| op.starts_with("emit."))
3304                {
3305                    op.to_string()
3306                } else {
3307                    extract_target_component(&node.input.mapping)
3308                        .unwrap_or_else(|| "component.exec".to_string())
3309                }
3310            } else {
3311                extract_target_component(&node.input.mapping)
3312                    .unwrap_or_else(|| component_ref.clone())
3313            };
3314            if target.starts_with("emit.") {
3315                NodeKind::BuiltinEmit {
3316                    kind: emit_kind_from_ref(&target),
3317                }
3318            } else {
3319                NodeKind::Exec {
3320                    target_component: target,
3321                }
3322            }
3323        } else if operation_is_emit {
3324            NodeKind::BuiltinEmit {
3325                kind: emit_kind_from_ref(raw_operation.as_deref().unwrap_or("emit.log")),
3326            }
3327        } else {
3328            match component_ref.as_str() {
3329                "flow.call" => NodeKind::FlowCall,
3330                "flow.goto" => NodeKind::FlowGoto,
3331                "provider.invoke" => NodeKind::ProviderInvoke,
3332                "session.wait" => NodeKind::Wait,
3333                "state.get" => NodeKind::BuiltinStateGet,
3334                "state.set" => NodeKind::BuiltinStateSet,
3335                "var.set" => {
3336                    let name = node
3337                        .input
3338                        .mapping
3339                        .get("name")
3340                        .and_then(Value::as_str)
3341                        .unwrap_or("")
3342                        .to_string();
3343                    let value = node
3344                        .input
3345                        .mapping
3346                        .get("value")
3347                        .cloned()
3348                        .unwrap_or(Value::Null);
3349                    NodeKind::VarSet { name, value }
3350                }
3351                "dw.agent" => NodeKind::DwAgent {
3352                    agent_id: raw_operation.clone().unwrap_or_default(),
3353                },
3354                "dw.agent_graph" => NodeKind::DwAgentGraph {
3355                    graph_id: raw_operation.clone().unwrap_or_default(),
3356                },
3357                "sorla.call" => NodeKind::SorlaCall {
3358                    target: raw_operation.clone().unwrap_or_default(),
3359                },
3360                "operala.call" => NodeKind::OperalaCall {
3361                    target: raw_operation.clone().unwrap_or_default(),
3362                },
3363                "agentic.call" => NodeKind::AgenticCall {
3364                    target: raw_operation.clone().unwrap_or_default(),
3365                },
3366                "telco-x.call" => NodeKind::TelcoXCall {
3367                    target: raw_operation.clone().unwrap_or_default(),
3368                },
3369                "approval.call" => NodeKind::ApprovalCall {
3370                    target: raw_operation.clone().unwrap_or_default(),
3371                },
3372                comp if comp.starts_with("emit.") => NodeKind::BuiltinEmit {
3373                    kind: emit_kind_from_ref(comp),
3374                },
3375                // LOCKED ENCODING v2 (shared with greentic-flow + designer):
3376                // `component == "mcp"` (a valid `ComponentId`) with `server` and
3377                // `tool` carried in the node PAYLOAD/config:
3378                //   payload = { server, tool, arguments, output? }.
3379                // The payload is the source of truth. A legacy
3380                // `operation = "<server>/<tool>"` (or an `mcp:<server>/<tool>`
3381                // component ref) is honored only as a defensive fallback when the
3382                // payload lacks the fields, so older packs keep loading.
3383                "mcp" => mcp_node_kind(&node.input.mapping, raw_operation.as_deref()),
3384                // `mcp:<server>/<tool>` carried verbatim in `component.id`.
3385                // `greentic_types::ComponentId` rejects `:`/`/`, so this form
3386                // only survives when the node-type string bypasses ComponentId
3387                // validation; it is still recognized as a fallback for older
3388                // packs.
3389                comp if comp.starts_with("mcp:") => mcp_node_kind(&node.input.mapping, Some(comp)),
3390                other => NodeKind::PackComponent {
3391                    component_ref: other.to_string(),
3392                },
3393            }
3394        };
3395        let component_label = match &kind {
3396            NodeKind::Exec { .. } => "component.exec".to_string(),
3397            NodeKind::PackComponent { component_ref } => component_ref.clone(),
3398            NodeKind::ProviderInvoke => "provider.invoke".to_string(),
3399            NodeKind::FlowCall => "flow.call".to_string(),
3400            NodeKind::FlowGoto => "flow.goto".to_string(),
3401            NodeKind::BuiltinEmit { kind } => emit_ref_from_kind(kind),
3402            NodeKind::BuiltinStateGet => "state.get".to_string(),
3403            NodeKind::BuiltinStateSet => "state.set".to_string(),
3404            NodeKind::VarSet { .. } => "var.set".to_string(),
3405            NodeKind::Wait => "session.wait".to_string(),
3406            NodeKind::DwAgent { .. } => "dw.agent".to_string(),
3407            NodeKind::DwAgentGraph { .. } => "dw.agent_graph".to_string(),
3408            NodeKind::SorlaCall { .. } => "sorla.call".to_string(),
3409            NodeKind::OperalaCall { .. } => "operala.call".to_string(),
3410            NodeKind::AgenticCall { .. } => "agentic.call".to_string(),
3411            NodeKind::TelcoXCall { .. } => "telco-x.call".to_string(),
3412            NodeKind::ApprovalCall { .. } => "approval.call".to_string(),
3413            NodeKind::Mcp { server_id, tool } => format!("mcp:{server_id}/{tool}"),
3414        };
3415        let operation_name = if is_component_exec && operation_is_component_exec {
3416            None
3417        } else {
3418            raw_operation.clone()
3419        };
3420        // Extract per-node output bindings before the mapping is consumed by
3421        // `payload_expr`. Stored as raw (unrendered) templates so they can be
3422        // applied after the node runs, using the node's own output as `prev`.
3423        let vars_out = node
3424            .input
3425            .mapping
3426            .get("vars_out")
3427            .and_then(Value::as_object)
3428            .cloned();
3429        let payload_expr = match kind {
3430            NodeKind::BuiltinEmit { .. } => extract_emit_payload(&node.input.mapping),
3431            // VarSet dispatch re-reads name/value from NodeKind::VarSet directly;
3432            // the payload render is redundant and must not be forwarded as node input.
3433            NodeKind::VarSet { .. } => Value::Null,
3434            _ => {
3435                // Strip the internal `vars_out` meta-key so it is never
3436                // forwarded as an input field to wasm components or other
3437                // non-emit node kinds (which may have strict schemas).
3438                let mut mapping = node.input.mapping.clone();
3439                if let Some(obj) = mapping.as_object_mut() {
3440                    obj.remove("vars_out");
3441                }
3442                mapping
3443            }
3444        };
3445        Self {
3446            kind,
3447            component: component_label,
3448            component_id: if is_component_exec {
3449                "component.exec".to_string()
3450            } else {
3451                component_ref
3452            },
3453            operation_name,
3454            operation_in_mapping,
3455            payload_expr,
3456            routing: node.routing,
3457            vars_out,
3458        }
3459    }
3460}
3461
3462/// Classify a `component == "mcp"` node into [`NodeKind::Mcp`].
3463///
3464/// LOCKED ENCODING v2: `server` and `tool` are read from the node
3465/// `payload`/config object (the source of truth). When the payload omits them,
3466/// a legacy `operation = "<server>/<tool>"` string (or an
3467/// `mcp:<server>/<tool>` component ref) is parsed as a defensive fallback for
3468/// older packs.
3469///
3470/// When neither source yields a usable `(server, tool)` pair the node falls
3471/// back to an ordinary [`NodeKind::PackComponent`], so a malformed MCP node
3472/// surfaces as a normal unknown-component error at run time rather than
3473/// panicking at load. Flow loading stays total.
3474fn mcp_node_kind(payload: &Value, legacy_ref: Option<&str>) -> NodeKind {
3475    if let Some((server_id, tool)) = crate::runner::mcp_node::server_tool_from_payload(payload) {
3476        return NodeKind::Mcp { server_id, tool };
3477    }
3478    if let Some((server_id, tool)) = legacy_ref.and_then(parse_legacy_mcp_ref) {
3479        return NodeKind::Mcp { server_id, tool };
3480    }
3481    NodeKind::PackComponent {
3482        component_ref: "mcp".to_string(),
3483    }
3484}
3485
3486/// Parse a legacy MCP server/tool reference, accepting either the bare
3487/// `"<server>/<tool>"` operation form or the prefixed `mcp:<server>/<tool>`
3488/// component-ref form. Returns `None` when either part is missing or empty.
3489fn parse_legacy_mcp_ref(reference: &str) -> Option<(String, String)> {
3490    let rest = reference.strip_prefix("mcp:").unwrap_or(reference);
3491    let (server, tool) = rest.split_once('/')?;
3492    if server.is_empty() || tool.is_empty() {
3493        return None;
3494    }
3495    Some((server.to_string(), tool.to_string()))
3496}
3497
3498fn extract_target_component(payload: &Value) -> Option<String> {
3499    match payload {
3500        Value::Object(map) => map
3501            .get("component")
3502            .or_else(|| map.get("component_ref"))
3503            .and_then(Value::as_str)
3504            .map(|s| s.to_string()),
3505        _ => None,
3506    }
3507}
3508
3509fn extract_operation_from_mapping(payload: &Value) -> Option<String> {
3510    match payload {
3511        Value::Object(map) => map
3512            .get("operation")
3513            .or_else(|| map.get("op"))
3514            .and_then(Value::as_str)
3515            .map(str::trim)
3516            .filter(|value| !value.is_empty())
3517            .map(|value| value.to_string()),
3518        _ => None,
3519    }
3520}
3521
3522fn extract_emit_payload(payload: &Value) -> Value {
3523    if let Value::Object(map) = payload {
3524        if let Some(input) = map.get("input") {
3525            return input.clone();
3526        }
3527        if let Some(inner) = map.get("payload") {
3528            return inner.clone();
3529        }
3530    }
3531    payload.clone()
3532}
3533
3534fn split_operation_payload(payload: Value) -> (Value, Value) {
3535    if let Value::Object(mut map) = payload.clone()
3536        && map.contains_key("input")
3537    {
3538        let input = map.remove("input").unwrap_or(Value::Null);
3539        let config = map.remove("config").unwrap_or(Value::Null);
3540        let legacy_only = map.keys().all(|key| {
3541            matches!(
3542                key.as_str(),
3543                "operation" | "op" | "component" | "component_ref"
3544            )
3545        });
3546        if legacy_only {
3547            return (input, config);
3548        }
3549    }
3550    (payload, Value::Null)
3551}
3552
3553fn resolve_component_operation(
3554    node_id: &str,
3555    component_label: &str,
3556    payload_operation: Option<String>,
3557    operation_override: Option<&str>,
3558    operation_in_mapping: Option<&str>,
3559) -> Result<String> {
3560    if let Some(op) = operation_override
3561        .map(str::trim)
3562        .filter(|value| !value.is_empty())
3563    {
3564        return Ok(op.to_string());
3565    }
3566
3567    if let Some(op) = payload_operation
3568        .as_deref()
3569        .map(str::trim)
3570        .filter(|value| !value.is_empty())
3571    {
3572        return Ok(op.to_string());
3573    }
3574
3575    let mut message = format!(
3576        "missing operation for node `{}` (component `{}`); expected node.component.operation to be set",
3577        node_id, component_label,
3578    );
3579    if let Some(found) = operation_in_mapping {
3580        message.push_str(&format!(
3581            ". Found operation in input.mapping (`{}`) but this is not used; pack compiler must preserve node.component.operation.",
3582            found
3583        ));
3584    }
3585    bail!(message);
3586}
3587
3588fn emit_kind_from_ref(component_ref: &str) -> EmitKind {
3589    match component_ref {
3590        "emit.log" => EmitKind::Log,
3591        "emit.response" => EmitKind::Response,
3592        other => EmitKind::Other(other.to_string()),
3593    }
3594}
3595
3596fn emit_ref_from_kind(kind: &EmitKind) -> String {
3597    match kind {
3598        EmitKind::Log => "emit.log".to_string(),
3599        EmitKind::Response => "emit.response".to_string(),
3600        EmitKind::Other(other) => other.clone(),
3601    }
3602}
3603
3604/// Returns `true` when `input` looks like an Adaptive Card invocation
3605/// (contains `card_source` or `card_spec` at the top level).
3606fn is_card_invocation(input: &Value) -> bool {
3607    if let Value::Object(map) = input {
3608        return map.contains_key("card_source") || map.contains_key("card_spec");
3609    }
3610    false
3611}
3612
3613/// When the node config declares adaptive-card defaults (`default_card_asset`,
3614/// `default_card_inline`, or `default_source`) but the runtime invocation has
3615/// no `card_source`/`card_spec` yet, lift those defaults into the invocation.
3616/// This produces a schema-valid invocation envelope so the component does not
3617/// fall back to its generic "Welcome" placeholder.
3618///
3619/// Adaptive-card defaults can arrive in either of two places depending on how
3620/// the pack was compiled:
3621/// - top-level `call.config` (post `split_operation_payload`)
3622/// - nested `call.input.config` (when the node mapping kept the
3623///   `{component, config}` shape and `split_operation_payload` left it intact)
3624fn promote_card_config_to_invocation(input: &mut Value, config: &Value) {
3625    if is_card_invocation(input) {
3626        return;
3627    }
3628
3629    let cfg_map = card_defaults_source(input, config);
3630    let Some(cfg) = cfg_map else { return };
3631
3632    let default_asset = cfg
3633        .get("default_card_asset")
3634        .and_then(Value::as_str)
3635        .map(str::trim)
3636        .filter(|value| !value.is_empty())
3637        .map(str::to_string);
3638    let default_inline = cfg
3639        .get("default_card_inline")
3640        .filter(|value| value.is_object() || value.is_array())
3641        .cloned();
3642    let default_source = cfg
3643        .get("default_source")
3644        .and_then(Value::as_str)
3645        .map(str::trim)
3646        .filter(|value| !value.is_empty())
3647        .map(str::to_lowercase);
3648
3649    if default_asset.is_none() && default_inline.is_none() && default_source.is_none() {
3650        return;
3651    }
3652
3653    let card_source = default_source.unwrap_or_else(|| {
3654        if default_inline.is_some() {
3655            "inline".to_string()
3656        } else {
3657            "asset".to_string()
3658        }
3659    });
3660
3661    let mut card_spec = serde_json::Map::new();
3662    match card_source.as_str() {
3663        "asset" => {
3664            if let Some(path) = default_asset {
3665                card_spec.insert("asset_path".into(), Value::String(path));
3666            }
3667        }
3668        "inline" => {
3669            if let Some(inline) = default_inline {
3670                card_spec.insert("inline_json".into(), inline);
3671            }
3672        }
3673        _ => {}
3674    }
3675
3676    if !matches!(input, Value::Object(_)) {
3677        *input = Value::Object(serde_json::Map::new());
3678    }
3679    if let Value::Object(map) = input {
3680        map.insert("card_source".into(), Value::String(card_source));
3681        map.insert("card_spec".into(), Value::Object(card_spec));
3682    }
3683}
3684
3685/// Locate the adaptive-card defaults config object, preferring the top-level
3686/// `call.config` when present, then falling back to a nested `input.config`
3687/// (the shape produced when `split_operation_payload` leaves the mapping
3688/// intact).
3689fn card_defaults_source<'a>(
3690    input: &'a Value,
3691    config: &'a Value,
3692) -> Option<&'a serde_json::Map<String, Value>> {
3693    if let Value::Object(map) = config {
3694        return Some(map);
3695    }
3696    if let Value::Object(map) = input
3697        && let Some(Value::Object(nested)) = map.get("config")
3698    {
3699        return Some(nested);
3700    }
3701    None
3702}
3703
3704fn inject_card_locale(payload: &mut Value, entry: &Value) {
3705    if !is_card_invocation(payload) {
3706        return;
3707    }
3708    let Value::Object(map) = payload else { return };
3709    if map.contains_key("locale") {
3710        return;
3711    }
3712    let locale = entry
3713        .pointer("/input/metadata/locale")
3714        .or_else(|| entry.pointer("/metadata/locale"))
3715        .and_then(Value::as_str);
3716    if let Some(locale) = locale {
3717        map.insert("locale".into(), Value::String(locale.to_string()));
3718    }
3719}
3720
3721/// Select an adaptive-card node's card from a `routeToCardId`/`toCardId`/
3722/// `nextCardId` carried on the flow entry (a card button's submit), so the flow
3723/// renders the routed card instead of the node's `default_card_asset`.
3724///
3725/// This keeps card navigation *inside* the flow — the runner sets the node's
3726/// `card_spec.asset_path` from the routing key, which makes
3727/// [`promote_card_config_to_invocation`] treat the input as an explicit card
3728/// invocation (so it does not overwrite it with the default), and
3729/// [`resolve_card_assets`] then inlines the routed card. It replaces the legacy
3730/// host-side "read the card from the pack and bypass the flow" shortcut.
3731///
3732/// No-ops (leaving the node's default card) when: the node is not the
3733/// adaptive-card component, the payload already carries an explicit
3734/// `card_source`/`card_spec` (author-set), or no routing key is present.
3735fn inject_card_route(payload: &mut Value, entry: &Value, node: &HostNode) {
3736    let is_adaptive_card =
3737        node.component_id().contains("adaptive-card") || node.component.contains("adaptive-card");
3738    if !is_adaptive_card || is_card_invocation(payload) {
3739        return;
3740    }
3741    let route = entry
3742        .pointer("/input/metadata/routeToCardId")
3743        .or_else(|| entry.pointer("/metadata/routeToCardId"))
3744        .or_else(|| entry.pointer("/input/metadata/toCardId"))
3745        .or_else(|| entry.pointer("/metadata/toCardId"))
3746        .or_else(|| entry.pointer("/input/metadata/nextCardId"))
3747        .or_else(|| entry.pointer("/metadata/nextCardId"))
3748        .and_then(Value::as_str)
3749        .map(str::trim)
3750        .filter(|value| !value.is_empty());
3751    let Some(route) = route else {
3752        return;
3753    };
3754
3755    if !matches!(payload, Value::Object(_)) {
3756        *payload = Value::Object(serde_json::Map::new());
3757    }
3758    if let Value::Object(map) = payload {
3759        let mut card_spec = serde_json::Map::new();
3760        card_spec.insert(
3761            "asset_path".into(),
3762            Value::String(format!("assets/cards/{route}.json")),
3763        );
3764        map.insert("card_source".into(), Value::String("asset".into()));
3765        map.insert("card_spec".into(), Value::Object(card_spec));
3766        tracing::debug!(route_to_card = %route, "inject_card_route: routed card asset selected");
3767    }
3768}
3769
3770/// Inject flow-level `slot_schema` as `slot_definitions` into the
3771/// slot-extractor component's input value. Skips injection when the input
3772/// already contains an explicit `slot_definitions` key (back-compat with
3773/// M2.4 NDA demo inline definitions). When the input is `Null`, promotes it
3774/// to an empty object first.
3775fn inject_slot_definitions(input: &mut Value, slot_schema: &Value, flow_id: &str, node_id: &str) {
3776    if input.is_null() {
3777        *input = Value::Object(serde_json::Map::new());
3778    }
3779    let Some(map) = input.as_object_mut() else {
3780        tracing::warn!(
3781            flow_id,
3782            node_id,
3783            "slot-extractor input is not an object; cannot inject slot_definitions"
3784        );
3785        return;
3786    };
3787    if map.contains_key("slot_definitions") {
3788        return;
3789    }
3790    let slot_count = slot_schema.as_array().map_or(0, Vec::len);
3791    tracing::debug!(
3792        flow_id,
3793        slot_count,
3794        "injecting flow-level slot_schema as slot_definitions into slot-extractor input"
3795    );
3796    map.insert("slot_definitions".to_string(), slot_schema.clone());
3797}
3798
3799/// Pre-resolve `card_source: "asset"` entries by reading the referenced JSON
3800/// file from the pack's assets directory and converting to
3801/// `card_source: "inline"` with `inline_json` populated.
3802///
3803/// This handles both top-level card fields and the nested `call.payload`
3804/// structure emitted by cards2pack.
3805fn resolve_card_assets(input: &mut Value, pack: &crate::pack::PackRuntime) {
3806    resolve_card_spec_asset(input, pack);
3807
3808    // Also resolve inside `call.payload` (cards2pack duplicates the card
3809    // invocation there).
3810    if let Value::Object(map) = input
3811        && let Some(Value::Object(call)) = map.get_mut("call")
3812        && let Some(payload) = call.get_mut("payload")
3813    {
3814        resolve_card_spec_asset(payload, pack);
3815    }
3816}
3817
3818/// Resolve a single card_spec asset_path → inline_json.
3819fn resolve_card_spec_asset(value: &mut Value, pack: &crate::pack::PackRuntime) {
3820    let Value::Object(map) = value else { return };
3821
3822    let is_asset = map
3823        .get("card_source")
3824        .and_then(Value::as_str)
3825        .map(|s| s.eq_ignore_ascii_case("asset"))
3826        .unwrap_or(false);
3827    if !is_asset {
3828        return;
3829    }
3830
3831    let asset_path = map
3832        .get("card_spec")
3833        .and_then(|spec| spec.get("asset_path"))
3834        .and_then(Value::as_str)
3835        .map(str::to_string);
3836
3837    let Some(asset_path) = asset_path else { return };
3838
3839    match pack.read_asset(&asset_path) {
3840        Ok(bytes) => {
3841            let card_json: Value = match serde_json::from_slice(&bytes) {
3842                Ok(v) => v,
3843                Err(err) => {
3844                    tracing::warn!(
3845                        asset_path,
3846                        %err,
3847                        "failed to parse card asset as JSON; leaving as asset reference"
3848                    );
3849                    return;
3850                }
3851            };
3852            tracing::debug!(asset_path, "pre-resolved card asset to inline_json");
3853            map.insert("card_source".into(), Value::String("inline".into()));
3854            if let Some(Value::Object(spec)) = map.get_mut("card_spec") {
3855                spec.insert("inline_json".into(), card_json);
3856                spec.remove("asset_path");
3857            }
3858        }
3859        Err(err) => {
3860            tracing::warn!(
3861                asset_path,
3862                %err,
3863                "card asset not found in pack; leaving as asset reference"
3864            );
3865        }
3866    }
3867
3868    // Pre-resolve i18n bundle: the WASM component cannot read pack assets
3869    // directly (no host resolver registered), so inline the i18n JSON into
3870    // the invocation under `card_spec.i18n_inline`. Defense-in-depth: when
3871    // the card omits an explicit `i18n_bundle_path` we still try the
3872    // conventional `assets/i18n/` location so cards that rely on
3873    // auto-generated i18n keys (e.g. cards2pack output) keep working.
3874    let configured_bundle_path = map
3875        .get("card_spec")
3876        .and_then(|spec| spec.get("i18n_bundle_path"))
3877        .and_then(Value::as_str)
3878        .map(|s| s.trim().trim_end_matches('/').to_string())
3879        .filter(|s| !s.is_empty());
3880
3881    let bundle_path = configured_bundle_path
3882        .clone()
3883        .unwrap_or_else(|| "assets/i18n".to_string());
3884
3885    let i18n_entries = load_i18n_bundle_entries(&bundle_path, |path| pack.read_asset(path));
3886
3887    if !i18n_entries.is_empty() {
3888        let locale_keys: Vec<_> = i18n_entries.keys().cloned().collect();
3889        if let Some(Value::Object(spec)) = map.get_mut("card_spec") {
3890            spec.insert("i18n_inline".into(), Value::Object(i18n_entries));
3891            if configured_bundle_path.is_some() {
3892                tracing::info!(%bundle_path, ?locale_keys, "pre-resolved i18n bundle into card_spec.i18n_inline");
3893            } else {
3894                tracing::info!(%bundle_path, ?locale_keys, "auto-discovered i18n bundle and inlined into card_spec.i18n_inline");
3895            }
3896        }
3897    }
3898}
3899
3900fn load_i18n_bundle_entries<F>(bundle_path: &str, mut read_asset: F) -> JsonMap<String, Value>
3901where
3902    F: FnMut(&str) -> Result<Vec<u8>>,
3903{
3904    let mut i18n_entries = JsonMap::new();
3905
3906    if bundle_path.ends_with(".json") {
3907        if let Ok(bytes) = read_asset(bundle_path)
3908            && let Ok(Value::Object(entries)) = serde_json::from_slice::<Value>(&bytes)
3909        {
3910            i18n_entries.insert("en".to_string(), Value::Object(entries));
3911        }
3912        return i18n_entries;
3913    }
3914
3915    let manifest_path = format!("{bundle_path}/_manifest.json");
3916    let locale_codes: Vec<String> = read_asset(&manifest_path)
3917        .ok()
3918        .and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
3919        .and_then(|value| {
3920            let locales = value
3921                .get("locales")
3922                .and_then(Value::as_array)
3923                .cloned()
3924                .or_else(|| value.as_array().cloned());
3925            locales.map(|items| {
3926                items
3927                    .iter()
3928                    .filter_map(Value::as_str)
3929                    .map(String::from)
3930                    .collect()
3931            })
3932        })
3933        .unwrap_or_default();
3934
3935    tracing::info!(%bundle_path, ?locale_codes, "i18n manifest discovered locales");
3936
3937    for locale in &locale_codes {
3938        let candidate = format!("{bundle_path}/{locale}.json");
3939        if let Ok(bytes) = read_asset(&candidate)
3940            && let Ok(Value::Object(entries)) = serde_json::from_slice::<Value>(&bytes)
3941        {
3942            i18n_entries.insert(locale.clone(), Value::Object(entries));
3943        }
3944    }
3945    if !i18n_entries.contains_key("en") {
3946        let en_path = format!("{bundle_path}/en.json");
3947        if let Ok(bytes) = read_asset(&en_path)
3948            && let Ok(Value::Object(entries)) = serde_json::from_slice::<Value>(&bytes)
3949        {
3950            i18n_entries.insert("en".to_string(), Value::Object(entries));
3951        }
3952    }
3953
3954    i18n_entries
3955}
3956
3957/// Outcome of `evaluate_custom_routing` for a node's `Routing::Custom` array.
3958///
3959/// `Next` advances the flow to the named target. `End` terminates the run.
3960/// `Wait` pauses the run at the current node so the next inbound activity
3961/// resumes here and re-evaluates the routing with the new context — this is
3962/// what allows messaging flows (welcome → ... → confirm) to behave like a
3963/// live conversation instead of restarting at the entry point on every
3964/// click.
3965#[derive(Debug)]
3966pub(crate) enum CustomRoutingDecision {
3967    Next(NodeId),
3968    End,
3969    Wait,
3970}
3971
3972/// Evaluate a node's `Routing::Custom` array against the current execution
3973/// context.
3974///
3975/// Parses `Routing::Custom(Value)` as an array of `{condition, to}` objects.
3976/// Conditions are simple equality expressions like `response.action == "about"`.
3977/// Falls back to the first route without a condition (default route).
3978///
3979/// The evaluation context includes:
3980/// - All fields from the node output payload (top-level)
3981/// - `entry` / `in` — the original flow entry (incoming message)
3982/// - `response` — synthesized from entry metadata for convenient condition checks
3983///   (e.g. `response.action` maps to `metadata.action` from the incoming envelope)
3984fn evaluate_custom_routing(
3985    raw: &Value,
3986    output: &NodeOutput,
3987    state: &ExecutionState,
3988    flow_ir: &HostFlow,
3989    node_id: &NodeId,
3990) -> CustomRoutingDecision {
3991    let routes = match raw.as_array() {
3992        Some(arr) => arr,
3993        None => {
3994            tracing::warn!(
3995                flow_id = %flow_ir.id,
3996                node_id = %node_id,
3997                "custom routing is not an array; terminating"
3998            );
3999            return CustomRoutingDecision::End;
4000        }
4001    };
4002
4003    // Build a rich context for condition evaluation:
4004    // Start with output payload, then overlay entry and synthesised "response".
4005    // The default `event` is chosen from the success/error-family port this node
4006    // actually routes on, so happy paths named `on_complete`/`on_submit` and
4007    // failure paths named `on_cancel`/`on_timeout` resolve instead of stalling
4008    // at `Wait`.
4009    let ctx = build_routing_context(
4010        output,
4011        state,
4012        default_success_event(routes),
4013        default_error_event(routes),
4014    );
4015
4016    let mut has_condition = false;
4017    for route in routes {
4018        let condition = route.get("condition").and_then(|v| v.as_str());
4019        let to = route.get("to").and_then(|v| v.as_str());
4020
4021        if let Some(cond) = condition {
4022            has_condition = true;
4023            if evaluate_simple_condition(cond, &ctx)
4024                && let Some(target) = to
4025                && let Ok(nid) = NodeId::new(target)
4026            {
4027                tracing::debug!(
4028                    flow_id = %flow_ir.id,
4029                    node_id = %node_id,
4030                    condition = cond,
4031                    target = target,
4032                    "conditional route matched"
4033                );
4034                return CustomRoutingDecision::Next(nid);
4035            }
4036        } else if let Some(target) = to
4037            && let Ok(nid) = NodeId::new(target)
4038        {
4039            tracing::debug!(
4040                flow_id = %flow_ir.id,
4041                node_id = %node_id,
4042                target = target,
4043                "default route taken"
4044            );
4045            return CustomRoutingDecision::Next(nid);
4046        }
4047    }
4048
4049    // Fall-through. When the routing array contained at least one
4050    // conditional entry, treat the unmatched fall-through as a pause: the
4051    // user's next submission should be re-evaluated against this same
4052    // node's routing rather than restarting the flow from the entry point.
4053    // Routing arrays with no conditions at all (pure unconditional `out`
4054    // terminators) remain true ends.
4055    if has_condition {
4056        tracing::debug!(
4057            flow_id = %flow_ir.id,
4058            node_id = %node_id,
4059            "no conditional route matched; pausing run at current node for resume"
4060        );
4061        CustomRoutingDecision::Wait
4062    } else {
4063        tracing::warn!(
4064            flow_id = %flow_ir.id,
4065            node_id = %node_id,
4066            "no route matched and no conditions present; terminating"
4067        );
4068        CustomRoutingDecision::End
4069    }
4070}
4071
4072/// Evaluate a simple condition expression used by `Routing::Custom` entries and
4073/// `conditional_branch` guards (e.g. `response.action == "about"`,
4074/// `register.q_age >= 18`, `msg.text contains "hello"`).
4075///
4076/// Dotted paths resolve against the JSON context; an unresolved path is false.
4077/// Operators (detected longest-token-first so `>=`/`<=` win over `>`/`<`):
4078/// - `== ` / `!=` — case-insensitive string equality.
4079/// - `>=` / `<=` / `>` / `<` — numeric ordering; both operands are parsed as
4080///   `f64`, and a non-numeric operand makes the condition false (never a panic).
4081/// - `contains` — case-insensitive substring of the resolved string.
4082fn evaluate_simple_condition(condition: &str, ctx: &Value) -> bool {
4083    if let Some((path, expected)) = split_condition(condition, "==") {
4084        return string_eq(ctx, path, expected, false);
4085    }
4086    if let Some((path, expected)) = split_condition(condition, "!=") {
4087        return string_eq(ctx, path, expected, true);
4088    }
4089    if let Some((path, expected)) = split_condition(condition, ">=") {
4090        return numeric_cmp(ctx, path, expected, |a, b| a >= b);
4091    }
4092    if let Some((path, expected)) = split_condition(condition, "<=") {
4093        return numeric_cmp(ctx, path, expected, |a, b| a <= b);
4094    }
4095    if let Some((path, expected)) = split_condition(condition, ">") {
4096        return numeric_cmp(ctx, path, expected, |a, b| a > b);
4097    }
4098    if let Some((path, expected)) = split_condition(condition, "<") {
4099        return numeric_cmp(ctx, path, expected, |a, b| a < b);
4100    }
4101    if let Some((path, expected)) = split_condition(condition, " contains ") {
4102        let needle = expected.to_lowercase();
4103        return resolve_dotted_path(ctx, path)
4104            .is_some_and(|actual| actual.to_lowercase().contains(&needle));
4105    }
4106    false
4107}
4108
4109/// Split a condition on the first occurrence of `op` into a trimmed
4110/// `(path, value)`, with surrounding quotes stripped from the value.
4111/// `None` when `op` is absent.
4112fn split_condition<'a>(condition: &'a str, op: &str) -> Option<(&'a str, &'a str)> {
4113    let idx = condition.find(op)?;
4114    let path = condition[..idx].trim();
4115    let value = condition[idx + op.len()..].trim().trim_matches('"');
4116    Some((path, value))
4117}
4118
4119/// Case-insensitive string equality of the resolved path against `expected`,
4120/// optionally negated. An unresolved path is treated as not-equal.
4121fn string_eq(ctx: &Value, path: &str, expected: &str, negate: bool) -> bool {
4122    let matches = resolve_dotted_path(ctx, path)
4123        .as_deref()
4124        .is_some_and(|a| a.eq_ignore_ascii_case(expected));
4125    if negate { !matches } else { matches }
4126}
4127
4128/// Numeric comparison of the resolved path against `expected`. Both sides are
4129/// parsed as `f64`; if either fails to parse the condition is false.
4130fn numeric_cmp(ctx: &Value, path: &str, expected: &str, cmp: impl Fn(f64, f64) -> bool) -> bool {
4131    let Some(actual) = resolve_dotted_path(ctx, path).and_then(|a| a.trim().parse::<f64>().ok())
4132    else {
4133        return false;
4134    };
4135    let Ok(rhs) = expected.parse::<f64>() else {
4136        return false;
4137    };
4138    cmp(actual, rhs)
4139}
4140
4141/// Resolve a dotted path like `response.action` against a JSON value.
4142fn resolve_dotted_path(value: &Value, path: &str) -> Option<String> {
4143    let parts: Vec<&str> = path.split('.').collect();
4144    let mut current = value;
4145    for part in &parts {
4146        current = current.get(part)?;
4147    }
4148    match current {
4149        Value::String(s) => Some(s.clone()),
4150        Value::Bool(b) => Some(b.to_string()),
4151        Value::Number(n) => Some(n.to_string()),
4152        _ => Some(current.to_string()),
4153    }
4154}
4155
4156/// Build a context object for routing condition evaluation.
4157///
4158/// The context merges the node output with the flow entry so that conditions
4159/// can reference both component results and incoming message data.
4160///
4161/// Layout:
4162/// ```text
4163/// {
4164///   ...output.payload...,     // top-level fields from component output
4165///   "entry": <flow entry>,
4166///   "in":    <flow entry>,    // alias
4167///   "response": {             // synthesised from envelope metadata
4168///     <key>: <value>,         // e.g. "action": "about"
4169///     ...
4170///   }
4171/// }
4172/// ```
4173/// Success-family outcome ports, in the priority order used to pick the default
4174/// success `event` for a node that succeeded without emitting an explicit
4175/// `outcome`. `on_success` is first so components whose success name is the
4176/// historical default keep routing unchanged (e.g. http).
4177const SUCCESS_EVENT_PORTS: [&str; 3] = ["on_success", "on_complete", "on_submit"];
4178
4179/// Error-family outcome ports, priority order, mirroring [`SUCCESS_EVENT_PORTS`]
4180/// for the failure (`ok == false`) branch. `on_error` is first so the historical
4181/// default is preserved; `on_cancel` / `on_timeout` let a node whose failure
4182/// port is named differently (qa cancel, http timeout) route instead of stalling.
4183const ERROR_EVENT_PORTS: [&str; 3] = ["on_error", "on_cancel", "on_timeout"];
4184
4185/// Whether a node opts into node_io error routing: a `Routing::Custom` array with
4186/// at least one route targeting an error-family port (`on_error` / `on_cancel` /
4187/// `on_timeout`), either as an explicit `event` field or via an `event == "<port>"`
4188/// condition (the form the designer emits). Such a node surfaces a component
4189/// failure as an `{errors}` output routed to that branch; every other node keeps
4190/// the historical hard-fail (`bail!`) on error — so this change is purely additive.
4191fn node_has_error_route(routing: &Routing) -> bool {
4192    let Routing::Custom(raw) = routing else {
4193        return false;
4194    };
4195    let Some(routes) = raw.as_array() else {
4196        return false;
4197    };
4198    routes.iter().any(|route| {
4199        let by_event = route
4200            .get("event")
4201            .and_then(Value::as_str)
4202            .is_some_and(|e| ERROR_EVENT_PORTS.contains(&e));
4203        let by_condition = route
4204            .get("condition")
4205            .and_then(Value::as_str)
4206            .is_some_and(|c| ERROR_EVENT_PORTS.iter().any(|port| c.contains(port)));
4207        by_event || by_condition
4208    })
4209}
4210
4211/// Derive the success `event` to default to when a node succeeds (`ok == true`)
4212/// but emits no explicit `outcome`. Designer-built nodes whose happy port is
4213/// `on_complete` (native `qa.process` / `llm.openai.chat` / `template_render`)
4214/// or `on_submit` (forms) compile to `event == "<port>"` conditions; with a
4215/// blanket `on_success` default those never match and the node stalls at
4216/// `Wait`. We instead pick the first success-family port the node actually has
4217/// an outgoing `event == "<port>"` edge for, so the happy path routes. Falls
4218/// back to `on_success` when no success-family port is referenced (preserving
4219/// the prior behaviour).
4220fn default_success_event(routes: &[Value]) -> &'static str {
4221    default_event(routes, &SUCCESS_EVENT_PORTS, "on_success")
4222}
4223
4224/// Failure-branch counterpart of [`default_success_event`]: the `event` to
4225/// default to when a node fails (`ok == false`) without an explicit `outcome`.
4226/// Picks the first error-family port the node actually routes on, falling back
4227/// to `on_error`.
4228fn default_error_event(routes: &[Value]) -> &'static str {
4229    default_event(routes, &ERROR_EVENT_PORTS, "on_error")
4230}
4231
4232/// Pick the first port in `ports` (priority order) that the node has an outgoing
4233/// `event == "<port>"` edge for; `fallback` when none is referenced.
4234fn default_event(routes: &[Value], ports: &[&'static str], fallback: &'static str) -> &'static str {
4235    let referenced: Vec<&str> = routes
4236        .iter()
4237        .filter_map(|route| route.get("condition").and_then(Value::as_str))
4238        .filter_map(condition_event_eq)
4239        .collect();
4240    ports
4241        .iter()
4242        .copied()
4243        .find(|port| referenced.contains(port))
4244        .unwrap_or(fallback)
4245}
4246
4247/// Extract `<value>` from an `event == "<value>"` condition; `None` for any
4248/// other shape (different path, `!=`, no `==`).
4249fn condition_event_eq(condition: &str) -> Option<&str> {
4250    let idx = condition.find("==")?;
4251    if condition[..idx].trim() != "event" {
4252        return None;
4253    }
4254    Some(condition[idx + 2..].trim().trim_matches('"'))
4255}
4256
4257/// Where the submit envelope carries its metadata, across both delivery paths.
4258///
4259/// `greentic-start` wraps the activity (`entry.input.metadata`); the direct
4260/// runner path does not (`entry.metadata`). Both the `response.*` synthesis in
4261/// [`build_routing_context`] and [`submitted_fields`] resolve through here, so
4262/// the two can never disagree about which object is the metadata.
4263fn resolve_entry_metadata(entry: &Value) -> Option<&Value> {
4264    entry
4265        .pointer("/input/metadata")
4266        .or_else(|| entry.pointer("/metadata"))
4267}
4268
4269/// The fields a person actually submitted, as one flat map.
4270///
4271/// The envelope root is `entry.input` when that is an object (the wrapped
4272/// `greentic-start` path) and `entry` itself otherwise (the Run Demo path,
4273/// whose inputs sit at the root — see that repo's
4274/// `flow_demo/host.rs::build_submit_payload`). Resolving it is not bookkeeping:
4275/// taking `entry`'s root keys directly on the wrapped path would make one field
4276/// named `input` holding the entire nested activity.
4277///
4278/// The rule:
4279///
4280/// > the envelope root's keys except `metadata` and `text`, unioned with the
4281/// > resolved metadata object's keys except `action`; on collision the envelope
4282/// > root wins.
4283///
4284/// `text` is the message body in both shapes and is already exposed as
4285/// `response.text`. `action` is the route discriminator, already exposed as
4286/// `response.action` — but a key named `action` at the envelope ROOT is counted,
4287/// because on the demo path that is a real keystroke at a different level from
4288/// the routing metadata.
4289///
4290/// NOTE: `response.*` must NOT be rebuilt on top of this. It shares only
4291/// [`resolve_entry_metadata`]. Feeding `response` this map would drop
4292/// `response.action`, which every parking card's conditional routing tests —
4293/// and a failed condition silently takes the false branch.
4294fn submitted_fields(entry: &Value) -> JsonMap<String, Value> {
4295    let mut fields = JsonMap::new();
4296    if let Some(Value::Object(meta)) = resolve_entry_metadata(entry) {
4297        for (key, value) in meta {
4298            if key == "action" {
4299                continue;
4300            }
4301            fields.insert(key.clone(), value.clone());
4302        }
4303    }
4304    let envelope = entry
4305        .get("input")
4306        .filter(|v| v.is_object())
4307        .unwrap_or(entry);
4308    if let Some(map) = envelope.as_object() {
4309        for (key, value) in map {
4310            if key == "metadata" || key == "text" {
4311                continue;
4312            }
4313            fields.insert(key.clone(), value.clone());
4314        }
4315    }
4316    fields
4317}
4318
4319/// Build the context a routing condition is evaluated against.
4320///
4321/// Layout:
4322/// ```text
4323/// {
4324///   ...output.payload...,     // this node's fields, spread at the top level
4325///   "entry":    <flow entry>,
4326///   "in":       <flow entry>, // alias for entry
4327///   "node":     { "<id>": <node_output_view>, ... },  // every prior node
4328///   "response": { <key>: <value>, ... },              // from envelope metadata
4329///   "event":    "<outcome>"   // the port this node routes on
4330/// }
4331/// ```
4332///
4333/// The spread comes FIRST and the named keys are inserted after, so
4334/// **`entry`, `in`, `node`, `response` and `event` are reserved**: a component
4335/// whose payload has a top-level field with one of those names has it shadowed
4336/// here. That is deliberate — the spread is what lets a guard say `q_age >= 18`
4337/// about its own node (and is what the designer's source-node prefix strip
4338/// relies on) — but it means those five names are not usable as payload fields
4339/// in a routed node.
4340///
4341/// `node` is the same `outputs_map()` projection [`template_context`] exposes,
4342/// so a condition resolves `node.<id>.<field>` exactly as a param template
4343/// resolves `{{node.<id>.<field>}}`. Note `vars` is NOT here: `vars.x` in a
4344/// condition does not resolve, whereas `{{vars.x}}` in a param does.
4345fn build_routing_context(
4346    output: &NodeOutput,
4347    state: &ExecutionState,
4348    success_event: &str,
4349    error_event: &str,
4350) -> Value {
4351    let mut ctx = match &output.payload {
4352        Value::Object(map) => map.clone(),
4353        _ => JsonMap::new(),
4354    };
4355
4356    // Alias `in.input` to the entry itself when the entry is the bare message
4357    // (env/revision path) so routing templates that read `in.input.*` resolve,
4358    // mirroring `template_context`. Legacy `{input: <message>}` entries are
4359    // left untouched.
4360    let entry = alias_input_to_entry(state.entry.clone());
4361    ctx.insert("entry".into(), entry.clone());
4362    ctx.insert("in".into(), entry.clone());
4363
4364    // Synthesise "response" from the envelope metadata.
4365    // greentic-start demo path: entry.input.metadata.*
4366    // greentic-runner direct path: entry.metadata.*
4367    let metadata = entry
4368        .pointer("/input/metadata")
4369        .or_else(|| entry.pointer("/metadata"));
4370
4371    let mut response = JsonMap::new();
4372    if let Some(Value::Object(meta)) = metadata {
4373        for (k, v) in meta {
4374            // Flatten string values; stringify others
4375            match v {
4376                Value::String(s) => {
4377                    response.insert(k.clone(), Value::String(s.clone()));
4378                }
4379                other => {
4380                    response.insert(k.clone(), other.clone());
4381                }
4382            }
4383        }
4384    }
4385    // Also pull text from the envelope for convenience
4386    if let Some(text) = entry
4387        .pointer("/input/text")
4388        .or_else(|| entry.pointer("/text"))
4389        .filter(|t| !t.is_null())
4390    {
4391        response.insert("text".into(), text.clone());
4392    }
4393    ctx.insert("response".into(), Value::Object(response));
4394
4395    // Inject the node's outcome as `event` so port-name routing
4396    // (`event == "<outcome>"`, emitted by the designer for nodes with multiple
4397    // outgoing edges) resolves. Prefer an explicit outcome the node emitted in
4398    // its output metadata; otherwise derive a default from `ok` — `success_event`
4399    // on success / `error_event` on failure (the success/error-family port the
4400    // node actually has an edge for; see `default_success_event` /
4401    // `default_error_event`). Without this, a multi-edge node falls through to
4402    // `Wait` at runtime.
4403    let event = output
4404        .meta
4405        .get("outcome")
4406        .and_then(Value::as_str)
4407        .map(str::to_string)
4408        .unwrap_or_else(|| {
4409            if output.ok {
4410                success_event
4411            } else {
4412                error_event
4413            }
4414            .to_string()
4415        });
4416    ctx.insert("event".into(), Value::String(event));
4417
4418    Value::Object(ctx)
4419}
4420
4421/// Pure autonomy-gate decision for an `approval.call` node. Returns `true` when
4422/// the request must go to a human (dispatch), `false` when it auto-approves.
4423///
4424/// The gate config fields (`mode`, `risk_threshold`, `confidence_threshold`)
4425/// are compiled by the designer as FLAT fields directly on the node input
4426/// (not nested under a `gate` object); `risk`/`confidence` are already
4427/// flat/dynamic values populated at flow render time.
4428fn approval_requires_human(input: &Value) -> bool {
4429    let mode = input
4430        .get("mode")
4431        .and_then(Value::as_str)
4432        .unwrap_or("always");
4433    match mode {
4434        "above_risk" => {
4435            let risk = input.get("risk").and_then(Value::as_f64).unwrap_or(0.0);
4436            let threshold = input
4437                .get("risk_threshold")
4438                .and_then(Value::as_f64)
4439                .unwrap_or(1.0);
4440            risk >= threshold
4441        }
4442        "above_confidence" => {
4443            let confidence = input
4444                .get("confidence")
4445                .and_then(Value::as_f64)
4446                .unwrap_or(0.0);
4447            let threshold = input
4448                .get("confidence_threshold")
4449                .and_then(Value::as_f64)
4450                .unwrap_or(1.0);
4451            confidence < threshold
4452        }
4453        // "always" and any unknown mode fail safe: require a human.
4454        _ => true,
4455    }
4456}
4457
4458#[cfg(test)]
4459mod approval_gate_tests {
4460    use super::approval_requires_human;
4461    use serde_json::json;
4462
4463    #[test]
4464    fn above_risk_auto_approves_below_threshold() {
4465        let input = json!({ "risk": 0.5, "mode": "above_risk", "risk_threshold": 0.7 });
4466        assert!(!approval_requires_human(&input));
4467    }
4468
4469    #[test]
4470    fn above_risk_requires_human_at_or_above_threshold() {
4471        let input = json!({ "risk": 0.9, "mode": "above_risk", "risk_threshold": 0.7 });
4472        assert!(approval_requires_human(&input));
4473    }
4474
4475    #[test]
4476    fn above_confidence_requires_human_when_low_confidence() {
4477        let input =
4478            json!({ "confidence": 0.4, "mode": "above_confidence", "confidence_threshold": 0.8 });
4479        assert!(approval_requires_human(&input));
4480    }
4481
4482    #[test]
4483    fn always_and_missing_gate_require_human() {
4484        assert!(approval_requires_human(&json!({ "mode": "always" })));
4485        assert!(approval_requires_human(&json!({})));
4486    }
4487}
4488
4489#[cfg(test)]
4490mod tests {
4491    use super::*;
4492
4493    /// The loader and the engine must agree on which op-keys are runner-native.
4494    ///
4495    /// `flow_adapter::NATIVE_OP_KEYS` decides which keys the LOADER preserves
4496    /// verbatim instead of wrapping in a `component.exec` node; the match in
4497    /// `component_label` above is what the ENGINE dispatches. Its doc says to
4498    /// keep the two in lockstep, and nothing enforced it — so `flow.goto`
4499    /// arrived with an engine arm the loader never routed a node to, which is
4500    /// silent: the node builds, loads as a generic component, and simply is not
4501    /// a goto any more.
4502    ///
4503    /// `native_op_key_for` exists to make that mechanical. It is an EXHAUSTIVE
4504    /// match, so adding a `NodeKind` variant fails to compile here until
4505    /// somebody says whether the loader must preserve it — which is the whole
4506    /// point; a test listing strings alone would have gone stale the same way
4507    /// the doc comment did.
4508    ///
4509    /// Ported from #686 on `research`, `var.set` arm included.
4510    fn native_op_key_for(kind: &NodeKind) -> Option<&'static str> {
4511        match kind {
4512            // Not op-keys: these ARE the generic component paths.
4513            NodeKind::Exec { .. } | NodeKind::PackComponent { .. } => None,
4514            // Prefix-matched by `is_native_op_key`, not listed in the array.
4515            NodeKind::BuiltinEmit { .. } | NodeKind::Mcp { .. } => None,
4516
4517            NodeKind::ProviderInvoke => Some("provider.invoke"),
4518            NodeKind::FlowCall => Some("flow.call"),
4519            NodeKind::FlowGoto => Some("flow.goto"),
4520            NodeKind::BuiltinStateGet => Some("state.get"),
4521            NodeKind::BuiltinStateSet => Some("state.set"),
4522            NodeKind::VarSet { .. } => Some("var.set"),
4523            NodeKind::Wait => Some("session.wait"),
4524            NodeKind::DwAgent { .. } => Some("dw.agent"),
4525            NodeKind::DwAgentGraph { .. } => Some("dw.agent_graph"),
4526            NodeKind::SorlaCall { .. } => Some("sorla.call"),
4527            NodeKind::OperalaCall { .. } => Some("operala.call"),
4528            NodeKind::AgenticCall { .. } => Some("agentic.call"),
4529            NodeKind::TelcoXCall { .. } => Some("telco-x.call"),
4530            NodeKind::ApprovalCall { .. } => Some("approval.call"),
4531        }
4532    }
4533
4534    #[test]
4535    fn every_engine_dispatched_op_key_is_native_to_the_loader() {
4536        // Mirrors `component_label`'s builtin arms. Each string here is one the
4537        // engine will dispatch itself, so the loader must hand it through
4538        // unwrapped.
4539        for key in [
4540            "provider.invoke",
4541            "flow.call",
4542            "flow.goto",
4543            "state.get",
4544            "state.set",
4545            "var.set",
4546            "session.wait",
4547            "dw.agent",
4548            "dw.agent_graph",
4549            "sorla.call",
4550            "operala.call",
4551            "agentic.call",
4552            "telco-x.call",
4553            "approval.call",
4554        ] {
4555            assert!(
4556                crate::runner::flow_adapter::is_native_op_key(key),
4557                "the engine dispatches `{key}`, but the loader does not treat it \
4558                 as native — it will be wrapped as a generic component and the \
4559                 engine arm becomes unreachable"
4560            );
4561        }
4562        // The two prefix families, which are deliberately not in the array.
4563        assert!(crate::runner::flow_adapter::is_native_op_key(
4564            "emit.response"
4565        ));
4566        assert!(crate::runner::flow_adapter::is_native_op_key(
4567            "mcp:srv/tool"
4568        ));
4569        // And a genuine pack component must NOT be native.
4570        assert!(!crate::runner::flow_adapter::is_native_op_key("mcp.exec"));
4571
4572        // Keeps `native_op_key_for` live: its exhaustiveness is the guard.
4573        assert_eq!(native_op_key_for(&NodeKind::FlowGoto), Some("flow.goto"));
4574    }
4575    use crate::validate::{ValidationConfig, ValidationMode};
4576    use greentic_types::{
4577        Flow, FlowComponentRef, FlowId, FlowKind, FlowMetadata, InputMapping, Node, NodeId,
4578        OutputMapping, Routing, TelemetryHints,
4579    };
4580    use serde_json::json;
4581    use std::collections::{BTreeMap, HashMap as StdHashMap};
4582    use std::str::FromStr;
4583    use std::sync::Mutex;
4584    use tokio::runtime::Runtime;
4585
4586    fn minimal_engine() -> FlowEngine {
4587        FlowEngine {
4588            packs: Vec::new(),
4589            flows: Vec::new(),
4590            flow_sources: HashMap::new(),
4591            messaging_provider_pack_ids: std::collections::HashSet::new(),
4592            flow_cache: RwLock::new(HashMap::new()),
4593            default_env: "local".to_string(),
4594            validation: ValidationConfig {
4595                mode: ValidationMode::Off,
4596            },
4597            cross_pack_resolver: None,
4598            rollout_ids: RolloutIds::default(),
4599            remote_dispatch_handler: None,
4600            #[cfg(feature = "agentic-worker")]
4601            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
4602            #[cfg(feature = "agentic-worker")]
4603            agent_node_handler: None,
4604            #[cfg(feature = "agentic-worker")]
4605            graph_node_handler: None,
4606            #[cfg(feature = "agentic-worker")]
4607            mcp_tool_source: None,
4608        }
4609    }
4610
4611    fn flow_desc(id: &str, pack_id: &str, flow_type: &str, entry: bool) -> FlowDescriptor {
4612        FlowDescriptor {
4613            id: id.into(),
4614            flow_type: flow_type.into(),
4615            pack_id: pack_id.into(),
4616            profile: pack_id.into(),
4617            version: "0.0.0".into(),
4618            description: None,
4619            entry,
4620        }
4621    }
4622
4623    #[test]
4624    fn entry_flow_by_type_disambiguates_entrypoint_from_internal_helpers() {
4625        // Regression: a pack with one public messaging entrypoint (`default`)
4626        // plus internal helper flows of the same type (dispatcher sub-flows)
4627        // must route an inbound, type-only provider event to the entrypoint —
4628        // NOT fail as "flow type messaging is ambiguous; pack_id is required".
4629        let mut engine = minimal_engine();
4630        engine.flows = vec![
4631            flow_desc("default", "weatherapi-pack", "messaging", true),
4632            flow_desc("flow_", "weatherapi-pack", "messaging", false),
4633            flow_desc("flow_error", "weatherapi-pack", "messaging", false),
4634            flow_desc("flow_get_weather", "weatherapi-pack", "messaging", false),
4635        ];
4636
4637        // Multiple flows of the type => the plain lookup is ambiguous...
4638        assert!(
4639            engine.flow_by_type("messaging").is_none(),
4640            "multiple messaging flows must be ambiguous for the plain lookup"
4641        );
4642        // ...but exactly one is an entrypoint, so entry-aware routing resolves.
4643        let resolved = engine
4644            .entry_flow_by_type("messaging")
4645            .expect("single entry flow must resolve");
4646        assert_eq!(resolved.id, "default");
4647        assert_eq!(resolved.pack_id, "weatherapi-pack");
4648    }
4649
4650    #[test]
4651    fn entry_flow_by_type_still_ambiguous_across_two_entrypoints() {
4652        // Two entrypoints of the same type across packs is genuinely ambiguous
4653        // and must still require a pack_id (no silent, arbitrary pick).
4654        let mut engine = minimal_engine();
4655        engine.flows = vec![
4656            flow_desc("default", "pack.a", "messaging", true),
4657            flow_desc("default", "pack.b", "messaging", true),
4658            flow_desc("helper", "pack.a", "messaging", false),
4659        ];
4660        assert!(engine.entry_flow_by_type("messaging").is_none());
4661    }
4662
4663    #[test]
4664    fn entry_flow_by_type_excludes_messaging_provider_pack_flows() {
4665        // Multi-provider bundle: the app pack's entry flow AND a messaging
4666        // *provider* pack's ingress `main` are both entry `messaging` flows.
4667        // The provider flow is that provider's plumbing, not the application
4668        // entrypoint, so a type-only webchat event must resolve to the app flow
4669        // — not bail "flow type messaging is ambiguous; pack_id is required".
4670        let mut engine = minimal_engine();
4671        engine.flows = vec![
4672            flow_desc("main", "hr-onboarding-pack", "messaging", true),
4673            flow_desc("main", "messaging-teams", "messaging", true),
4674        ];
4675        // `messaging-teams` declares a `messaging.*` provider in its manifest;
4676        // the engine records that at build time.
4677        engine
4678            .messaging_provider_pack_ids
4679            .insert("messaging-teams".to_string());
4680
4681        // Plain lookup is still ambiguous (two flows of the type)...
4682        assert!(engine.flow_by_type("messaging").is_none());
4683        // ...but only the app pack's flow is an *application* entrypoint.
4684        let resolved = engine
4685            .entry_flow_by_type("messaging")
4686            .expect("app entry flow must resolve past the provider flow");
4687        assert_eq!(resolved.id, "main");
4688        assert_eq!(resolved.pack_id, "hr-onboarding-pack");
4689    }
4690
4691    #[test]
4692    fn entry_flow_by_type_matches_plain_lookup_for_single_flow() {
4693        // Backward-compat: a lone flow of a type resolves the same way through
4694        // both paths, tagged entry or not.
4695        let mut engine = minimal_engine();
4696        engine.flows = vec![flow_desc("only", "pack.a", "messaging", true)];
4697        assert_eq!(
4698            engine.flow_by_type("messaging").map(|f| f.id.as_str()),
4699            Some("only")
4700        );
4701        assert_eq!(
4702            engine
4703                .entry_flow_by_type("messaging")
4704                .map(|f| f.id.as_str()),
4705            Some("only")
4706        );
4707    }
4708
4709    #[test]
4710    fn to_node_output_legacy_success_becomes_data() {
4711        // Legacy `{ok:true, ...fields}` (no node_io envelope) → Data{data}.
4712        let out = to_node_output(&json!({ "ok": true, "temp": "20C" }));
4713        assert!(out.is_ok(), "legacy ok:true must classify as Data");
4714        let data = out.data().expect("data present");
4715        assert_eq!(data.get("temp").and_then(Value::as_str), Some("20C"));
4716    }
4717
4718    #[test]
4719    fn to_node_output_legacy_error_becomes_errors() {
4720        // Legacy `{ok:false, error:{code,message}}` → Errors{errors:[NodeError]}.
4721        let out = to_node_output(
4722            &json!({ "ok": false, "error": { "code": "E_BAD", "message": "boom" } }),
4723        );
4724        assert!(!out.is_ok(), "legacy ok:false must classify as Errors");
4725        let errs = out.errors();
4726        assert_eq!(errs.len(), 1);
4727        assert_eq!(errs[0].code, "E_BAD");
4728        assert_eq!(errs[0].message, "boom");
4729    }
4730
4731    #[test]
4732    fn to_node_output_native_data_envelope_roundtrips() {
4733        // A node_io-native `{data:{...}}` envelope parses straight to Data.
4734        let out = to_node_output(&json!({ "data": { "x": 1 } }));
4735        assert!(out.is_ok());
4736        assert_eq!(
4737            out.data().and_then(|d| d.get("x")).and_then(Value::as_i64),
4738            Some(1)
4739        );
4740    }
4741
4742    #[test]
4743    fn to_node_output_native_errors_envelope_roundtrips() {
4744        // A node_io-native `{errors:[...]}` envelope parses straight to Errors.
4745        let out = to_node_output(&json!({
4746            "errors": [ { "code": "C", "message": "m", "kind": "validation",
4747                          "retryable": false, "details": {} } ]
4748        }));
4749        assert!(!out.is_ok());
4750        assert_eq!(out.errors()[0].code, "C");
4751        assert_eq!(
4752            out.errors()[0].kind,
4753            greentic_types::node_io::ErrorKind::Validation
4754        );
4755    }
4756
4757    #[test]
4758    fn to_node_output_bare_object_becomes_data() {
4759        // A bare result with no envelope keys → Data{data: <whole value>}.
4760        let out = to_node_output(&json!({ "foo": 1 }));
4761        assert!(out.is_ok());
4762        assert_eq!(
4763            out.data()
4764                .and_then(|d| d.get("foo"))
4765                .and_then(Value::as_i64),
4766            Some(1)
4767        );
4768    }
4769
4770    #[test]
4771    fn templating_renders_with_partials_and_data() {
4772        let mut state = ExecutionState::new(json!({ "city": "London" }));
4773        state.nodes.insert(
4774            "forecast".to_string(),
4775            NodeOutput::new(json!({ "temp": "20C" })),
4776        );
4777
4778        // templating context includes node outputs for runner-side payload rendering.
4779        let ctx = state.context();
4780        assert_eq!(ctx["nodes"]["forecast"]["payload"]["temp"], json!("20C"));
4781    }
4782
4783    #[test]
4784    fn outputs_map_exposes_node_io_data_and_errors_alongside_flat() {
4785        let mut state = ExecutionState::new(json!({}));
4786        state.nodes.insert(
4787            "forecast".to_string(),
4788            NodeOutput::new(json!({ "temp": "20C" })),
4789        );
4790        let outs = state.outputs_map();
4791        // Legacy flat ref `{{node.forecast.temp}}` keeps working.
4792        assert_eq!(outs["forecast"]["temp"], json!("20C"));
4793        // Canonical node_io ref `{{node.forecast.data.temp}}` resolves to the same.
4794        assert_eq!(outs["forecast"]["data"]["temp"], json!("20C"));
4795        // `{{node.forecast.errors}}` is present and empty for a success output.
4796        assert_eq!(outs["forecast"]["errors"], json!([]));
4797    }
4798
4799    #[test]
4800    fn finalize_wraps_emitted_payloads() {
4801        let mut state = ExecutionState::new(json!({}));
4802        state.push_egress(json!({ "text": "first" }));
4803        state.push_egress(json!({ "text": "second" }));
4804        let result = state.finalize_with(Some(json!({ "text": "final" })));
4805        assert_eq!(
4806            result,
4807            json!([
4808                { "text": "first" },
4809                { "text": "second" },
4810                { "text": "final" }
4811            ])
4812        );
4813    }
4814
4815    #[test]
4816    fn finalize_does_not_double_terminal_emit_response() {
4817        // Regression: a terminal `emit.response` node pushes its card to egress
4818        // AND returns it as the node output, which the `End` path passes as
4819        // `final_payload`. The card must appear ONCE, not twice (the webchat
4820        // "double card").
4821        let card = json!({ "renderedCard": { "type": "AdaptiveCard" } });
4822        let mut state = ExecutionState::new(json!({}));
4823        state.push_egress(card.clone());
4824        let result = state.finalize_with(Some(card.clone()));
4825        assert_eq!(result, json!([card]));
4826    }
4827
4828    #[test]
4829    fn finalize_still_appends_distinct_terminal_output() {
4830        // A terminal output that differs from the last emitted response is a
4831        // genuine additional reply and must still be appended.
4832        let mut state = ExecutionState::new(json!({}));
4833        state.push_egress(json!({ "text": "emitted" }));
4834        let result = state.finalize_with(Some(json!({ "text": "final" })));
4835        assert_eq!(result, json!([{ "text": "emitted" }, { "text": "final" }]));
4836    }
4837
4838    #[test]
4839    fn alias_input_to_entry_exposes_input_for_bare_message() {
4840        // Env/revision path: the flow entry IS the message — metadata at the
4841        // top level, no `input` wrapper. After aliasing, the pack's
4842        // `in.input.metadata.*` template resolves the same as `in.metadata.*`.
4843        let msg = json!({ "text": "hi", "metadata": { "operation": "get_weather" } });
4844        let aliased = alias_input_to_entry(msg);
4845        assert_eq!(
4846            aliased.pointer("/metadata/operation"),
4847            Some(&json!("get_weather"))
4848        );
4849        assert_eq!(
4850            aliased.pointer("/input/metadata/operation"),
4851            Some(&json!("get_weather"))
4852        );
4853    }
4854
4855    #[test]
4856    fn alias_input_to_entry_preserves_explicit_input_wrapper() {
4857        // Legacy `{input: <message>}` entries must not be double-wrapped.
4858        let wrapped = json!({ "input": { "metadata": { "operation": "x" } } });
4859        assert_eq!(alias_input_to_entry(wrapped.clone()), wrapped);
4860    }
4861
4862    #[test]
4863    fn alias_input_to_entry_ignores_non_objects() {
4864        assert_eq!(alias_input_to_entry(json!("hi")), json!("hi"));
4865        assert_eq!(alias_input_to_entry(json!(null)), json!(null));
4866    }
4867
4868    /// Render one template string against the context a node parameter sees.
4869    fn render_entry(entry: Value, expr: &str) -> Value {
4870        let st = ExecutionState::new(entry);
4871        let ctx = template_context(&st, Value::Null);
4872        render_template_value(&json!(expr), &ctx, TemplateOptions::default())
4873            .expect("template renders")
4874    }
4875
4876    #[test]
4877    fn entry_root_exposes_submitted_fields_on_the_wrapped_path() {
4878        // greentic-start wraps the activity, so the submitted field lands at
4879        // entry.input.metadata.* and `{{entry.company_size}}` used to render
4880        // the empty string with no warning at any layer.
4881        let entry = json!({
4882            "input": {
4883                "metadata": { "action": "submit", "company_size": "11-50" },
4884                "text": "hi"
4885            },
4886            "tenant": "acme",
4887            "correlation_id": "c-1"
4888        });
4889
4890        assert_eq!(
4891            render_entry(entry.clone(), "{{entry.company_size}}"),
4892            json!("11-50")
4893        );
4894        // `in` is an alias of `entry`, so it must resolve identically.
4895        assert_eq!(
4896            render_entry(entry.clone(), "{{in.company_size}}"),
4897            json!("11-50")
4898        );
4899        // The raw paths a digest-pinned pack already reads keep working.
4900        assert_eq!(
4901            render_entry(entry.clone(), "{{entry.input.metadata.company_size}}"),
4902            json!("11-50")
4903        );
4904        assert_eq!(
4905            render_entry(entry.clone(), "{{in.input.metadata.company_size}}"),
4906            json!("11-50")
4907        );
4908        // The envelope's own keys are untouched.
4909        assert_eq!(
4910            render_entry(entry.clone(), "{{entry.tenant}}"),
4911            json!("acme")
4912        );
4913        assert_eq!(
4914            render_entry(entry.clone(), "{{entry.correlation_id}}"),
4915            json!("c-1")
4916        );
4917        // `text` and `action` are not merged as fields, but stay reachable raw.
4918        assert_eq!(
4919            render_entry(entry.clone(), "{{entry.input.text}}"),
4920            json!("hi")
4921        );
4922        assert_eq!(
4923            render_entry(entry, "{{entry.input.metadata.action}}"),
4924            json!("submit")
4925        );
4926    }
4927
4928    #[test]
4929    fn entry_root_exposes_submitted_fields_on_the_demo_path() {
4930        // Run Demo puts input ids at the entry ROOT beside `metadata`, so the
4931        // root field already resolved — but a metadata-only field did not.
4932        // Both lanes must now agree.
4933        let entry = json!({
4934            "company_size": "11-50",
4935            "metadata": { "action": "submit", "email": "a@b.c" },
4936            "text": "hi"
4937        });
4938
4939        assert_eq!(
4940            render_entry(entry.clone(), "{{entry.company_size}}"),
4941            json!("11-50")
4942        );
4943        assert_eq!(
4944            render_entry(entry.clone(), "{{entry.email}}"),
4945            json!("a@b.c")
4946        );
4947        // Raw paths still resolve on this shape too.
4948        assert_eq!(
4949            render_entry(entry.clone(), "{{entry.metadata.email}}"),
4950            json!("a@b.c")
4951        );
4952        // The envelope body keeps its own meaning rather than being overwritten.
4953        assert_eq!(render_entry(entry, "{{entry.text}}"), json!("hi"));
4954    }
4955
4956    #[test]
4957    fn entry_merge_never_clobbers_an_existing_root_key() {
4958        // A submitted field colliding with an envelope key must lose: `tenant`
4959        // is the caller's identity, not a form input.
4960        let entry = json!({
4961            "input": {
4962                "metadata": { "tenant": "evil", "company_size": "11-50" }
4963            },
4964            "tenant": "acme"
4965        });
4966
4967        assert_eq!(
4968            render_entry(entry.clone(), "{{entry.tenant}}"),
4969            json!("acme")
4970        );
4971        assert_eq!(
4972            render_entry(entry.clone(), "{{entry.company_size}}"),
4973            json!("11-50")
4974        );
4975        // `input` itself must survive as the envelope object.
4976        assert_eq!(
4977            render_entry(entry, "{{entry.input.metadata.company_size}}"),
4978            json!("11-50")
4979        );
4980    }
4981
4982    #[test]
4983    fn entry_and_in_stay_identical_after_the_merge() {
4984        let entry = json!({
4985            "input": { "metadata": { "action": "submit", "email": "a@b.c" } },
4986            "tenant": "acme"
4987        });
4988        let st = ExecutionState::new(entry);
4989        let ctx = template_context(&st, Value::Null);
4990        assert_eq!(
4991            ctx.pointer("/entry"),
4992            ctx.pointer("/in"),
4993            "`in` is an alias of `entry` and must not drift from it"
4994        );
4995    }
4996
4997    #[test]
4998    fn entry_merge_leaves_a_non_object_entry_untouched() {
4999        // A bare string/null entry has no root to merge into; it must pass
5000        // through exactly as `alias_input_to_entry` leaves it.
5001        assert_eq!(
5002            merge_submitted_fields_into_entry(json!("hi"), JsonMap::new()),
5003            json!("hi")
5004        );
5005        let mut fields = JsonMap::new();
5006        fields.insert("x".into(), json!(1));
5007        assert_eq!(
5008            merge_submitted_fields_into_entry(json!(null), fields),
5009            json!(null)
5010        );
5011    }
5012
5013    #[test]
5014    fn finalize_flattens_final_array() {
5015        let mut state = ExecutionState::new(json!({}));
5016        state.push_egress(json!({ "text": "only" }));
5017        let result = state.finalize_with(Some(json!([
5018            { "text": "extra-1" },
5019            { "text": "extra-2" }
5020        ])));
5021        assert_eq!(
5022            result,
5023            json!([
5024                { "text": "only" },
5025                { "text": "extra-1" },
5026                { "text": "extra-2" }
5027            ])
5028        );
5029    }
5030
5031    #[test]
5032    fn inject_card_locale_uses_entry_metadata_without_overwriting_payload() {
5033        let mut payload = json!({
5034            "card_source": "inline",
5035            "card_spec": { "title": "Hello" }
5036        });
5037        inject_card_locale(
5038            &mut payload,
5039            &json!({"input": {"metadata": {"locale": "nl-NL"}}}),
5040        );
5041        assert_eq!(payload["locale"], json!("nl-NL"));
5042
5043        let mut existing = json!({
5044            "card_source": "inline",
5045            "card_spec": { "title": "Hello" },
5046            "locale": "en-GB"
5047        });
5048        inject_card_locale(&mut existing, &json!({"metadata": {"locale": "nl-NL"}}));
5049        assert_eq!(existing["locale"], json!("en-GB"));
5050    }
5051
5052    #[test]
5053    fn load_i18n_bundle_entries_reads_manifest_and_falls_back_to_en() {
5054        let assets = StdHashMap::from([
5055            (
5056                "cards/i18n/_manifest.json".to_string(),
5057                br#"{"locales":["de"]}"#.to_vec(),
5058            ),
5059            (
5060                "cards/i18n/de.json".to_string(),
5061                br#"{"title":"Hallo"}"#.to_vec(),
5062            ),
5063            (
5064                "cards/i18n/en.json".to_string(),
5065                br#"{"title":"Hello"}"#.to_vec(),
5066            ),
5067        ]);
5068
5069        let entries = load_i18n_bundle_entries("cards/i18n", |path| {
5070            assets
5071                .get(path)
5072                .cloned()
5073                .with_context(|| format!("missing asset {path}"))
5074        });
5075
5076        assert_eq!(entries["de"]["title"], json!("Hallo"));
5077        assert_eq!(entries["en"]["title"], json!("Hello"));
5078    }
5079
5080    #[test]
5081    fn load_i18n_bundle_entries_reads_single_file_bundle() {
5082        let entries = load_i18n_bundle_entries("cards/i18n.json", |path| {
5083            if path == "cards/i18n.json" {
5084                Ok(br#"{"title":"Hello"}"#.to_vec())
5085            } else {
5086                bail!("unexpected asset {path}");
5087            }
5088        });
5089
5090        assert_eq!(entries["en"]["title"], json!("Hello"));
5091    }
5092
5093    struct TestCrossPackResolver;
5094
5095    impl CrossPackResolver for TestCrossPackResolver {
5096        fn invoke(
5097            &self,
5098            provider_id: &str,
5099            provider_type: Option<&str>,
5100            op: &str,
5101            input: &[u8],
5102            tenant: &str,
5103            team: Option<&str>,
5104        ) -> Result<Value> {
5105            Ok(json!({
5106                "provider_id": provider_id,
5107                "provider_type": provider_type,
5108                "op": op,
5109                "tenant": tenant,
5110                "team": team,
5111                "input": serde_json::from_slice::<Value>(input)?,
5112            }))
5113        }
5114    }
5115
5116    #[test]
5117    fn cross_pack_resolver_returns_node_output_when_present() {
5118        let mut engine = minimal_engine();
5119        engine.set_cross_pack_resolver(Arc::new(TestCrossPackResolver));
5120
5121        let output = engine
5122            .try_invoke_cross_pack_resolver(
5123                Some("mail"),
5124                Some("messaging"),
5125                "send",
5126                br#"{"subject":"hello"}"#,
5127                "demo",
5128            )
5129            .expect("resolver invocation")
5130            .expect("resolver output");
5131
5132        assert_eq!(
5133            output.payload,
5134            json!({
5135                "provider_id": "mail",
5136                "provider_type": "messaging",
5137                "op": "send",
5138                "tenant": "demo",
5139                "team": null,
5140                "input": { "subject": "hello" },
5141            })
5142        );
5143    }
5144
5145    #[test]
5146    fn flow_goto_builds_a_jump_to_the_named_flow() {
5147        let outcome = execute_flow_goto(json!({
5148            "flow_id": "support",
5149            "node": "ask_order",
5150            "input": { "order": "A-1" },
5151        }))
5152        .expect("goto builds");
5153
5154        let NodeControl::Jump(jump) = outcome.control else {
5155            panic!("flow.goto must produce a Jump, got {:?}", outcome.control);
5156        };
5157        assert_eq!(jump.flow, "support");
5158        assert_eq!(jump.node.as_deref(), Some("ask_order"));
5159        assert_eq!(jump.payload, json!({ "order": "A-1" }));
5160        // The node's own output is the payload the target receives, so a
5161        // template downstream of the goto reads what was actually handed over.
5162        assert_eq!(outcome.output.payload, json!({ "order": "A-1" }));
5163    }
5164
5165    /// `flow` is accepted alongside `flow_id`, matching `flow.call`'s payload so
5166    /// a document reads the same whichever primitive it uses.
5167    #[test]
5168    fn flow_goto_accepts_the_flow_alias_and_defaults_the_entry_node() {
5169        let outcome = execute_flow_goto(json!({ "flow": "support" })).expect("goto builds");
5170        let NodeControl::Jump(jump) = outcome.control else {
5171            panic!("expected a Jump");
5172        };
5173        assert_eq!(jump.flow, "support");
5174        assert!(
5175            jump.node.is_none(),
5176            "no node means `apply_jump` uses the target flow's start"
5177        );
5178        assert_eq!(jump.payload, Value::Null);
5179    }
5180
5181    /// An empty target is refused here rather than reaching `apply_jump`, which
5182    /// would fail later with a message about a flow named "".
5183    #[test]
5184    fn flow_goto_refuses_an_empty_flow_id() {
5185        for payload in [json!({ "flow_id": "" }), json!({ "flow_id": "   " })] {
5186            let err = execute_flow_goto(payload)
5187                .err()
5188                .expect("empty target must not build");
5189            assert!(
5190                err.to_string().contains("flow_id"),
5191                "error must name the field: {err}"
5192            );
5193        }
5194        let err = execute_flow_goto(json!({ "input": {} }))
5195            .err()
5196            .expect("missing target");
5197        assert!(err.to_string().contains("flow.goto"), "got: {err}");
5198    }
5199
5200    /// A blank `node` is the same as omitting it — otherwise `apply_jump` would
5201    /// look for a node whose id is the empty string and fail with a confusing
5202    /// "node not found".
5203    #[test]
5204    fn flow_goto_treats_a_blank_entry_node_as_absent() {
5205        let outcome =
5206            execute_flow_goto(json!({ "flow_id": "support", "node": "  " })).expect("goto builds");
5207        let NodeControl::Jump(jump) = outcome.control else {
5208            panic!("expected a Jump");
5209        };
5210        assert!(jump.node.is_none());
5211    }
5212
5213    /// The redirect ceiling is forwarded so a flow can tighten (or loosen) the
5214    /// default of 3 that `apply_jump` applies.
5215    #[test]
5216    fn flow_goto_forwards_the_redirect_ceiling_and_reason() {
5217        let outcome = execute_flow_goto(json!({
5218            "flow_id": "support",
5219            "max_redirects": 1,
5220            "reason": "menu choice",
5221        }))
5222        .expect("goto builds");
5223        let NodeControl::Jump(jump) = outcome.control else {
5224            panic!("expected a Jump");
5225        };
5226        assert_eq!(jump.max_redirects, Some(1));
5227        assert_eq!(jump.reason.as_deref(), Some("menu choice"));
5228    }
5229
5230    /// Absent a reason, one is supplied — `flow.jump.applied` logs it, and an
5231    /// empty reason there is indistinguishable from a component-emitted jump.
5232    #[test]
5233    fn flow_goto_names_itself_as_the_reason_by_default() {
5234        let outcome = execute_flow_goto(json!({ "flow_id": "support" })).expect("goto builds");
5235        let NodeControl::Jump(jump) = outcome.control else {
5236            panic!("expected a Jump");
5237        };
5238        assert_eq!(jump.reason.as_deref(), Some("flow.goto node"));
5239    }
5240
5241    #[test]
5242    fn parse_component_control_ignores_plain_payload() {
5243        let payload = json!({
5244            "flow": "not-a-control-field",
5245            "node": "n1"
5246        });
5247        let control = parse_component_control(&payload).expect("parse control");
5248        assert!(control.is_none());
5249    }
5250
5251    #[test]
5252    fn parse_component_control_parses_jump_marker() {
5253        let payload = json!({
5254            "greentic_control": {
5255                "action": "jump",
5256                "v": 1,
5257                "flow": "flow.b",
5258                "node": "node-2",
5259                "payload": { "message": "hi" },
5260                "hints": { "k": "v" },
5261                "max_redirects": 2,
5262                "reason": "handoff"
5263            }
5264        });
5265        let control = parse_component_control(&payload)
5266            .expect("parse control")
5267            .expect("missing control");
5268        match control {
5269            NodeControl::Jump(jump) => {
5270                assert_eq!(jump.flow, "flow.b");
5271                assert_eq!(jump.node.as_deref(), Some("node-2"));
5272                assert_eq!(jump.payload, json!({ "message": "hi" }));
5273                assert_eq!(jump.hints, json!({ "k": "v" }));
5274                assert_eq!(jump.max_redirects, Some(2));
5275                assert_eq!(jump.reason.as_deref(), Some("handoff"));
5276            }
5277            other => panic!("expected jump control, got {other:?}"),
5278        }
5279    }
5280
5281    #[test]
5282    fn parse_component_control_rejects_invalid_marker() {
5283        let payload = json!({
5284            "greentic_control": "bad-shape"
5285        });
5286        let err = parse_component_control(&payload).expect_err("expected invalid marker error");
5287        assert!(err.to_string().contains("greentic_control"));
5288    }
5289
5290    #[test]
5291    fn missing_operation_reports_node_and_component() {
5292        let engine = minimal_engine();
5293        let rt = Runtime::new().unwrap();
5294        let retry_config = RetryConfig {
5295            max_attempts: 1,
5296            base_delay_ms: 1,
5297        };
5298        let ctx = FlowContext {
5299            tenant: "tenant",
5300            pack_id: "test-pack",
5301            flow_id: "flow",
5302            node_id: Some("missing-op"),
5303            tool: None,
5304            action: None,
5305            session_id: None,
5306            provider_id: None,
5307            reply_scope: None,
5308            retry_config,
5309            attempt: 1,
5310            observer: None,
5311            mocks: None,
5312        };
5313        let node = HostNode {
5314            kind: NodeKind::Exec {
5315                target_component: "qa.process".into(),
5316            },
5317            component: "component.exec".into(),
5318            component_id: "component.exec".into(),
5319            operation_name: None,
5320            operation_in_mapping: None,
5321            payload_expr: Value::Null,
5322            routing: Routing::End,
5323            vars_out: None,
5324        };
5325        let _state = ExecutionState::new(Value::Null);
5326        let payload = json!({ "component": "qa.process" });
5327        let event = NodeEvent {
5328            context: &ctx,
5329            node_id: "missing-op",
5330            node: &node,
5331            payload: &payload,
5332        };
5333        let err = rt
5334            .block_on(engine.execute_component_exec(
5335                &ctx,
5336                "missing-op",
5337                &node,
5338                payload.clone(),
5339                &event,
5340                ComponentOverrides {
5341                    component: None,
5342                    operation: None,
5343                },
5344            ))
5345            .unwrap_err();
5346        let message = err.to_string();
5347        assert!(
5348            message.contains("missing operation for node `missing-op`"),
5349            "unexpected message: {message}"
5350        );
5351        assert!(
5352            message.contains("(component `component.exec`)"),
5353            "unexpected message: {message}"
5354        );
5355    }
5356
5357    #[test]
5358    fn missing_operation_mentions_mapping_hint() {
5359        let engine = minimal_engine();
5360        let rt = Runtime::new().unwrap();
5361        let retry_config = RetryConfig {
5362            max_attempts: 1,
5363            base_delay_ms: 1,
5364        };
5365        let ctx = FlowContext {
5366            tenant: "tenant",
5367            pack_id: "test-pack",
5368            flow_id: "flow",
5369            node_id: Some("missing-op-hint"),
5370            tool: None,
5371            action: None,
5372            session_id: None,
5373            provider_id: None,
5374            reply_scope: None,
5375            retry_config,
5376            attempt: 1,
5377            observer: None,
5378            mocks: None,
5379        };
5380        let node = HostNode {
5381            kind: NodeKind::Exec {
5382                target_component: "qa.process".into(),
5383            },
5384            component: "component.exec".into(),
5385            component_id: "component.exec".into(),
5386            operation_name: None,
5387            operation_in_mapping: Some("render".into()),
5388            payload_expr: Value::Null,
5389            routing: Routing::End,
5390            vars_out: None,
5391        };
5392        let _state = ExecutionState::new(Value::Null);
5393        let payload = json!({ "component": "qa.process" });
5394        let event = NodeEvent {
5395            context: &ctx,
5396            node_id: "missing-op-hint",
5397            node: &node,
5398            payload: &payload,
5399        };
5400        let err = rt
5401            .block_on(engine.execute_component_exec(
5402                &ctx,
5403                "missing-op-hint",
5404                &node,
5405                payload.clone(),
5406                &event,
5407                ComponentOverrides {
5408                    component: None,
5409                    operation: None,
5410                },
5411            ))
5412            .unwrap_err();
5413        let message = err.to_string();
5414        assert!(
5415            message.contains("missing operation for node `missing-op-hint`"),
5416            "unexpected message: {message}"
5417        );
5418        assert!(
5419            message.contains("Found operation in input.mapping (`render`)"),
5420            "unexpected message: {message}"
5421        );
5422    }
5423
5424    struct CountingObserver {
5425        starts: Mutex<Vec<String>>,
5426        ends: Mutex<Vec<Value>>,
5427    }
5428
5429    impl CountingObserver {
5430        fn new() -> Self {
5431            Self {
5432                starts: Mutex::new(Vec::new()),
5433                ends: Mutex::new(Vec::new()),
5434            }
5435        }
5436    }
5437
5438    impl ExecutionObserver for CountingObserver {
5439        fn on_node_start(&self, event: &NodeEvent<'_>) {
5440            self.starts.lock().unwrap().push(event.node_id.to_string());
5441        }
5442
5443        fn on_node_end(&self, _event: &NodeEvent<'_>, output: &Value) {
5444            self.ends.lock().unwrap().push(output.clone());
5445        }
5446
5447        fn on_node_error(&self, _event: &NodeEvent<'_>, _error: &dyn StdError) {}
5448    }
5449
5450    #[test]
5451    fn emits_end_event_for_successful_node() {
5452        let node_id = NodeId::from_str("emit").unwrap();
5453        let node = Node {
5454            id: node_id.clone(),
5455            component: FlowComponentRef {
5456                id: "emit.log".parse().unwrap(),
5457                pack_alias: None,
5458                operation: None,
5459            },
5460            input: InputMapping {
5461                mapping: json!({ "message": "logged" }),
5462            },
5463            output: OutputMapping {
5464                mapping: Value::Null,
5465            },
5466            err_map: None,
5467            routing: Routing::End,
5468            telemetry: TelemetryHints::default(),
5469            conversational: false,
5470        };
5471        let mut nodes = indexmap::IndexMap::default();
5472        nodes.insert(node_id.clone(), node);
5473        let flow = Flow {
5474            schema_version: "1.0".into(),
5475            id: FlowId::from_str("emit.flow").unwrap(),
5476            kind: FlowKind::Messaging,
5477            entrypoints: BTreeMap::from([(
5478                "default".to_string(),
5479                Value::String(node_id.to_string()),
5480            )]),
5481            nodes,
5482            metadata: Default::default(),
5483        };
5484        let host_flow = HostFlow::from(flow);
5485
5486        let engine = FlowEngine {
5487            packs: Vec::new(),
5488            flows: Vec::new(),
5489            flow_sources: HashMap::new(),
5490            messaging_provider_pack_ids: std::collections::HashSet::new(),
5491            flow_cache: RwLock::new(HashMap::from([(
5492                FlowKey {
5493                    pack_id: "test-pack".to_string(),
5494                    flow_id: "emit.flow".to_string(),
5495                },
5496                host_flow,
5497            )])),
5498            default_env: "local".to_string(),
5499            validation: ValidationConfig {
5500                mode: ValidationMode::Off,
5501            },
5502            cross_pack_resolver: None,
5503            rollout_ids: RolloutIds::default(),
5504            remote_dispatch_handler: None,
5505            #[cfg(feature = "agentic-worker")]
5506            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
5507            #[cfg(feature = "agentic-worker")]
5508            agent_node_handler: None,
5509            #[cfg(feature = "agentic-worker")]
5510            graph_node_handler: None,
5511            #[cfg(feature = "agentic-worker")]
5512            mcp_tool_source: None,
5513        };
5514        let observer = CountingObserver::new();
5515        let ctx = FlowContext {
5516            tenant: "demo",
5517            pack_id: "test-pack",
5518            flow_id: "emit.flow",
5519            node_id: None,
5520            tool: None,
5521            action: None,
5522            session_id: None,
5523            provider_id: None,
5524            reply_scope: None,
5525            retry_config: RetryConfig {
5526                max_attempts: 1,
5527                base_delay_ms: 1,
5528            },
5529            attempt: 1,
5530            observer: Some(&observer),
5531            mocks: None,
5532        };
5533
5534        let rt = Runtime::new().unwrap();
5535        let result = rt.block_on(engine.execute(ctx, Value::Null)).unwrap();
5536        assert!(matches!(result.status, FlowStatus::Completed));
5537
5538        let starts = observer.starts.lock().unwrap();
5539        let ends = observer.ends.lock().unwrap();
5540        assert_eq!(starts.len(), 1);
5541        assert_eq!(ends.len(), 1);
5542        assert_eq!(ends[0], json!({ "message": "logged" }));
5543    }
5544
5545    #[test]
5546    fn dotted_component_id_with_mapping_operation_is_not_split() {
5547        // greentic-pack resolves a component node to a bare component symbol and
5548        // keeps the operation in the input mapping. The runtime must NOT split the
5549        // dotted symbol on the last dot (which would yield `ai.greentic`, "not
5550        // found in pack"); the structured mapping operation makes the id a
5551        // complete reference.
5552        let node = Node {
5553            id: NodeId::from_str("render").unwrap(),
5554            component: FlowComponentRef {
5555                id: "ai.greentic.component-templates".parse().unwrap(),
5556                pack_alias: None,
5557                operation: None,
5558            },
5559            input: InputMapping {
5560                mapping: json!({ "operation": "handle_message", "input": "hi" }),
5561            },
5562            output: OutputMapping {
5563                mapping: Value::Null,
5564            },
5565            err_map: None,
5566            routing: Routing::End,
5567            telemetry: TelemetryHints::default(),
5568            conversational: false,
5569        };
5570        let host = HostNode::from(node);
5571        assert!(
5572            matches!(&host.kind, NodeKind::PackComponent { component_ref } if component_ref == "ai.greentic.component-templates"),
5573            "dotted component id must stay intact, got kind {:?}",
5574            host.kind
5575        );
5576        assert_eq!(host.component, "ai.greentic.component-templates");
5577        assert_eq!(host.operation_in_mapping(), Some("handle_message"));
5578    }
5579
5580    #[test]
5581    fn packed_component_operation_id_still_splits_without_mapping_operation() {
5582        // Legacy encoding: the operation is packed into the id as
5583        // `<component>.<operation>` and absent from the mapping. The last-dot
5584        // split must still recover it.
5585        let node = Node {
5586            id: NodeId::from_str("render").unwrap(),
5587            component: FlowComponentRef {
5588                id: "templating.handlebars".parse().unwrap(),
5589                pack_alias: None,
5590                operation: None,
5591            },
5592            input: InputMapping {
5593                mapping: json!({ "text": "hello" }),
5594            },
5595            output: OutputMapping {
5596                mapping: Value::Null,
5597            },
5598            err_map: None,
5599            routing: Routing::End,
5600            telemetry: TelemetryHints::default(),
5601            conversational: false,
5602        };
5603        let host = HostNode::from(node);
5604        assert!(
5605            matches!(&host.kind, NodeKind::PackComponent { component_ref } if component_ref == "templating"),
5606            "packed <component>.<operation> id must split, got kind {:?}",
5607            host.kind
5608        );
5609        assert_eq!(host.operation_name(), Some("handlebars"));
5610    }
5611
5612    #[cfg(feature = "agentic-worker")]
5613    #[test]
5614    fn dw_agent_node_routes_to_handler_and_returns_reply() {
5615        use crate::runner::agent_node::{AgentNodeHandler, RuntimeAgentNodeHandler};
5616        use greentic_aw_runtime::cost::MockTokenMeter;
5617        use greentic_aw_runtime::llm::LlmResponse;
5618        use greentic_aw_runtime::mock::{
5619            MockAgentStateStore, MockConfigProvider, MockLlmBackend, MockTelemetry, NoopToolLedger,
5620        };
5621        use greentic_aw_runtime::{
5622            AgentConfig, AgentLimits, AgentRuntime, LlmProviderRef, TenantContext,
5623        };
5624
5625        // --- mock-backed AgentRuntime: the LLM replies "pong" in one step ---
5626        let llm = Arc::new(MockLlmBackend::new(vec![Ok(LlmResponse {
5627            content: Some("pong".into()),
5628            tool_calls: vec![],
5629            tokens_in: 1,
5630            tokens_out: 1,
5631        })]));
5632        let store = Arc::new(MockAgentStateStore::new());
5633        let telemetry = Arc::new(MockTelemetry::new());
5634
5635        // The dispatch builds TenantContext::new(ctx.tenant, default_env) =
5636        // ("demo", "local"). MockConfigProvider keys by
5637        // `format!("{}:{agent_id}", tenant.key_prefix())` = "aw:demo:local:greeter",
5638        // so seed with the SAME tenant+env+agent_id the engine will look up.
5639        let config_provider = MockConfigProvider::new();
5640        let tenant = TenantContext::new("demo", "local");
5641        config_provider.insert(
5642            &tenant,
5643            "greeter",
5644            AgentConfig {
5645                agent_id: "greeter".into(),
5646                system_prompt: "sys".into(),
5647                tools: vec![],
5648                guardrails: vec![],
5649                llm: LlmProviderRef {
5650                    provider: "mock".into(),
5651                    model: "m".into(),
5652                    credential_ref: None,
5653                },
5654                limits: AgentLimits::default(),
5655                memory: None,
5656                knowledge: None,
5657            },
5658        );
5659        let config_provider = Arc::new(config_provider);
5660        let token_meter = Arc::new(MockTokenMeter::new(0));
5661        let ledger = Arc::new(NoopToolLedger);
5662        let ext_runtime = Arc::new(crate::runner::agent_node::test_extension_runtime());
5663        let runtime = Arc::new(AgentRuntime::new(
5664            config_provider,
5665            store,
5666            ext_runtime,
5667            llm,
5668            telemetry,
5669            token_meter,
5670            ledger,
5671            None,
5672        ));
5673        let handler: Arc<dyn AgentNodeHandler> =
5674            Arc::new(RuntimeAgentNodeHandler::new(runtime, None, None));
5675
5676        // --- flow with a single dw.agent node (operation = agent_id) ---
5677        let node_id = NodeId::from_str("agent").unwrap();
5678        let node = Node {
5679            id: node_id.clone(),
5680            component: FlowComponentRef {
5681                id: "dw.agent".parse().unwrap(),
5682                pack_alias: None,
5683                operation: Some("greeter".to_string()),
5684            },
5685            input: InputMapping {
5686                mapping: json!({ "user_text": "ping" }),
5687            },
5688            output: OutputMapping {
5689                mapping: Value::Null,
5690            },
5691            err_map: None,
5692            routing: Routing::End,
5693            telemetry: TelemetryHints::default(),
5694            conversational: false,
5695        };
5696        let mut nodes = indexmap::IndexMap::default();
5697        nodes.insert(node_id.clone(), node);
5698        let flow = Flow {
5699            schema_version: "1.0".into(),
5700            id: FlowId::from_str("dw.flow").unwrap(),
5701            kind: FlowKind::Messaging,
5702            entrypoints: BTreeMap::from([(
5703                "default".to_string(),
5704                Value::String(node_id.to_string()),
5705            )]),
5706            nodes,
5707            metadata: Default::default(),
5708        };
5709        let host_flow = HostFlow::from(flow);
5710
5711        let engine = FlowEngine {
5712            packs: Vec::new(),
5713            flows: Vec::new(),
5714            flow_sources: HashMap::new(),
5715            messaging_provider_pack_ids: std::collections::HashSet::new(),
5716            flow_cache: RwLock::new(HashMap::from([(
5717                FlowKey {
5718                    pack_id: "test-pack".to_string(),
5719                    flow_id: "dw.flow".to_string(),
5720                },
5721                host_flow,
5722            )])),
5723            default_env: "local".to_string(),
5724            validation: ValidationConfig {
5725                mode: ValidationMode::Off,
5726            },
5727            cross_pack_resolver: None,
5728            rollout_ids: RolloutIds::default(),
5729            remote_dispatch_handler: None,
5730            #[cfg(feature = "agentic-worker")]
5731            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
5732            #[cfg(feature = "agentic-worker")]
5733            agent_node_handler: Some(handler),
5734            #[cfg(feature = "agentic-worker")]
5735            graph_node_handler: None,
5736            #[cfg(feature = "agentic-worker")]
5737            mcp_tool_source: None,
5738        };
5739        let ctx = FlowContext {
5740            tenant: "demo",
5741            pack_id: "test-pack",
5742            flow_id: "dw.flow",
5743            node_id: None,
5744            tool: None,
5745            action: None,
5746            session_id: Some("sess-1"),
5747            provider_id: None,
5748            reply_scope: None,
5749            retry_config: RetryConfig {
5750                max_attempts: 1,
5751                base_delay_ms: 1,
5752            },
5753            attempt: 1,
5754            observer: None,
5755            mocks: None,
5756        };
5757
5758        let rt = Runtime::new().unwrap();
5759        let result = rt
5760            .block_on(engine.execute(ctx, json!({ "user_text": "ping" })))
5761            .unwrap();
5762        assert!(matches!(result.status, FlowStatus::Completed));
5763
5764        // The dw.agent node output is {"reply", "trail", "terminated_by"}; the
5765        // engine finalises a single-node flow's egress into an array wrapping it.
5766        let output_str = serde_json::to_string(&result.output).unwrap();
5767        assert!(
5768            output_str.contains("pong"),
5769            "expected agent reply in flow output, got: {output_str}"
5770        );
5771    }
5772
5773    /// Engine twin of [`dw_agent_node_routes_to_handler_and_returns_reply`]:
5774    /// asserts a `dw.agent_graph` node is detected, routed to the configured
5775    /// [`GraphNodeHandler`] with the engine-derived tenant/env/session and the
5776    /// node's `operation` as the `graph_id`, and its reply lands in the flow
5777    /// output. A lightweight recording stub stands in for the durable executor.
5778    #[cfg(feature = "agentic-worker")]
5779    #[test]
5780    fn dw_agent_graph_node_routes_to_handler_and_returns_reply() {
5781        use std::sync::Mutex;
5782
5783        use crate::runner::graph_node::GraphNodeHandler;
5784
5785        /// Records the dispatch arguments and returns a fixed DwAgent envelope.
5786        struct RecordingGraphHandler {
5787            seen: Mutex<Option<(String, String, String, String)>>,
5788        }
5789
5790        #[async_trait::async_trait]
5791        impl GraphNodeHandler for RecordingGraphHandler {
5792            async fn execute(
5793                &self,
5794                tenant_id: &str,
5795                env_id: &str,
5796                graph_id: &str,
5797                session_id: &str,
5798                _flow_input: &Value,
5799            ) -> Result<Value> {
5800                *self.seen.lock().unwrap() = Some((
5801                    tenant_id.to_string(),
5802                    env_id.to_string(),
5803                    graph_id.to_string(),
5804                    session_id.to_string(),
5805                ));
5806                Ok(json!({
5807                    "reply": "graph-pong",
5808                    "trail": [],
5809                    "terminated_by": "respond",
5810                }))
5811            }
5812        }
5813
5814        let handler = Arc::new(RecordingGraphHandler {
5815            seen: Mutex::new(None),
5816        });
5817        let handler_dyn: Arc<dyn GraphNodeHandler> = handler.clone();
5818
5819        // --- flow with a single dw.agent_graph node (operation = graph_id) ---
5820        let node_id = NodeId::from_str("graph").unwrap();
5821        let node = Node {
5822            id: node_id.clone(),
5823            component: FlowComponentRef {
5824                id: "dw.agent_graph".parse().unwrap(),
5825                pack_alias: None,
5826                operation: Some("triage".to_string()),
5827            },
5828            input: InputMapping {
5829                mapping: json!({ "user_text": "ping" }),
5830            },
5831            output: OutputMapping {
5832                mapping: Value::Null,
5833            },
5834            err_map: None,
5835            routing: Routing::End,
5836            telemetry: TelemetryHints::default(),
5837            conversational: false,
5838        };
5839        let mut nodes = indexmap::IndexMap::default();
5840        nodes.insert(node_id.clone(), node);
5841        let flow = Flow {
5842            schema_version: "1.0".into(),
5843            id: FlowId::from_str("dwg.flow").unwrap(),
5844            kind: FlowKind::Messaging,
5845            entrypoints: BTreeMap::from([(
5846                "default".to_string(),
5847                Value::String(node_id.to_string()),
5848            )]),
5849            nodes,
5850            metadata: Default::default(),
5851        };
5852        let host_flow = HostFlow::from(flow);
5853
5854        let engine = FlowEngine {
5855            packs: Vec::new(),
5856            flows: Vec::new(),
5857            flow_sources: HashMap::new(),
5858            messaging_provider_pack_ids: std::collections::HashSet::new(),
5859            flow_cache: RwLock::new(HashMap::from([(
5860                FlowKey {
5861                    pack_id: "test-pack".to_string(),
5862                    flow_id: "dwg.flow".to_string(),
5863                },
5864                host_flow,
5865            )])),
5866            default_env: "local".to_string(),
5867            validation: ValidationConfig {
5868                mode: ValidationMode::Off,
5869            },
5870            cross_pack_resolver: None,
5871            rollout_ids: RolloutIds::default(),
5872            remote_dispatch_handler: None,
5873            #[cfg(feature = "agentic-worker")]
5874            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
5875            #[cfg(feature = "agentic-worker")]
5876            agent_node_handler: None,
5877            #[cfg(feature = "agentic-worker")]
5878            graph_node_handler: Some(handler_dyn),
5879            #[cfg(feature = "agentic-worker")]
5880            mcp_tool_source: None,
5881        };
5882        let ctx = FlowContext {
5883            tenant: "demo",
5884            pack_id: "test-pack",
5885            flow_id: "dwg.flow",
5886            node_id: None,
5887            tool: None,
5888            action: None,
5889            session_id: Some("sess-1"),
5890            provider_id: None,
5891            reply_scope: None,
5892            retry_config: RetryConfig {
5893                max_attempts: 1,
5894                base_delay_ms: 1,
5895            },
5896            attempt: 1,
5897            observer: None,
5898            mocks: None,
5899        };
5900
5901        let rt = Runtime::new().unwrap();
5902        let result = rt
5903            .block_on(engine.execute(ctx, json!({ "user_text": "ping" })))
5904            .unwrap();
5905        assert!(matches!(result.status, FlowStatus::Completed));
5906
5907        // The handler must have been called with the engine-derived
5908        // tenant/env/session and the node's operation as graph_id.
5909        let seen = handler.seen.lock().unwrap().clone();
5910        assert_eq!(
5911            seen,
5912            Some((
5913                "demo".to_string(),
5914                "local".to_string(),
5915                "triage".to_string(),
5916                "sess-1".to_string(),
5917            )),
5918            "dw.agent_graph dispatch must mirror dw.agent's tenant/env/graph_id/session derivation"
5919        );
5920
5921        let output_str = serde_json::to_string(&result.output).unwrap();
5922        assert!(
5923            output_str.contains("graph-pong"),
5924            "expected graph reply in flow output, got: {output_str}"
5925        );
5926    }
5927
5928    /// When `GREENTIC_AW_DISPATCH=nats` is set, a `dw.agent` node must be
5929    /// rerouted through the remote-dispatch path (`"agentic"` runtime) rather
5930    /// than calling the in-process `AgentNodeHandler`. The node payload is
5931    /// wrapped as `input`, `await=true` is injected, and the engine pauses
5932    /// (returns a wait outcome, not a complete one).
5933    #[cfg(feature = "agentic-worker")]
5934    #[test]
5935    fn dw_agent_nats_mode_dispatches_remote() {
5936        use std::sync::Mutex;
5937
5938        use crate::runner::agent_node::DwAgentDispatch;
5939        use crate::runner::remote_dispatch::{
5940            RemoteDispatch, RemoteDispatchAction, RemoteDispatchHandler,
5941        };
5942
5943        /// Recording stub: captures the last dispatch and returns
5944        /// `AwaitingResponse` so the engine pauses.
5945        struct RecordingDispatcher {
5946            seen: Mutex<Option<RemoteDispatch>>,
5947        }
5948
5949        #[async_trait::async_trait]
5950        impl RemoteDispatchHandler for RecordingDispatcher {
5951            async fn dispatch(
5952                &self,
5953                request: RemoteDispatch,
5954            ) -> anyhow::Result<RemoteDispatchAction> {
5955                let corr = request.correlation_id.clone();
5956                *self.seen.lock().unwrap() = Some(request);
5957                Ok(RemoteDispatchAction::AwaitingResponse {
5958                    correlation_id: corr,
5959                })
5960            }
5961        }
5962
5963        let dispatcher = Arc::new(RecordingDispatcher {
5964            seen: Mutex::new(None),
5965        });
5966
5967        // --- two-node flow: dw.agent → emit (resume target) ---
5968        // The agent node must have Routing::Next so the engine knows where to
5969        // resume once the async response arrives (same requirement as sorla.call /
5970        // agentic.call nodes in production).
5971        let resume_id = NodeId::from_str("after-agent").unwrap();
5972        let node_id = NodeId::from_str("agent-nats").unwrap();
5973        let agent_node = Node {
5974            id: node_id.clone(),
5975            component: FlowComponentRef {
5976                id: "dw.agent".parse().unwrap(),
5977                pack_alias: None,
5978                operation: Some("greeter".to_string()),
5979            },
5980            input: InputMapping {
5981                mapping: json!({ "user_text": "hi" }),
5982            },
5983            output: OutputMapping {
5984                mapping: Value::Null,
5985            },
5986            err_map: None,
5987            routing: Routing::Next {
5988                node_id: resume_id.clone(),
5989            },
5990            telemetry: TelemetryHints::default(),
5991            conversational: false,
5992        };
5993        let resume_node = Node {
5994            id: resume_id.clone(),
5995            component: FlowComponentRef {
5996                id: "emit.log".parse().unwrap(),
5997                pack_alias: None,
5998                operation: None,
5999            },
6000            input: InputMapping {
6001                mapping: json!({ "message": "done" }),
6002            },
6003            output: OutputMapping {
6004                mapping: Value::Null,
6005            },
6006            err_map: None,
6007            routing: Routing::End,
6008            telemetry: TelemetryHints::default(),
6009            conversational: false,
6010        };
6011        let mut nodes = indexmap::IndexMap::default();
6012        nodes.insert(node_id.clone(), agent_node);
6013        nodes.insert(resume_id.clone(), resume_node);
6014        let flow = Flow {
6015            schema_version: "1.0".into(),
6016            id: FlowId::from_str("nats-agent.flow").unwrap(),
6017            kind: FlowKind::Messaging,
6018            entrypoints: BTreeMap::from([(
6019                "default".to_string(),
6020                Value::String(node_id.to_string()),
6021            )]),
6022            nodes,
6023            metadata: Default::default(),
6024        };
6025        let host_flow = HostFlow::from(flow);
6026
6027        let engine = FlowEngine {
6028            packs: Vec::new(),
6029            flows: Vec::new(),
6030            flow_sources: HashMap::new(),
6031            messaging_provider_pack_ids: std::collections::HashSet::new(),
6032            flow_cache: RwLock::new(HashMap::from([(
6033                FlowKey {
6034                    pack_id: "test-pack".to_string(),
6035                    flow_id: "nats-agent.flow".to_string(),
6036                },
6037                host_flow,
6038            )])),
6039            default_env: "local".to_string(),
6040            validation: ValidationConfig {
6041                mode: ValidationMode::Off,
6042            },
6043            cross_pack_resolver: None,
6044            rollout_ids: RolloutIds::default(),
6045            remote_dispatch_handler: Some(dispatcher.clone() as Arc<dyn crate::runner::remote_dispatch::RemoteDispatchHandler>),
6046            #[cfg(feature = "agentic-worker")]
6047            dw_agent_dispatch: DwAgentDispatch::Nats,
6048            #[cfg(feature = "agentic-worker")]
6049            // No in-process handler wired — Nats path must NOT call it.
6050            agent_node_handler: None,
6051            #[cfg(feature = "agentic-worker")]
6052            graph_node_handler: None,
6053            #[cfg(feature = "agentic-worker")]
6054            mcp_tool_source: None,
6055        };
6056
6057        let ctx = FlowContext {
6058            tenant: "demo",
6059            pack_id: "test-pack",
6060            flow_id: "nats-agent.flow",
6061            node_id: None,
6062            tool: None,
6063            action: None,
6064            session_id: Some("sess-nats"),
6065            provider_id: None,
6066            reply_scope: None,
6067            retry_config: RetryConfig {
6068                max_attempts: 1,
6069                base_delay_ms: 1,
6070            },
6071            attempt: 1,
6072            observer: None,
6073            mocks: None,
6074        };
6075
6076        let rt = Runtime::new().unwrap();
6077        let result = rt
6078            .block_on(engine.execute(ctx, json!({ "user_text": "hi" })))
6079            .unwrap();
6080
6081        // The Nats path pauses the flow (await=true → DispatchOutcome::wait).
6082        assert!(
6083            matches!(result.status, FlowStatus::Waiting(_)),
6084            "expected Waiting outcome from dw.agent Nats mode, got: {:?}",
6085            result.status
6086        );
6087
6088        // The dispatcher must have been called with runtime="agentic" and
6089        // target=<agent_id>, and the node payload wrapped as `input`.
6090        let seen = dispatcher.seen.lock().unwrap();
6091        let dispatch = seen.as_ref().expect("dispatcher was not called");
6092        assert_eq!(
6093            dispatch.runtime, "agentic",
6094            "runtime name must be 'agentic'"
6095        );
6096        assert_eq!(dispatch.target, "greeter", "target must be the agent_id");
6097        assert_eq!(
6098            dispatch.input,
6099            json!({ "user_text": "hi" }),
6100            "node payload must be forwarded as dispatch input"
6101        );
6102    }
6103
6104    fn host_flow_for_test(
6105        flow_id: &str,
6106        node_ids: &[&str],
6107        default_start: Option<&str>,
6108    ) -> HostFlow {
6109        let mut nodes = indexmap::IndexMap::default();
6110        for node_id in node_ids {
6111            let id = NodeId::from_str(node_id).unwrap();
6112            let node = Node {
6113                id: id.clone(),
6114                component: FlowComponentRef {
6115                    id: "emit.log".parse().unwrap(),
6116                    pack_alias: None,
6117                    operation: None,
6118                },
6119                input: InputMapping {
6120                    mapping: json!({ "message": node_id }),
6121                },
6122                output: OutputMapping {
6123                    mapping: Value::Null,
6124                },
6125                err_map: None,
6126                routing: Routing::End,
6127                telemetry: TelemetryHints::default(),
6128                conversational: false,
6129            };
6130            nodes.insert(id, node);
6131        }
6132        let mut entrypoints = BTreeMap::new();
6133        if let Some(start) = default_start {
6134            entrypoints.insert("default".to_string(), Value::String(start.to_string()));
6135        }
6136        HostFlow::from(Flow {
6137            schema_version: "1.0".into(),
6138            id: FlowId::from_str(flow_id).unwrap(),
6139            kind: FlowKind::Messaging,
6140            entrypoints,
6141            nodes,
6142            metadata: Default::default(),
6143        })
6144    }
6145
6146    fn jump_test_engine() -> FlowEngine {
6147        let target_flow = host_flow_for_test("flow.target", &["node-a", "node-b"], None);
6148        FlowEngine {
6149            packs: Vec::new(),
6150            flows: Vec::new(),
6151            flow_sources: HashMap::new(),
6152            messaging_provider_pack_ids: std::collections::HashSet::new(),
6153            flow_cache: RwLock::new(HashMap::from([(
6154                FlowKey {
6155                    pack_id: "test-pack".to_string(),
6156                    flow_id: "flow.target".to_string(),
6157                },
6158                target_flow,
6159            )])),
6160            default_env: "local".to_string(),
6161            validation: ValidationConfig {
6162                mode: ValidationMode::Off,
6163            },
6164            cross_pack_resolver: None,
6165            rollout_ids: RolloutIds::default(),
6166            remote_dispatch_handler: None,
6167            #[cfg(feature = "agentic-worker")]
6168            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
6169            #[cfg(feature = "agentic-worker")]
6170            agent_node_handler: None,
6171            #[cfg(feature = "agentic-worker")]
6172            graph_node_handler: None,
6173            #[cfg(feature = "agentic-worker")]
6174            mcp_tool_source: None,
6175        }
6176    }
6177
6178    fn jump_ctx<'a>(flow_id: &'a str) -> FlowContext<'a> {
6179        FlowContext {
6180            tenant: "demo",
6181            pack_id: "test-pack",
6182            flow_id,
6183            node_id: None,
6184            tool: None,
6185            action: None,
6186            session_id: None,
6187            provider_id: None,
6188            reply_scope: None,
6189            retry_config: RetryConfig {
6190                max_attempts: 1,
6191                base_delay_ms: 1,
6192            },
6193            attempt: 1,
6194            observer: None,
6195            mocks: None,
6196        }
6197    }
6198
6199    #[test]
6200    fn with_rollout_ids_binds_revision_identity() {
6201        let engine = minimal_engine().with_rollout_ids(RolloutIds {
6202            customer_id: Some("cust-acme".into()),
6203            deployment_id: Some("01JTKS".into()),
6204            bundle_id: Some("customer.support".into()),
6205            revision_id: Some("01JTKR".into()),
6206        });
6207        assert_eq!(engine.rollout_ids.revision_id.as_deref(), Some("01JTKR"));
6208        assert_eq!(engine.rollout_ids.deployment_id.as_deref(), Some("01JTKS"));
6209        // A freshly-built engine carries no rollout identity (legacy runtime).
6210        assert!(minimal_engine().rollout_ids.is_empty());
6211    }
6212
6213    /// The composition that matters: what a `flow.goto` NODE produces is a jump
6214    /// the engine actually applies. Proven end to end through `apply_jump`
6215    /// rather than by inspecting the control alone — a `JumpControl` the engine
6216    /// would reject is not a working transfer.
6217    ///
6218    /// The target flow's start node is selected, the handed-over payload
6219    /// becomes the target's input, and the redirect counter advances, which is
6220    /// what makes a goto loop terminate instead of spinning.
6221    #[test]
6222    fn a_flow_goto_node_produces_a_jump_the_engine_applies() {
6223        let outcome = execute_flow_goto(json!({
6224            "flow_id": "flow.target",
6225            "input": { "order": "A-1" },
6226        }))
6227        .expect("goto builds");
6228        let NodeControl::Jump(jump) = outcome.control else {
6229            panic!("expected a Jump");
6230        };
6231
6232        let engine = jump_test_engine();
6233        let mut state = ExecutionState::new(Value::Null);
6234        let rt = Runtime::new().unwrap();
6235        let target = rt
6236            .block_on(engine.apply_jump(&jump_ctx("flow.source"), &mut state, jump))
6237            .expect("the engine must accept the jump a flow.goto node builds");
6238
6239        assert_eq!(target.flow_id, "flow.target");
6240        assert_eq!(
6241            target.node_id.as_str(),
6242            "node-a",
6243            "no explicit node means the target flow's first node"
6244        );
6245        assert_eq!(
6246            state.redirect_count(),
6247            1,
6248            "the loop guard must have counted"
6249        );
6250    }
6251
6252    /// An explicit entry node is honoured, so a menu can hand over to the exact
6253    /// step that answers the option the user picked.
6254    #[test]
6255    fn a_flow_goto_node_can_name_the_entry_node() {
6256        let outcome = execute_flow_goto(json!({ "flow_id": "flow.target", "node": "node-b" }))
6257            .expect("goto builds");
6258        let NodeControl::Jump(jump) = outcome.control else {
6259            panic!("expected a Jump");
6260        };
6261
6262        let engine = jump_test_engine();
6263        let mut state = ExecutionState::new(Value::Null);
6264        let rt = Runtime::new().unwrap();
6265        let target = rt
6266            .block_on(engine.apply_jump(&jump_ctx("flow.source"), &mut state, jump))
6267            .expect("jump applies");
6268        assert_eq!(target.node_id.as_str(), "node-b");
6269    }
6270
6271    #[test]
6272    fn apply_jump_unknown_flow_errors() {
6273        let engine = minimal_engine();
6274        let mut state = ExecutionState::new(Value::Null);
6275        let rt = Runtime::new().unwrap();
6276        let err = rt
6277            .block_on(engine.apply_jump(
6278                &jump_ctx("flow.source"),
6279                &mut state,
6280                JumpControl {
6281                    flow: "flow.missing".into(),
6282                    node: None,
6283                    payload: json!({ "ok": true }),
6284                    hints: Value::Null,
6285                    max_redirects: None,
6286                    reason: None,
6287                },
6288            ))
6289            .unwrap_err();
6290        assert!(
6291            err.to_string().contains("unknown_flow"),
6292            "unexpected error: {err}"
6293        );
6294    }
6295
6296    #[test]
6297    fn apply_jump_unknown_node_errors() {
6298        let engine = jump_test_engine();
6299        let mut state = ExecutionState::new(Value::Null);
6300        let rt = Runtime::new().unwrap();
6301        let err = rt
6302            .block_on(engine.apply_jump(
6303                &jump_ctx("flow.source"),
6304                &mut state,
6305                JumpControl {
6306                    flow: "flow.target".into(),
6307                    node: Some("node-missing".into()),
6308                    payload: json!({ "ok": true }),
6309                    hints: Value::Null,
6310                    max_redirects: None,
6311                    reason: None,
6312                },
6313            ))
6314            .unwrap_err();
6315        assert!(
6316            err.to_string().contains("unknown_node"),
6317            "unexpected error: {err}"
6318        );
6319    }
6320
6321    #[test]
6322    fn apply_jump_uses_default_start_fallback() {
6323        let engine = jump_test_engine();
6324        let mut state = ExecutionState::new(Value::Null);
6325        let rt = Runtime::new().unwrap();
6326        let target = rt
6327            .block_on(engine.apply_jump(
6328                &jump_ctx("flow.source"),
6329                &mut state,
6330                JumpControl {
6331                    flow: "flow.target".into(),
6332                    node: None,
6333                    payload: json!({ "k": "v" }),
6334                    hints: Value::Null,
6335                    max_redirects: None,
6336                    reason: None,
6337                },
6338            ))
6339            .expect("jump target");
6340        assert_eq!(target.flow_id, "flow.target");
6341        assert_eq!(target.node_id.as_str(), "node-a");
6342    }
6343
6344    #[test]
6345    fn apply_jump_redirect_limit_enforced() {
6346        let engine = jump_test_engine();
6347        let mut state = ExecutionState::new(Value::Null);
6348        state.redirect_count = 3;
6349        let rt = Runtime::new().unwrap();
6350        let err = rt
6351            .block_on(engine.apply_jump(
6352                &jump_ctx("flow.source"),
6353                &mut state,
6354                JumpControl {
6355                    flow: "flow.target".into(),
6356                    node: None,
6357                    payload: json!({ "k": "v" }),
6358                    hints: Value::Null,
6359                    max_redirects: Some(3),
6360                    reason: None,
6361                },
6362            ))
6363            .unwrap_err();
6364        assert_eq!(err.to_string(), "redirect_limit");
6365    }
6366
6367    /// Regression: a `Routing::Custom` array containing at least one
6368    /// conditional entry must pause (return `Wait`) when no condition
6369    /// matches, instead of terminating. Concrete bug it guards against:
6370    /// every card click used to terminate the flow because the entry-card's
6371    /// routing array didn't enumerate every downstream action, so users got
6372    /// looped back to the entry on every interaction.
6373    #[test]
6374    fn evaluate_custom_routing_waits_when_conditional_falls_through() {
6375        let raw_routing = json!([
6376            { "condition": "response.action == \"go\"", "to": "next" },
6377            { "out": true }
6378        ]);
6379        let flow_ir = HostFlow {
6380            id: "flow.test".to_string(),
6381            start: None,
6382            nodes: IndexMap::new(),
6383            slot_schema: None,
6384            vars_init: JsonMap::new(),
6385        };
6386        let current_node = NodeId::from_str("current").unwrap();
6387        let output = NodeOutput::new(Value::Null);
6388
6389        // First case: empty action -> conditional does not match, must wait.
6390        let mut state_empty = ExecutionState::new(json!({ "metadata": { "action": "" } }));
6391        state_empty.entry = json!({ "metadata": { "action": "" } });
6392        let decision_empty =
6393            evaluate_custom_routing(&raw_routing, &output, &state_empty, &flow_ir, &current_node);
6394        assert!(
6395            matches!(decision_empty, CustomRoutingDecision::Wait),
6396            "expected Wait on conditional fall-through, got {decision_empty:?}"
6397        );
6398
6399        // Second case: action == "go" -> conditional matches, must advance.
6400        let mut state_go = ExecutionState::new(json!({ "metadata": { "action": "go" } }));
6401        state_go.entry = json!({ "metadata": { "action": "go" } });
6402        let decision_go =
6403            evaluate_custom_routing(&raw_routing, &output, &state_go, &flow_ir, &current_node);
6404        match decision_go {
6405            CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "next"),
6406            other => panic!("expected Next(\"next\"), got {other:?}"),
6407        }
6408    }
6409
6410    #[test]
6411    fn node_output_with_error_marks_ok_false_and_stashes_in_meta() {
6412        let err: Box<dyn std::error::Error + 'static> =
6413            Box::<dyn std::error::Error + 'static>::from("weatherapi returned 401 Unauthorized");
6414        let out = NodeOutput::with_error("call_weather", err.as_ref());
6415        assert!(!out.ok);
6416        assert_eq!(out.payload, Value::Null);
6417        assert_eq!(out.meta["error"]["kind"], "flow_node_failed");
6418        assert_eq!(out.meta["error"]["node_id"], "call_weather");
6419        assert_eq!(
6420            out.meta["error"]["message"],
6421            "weatherapi returned 401 Unauthorized"
6422        );
6423    }
6424
6425    /// A failed MCP call must mark the node not-ok so the already-wired
6426    /// `lift_first_node_error_from_nodes` has something to find.
6427    ///
6428    /// `mcp_node::invoke` is infallible — every failure arrives as
6429    /// `{"error": ...}` in the result — so the node used to report `ok: true`
6430    /// and the flow completed clean. That is what made a Digital Worker run
6431    /// show 33/33 green nodes with a blank quote.
6432    #[test]
6433    fn a_failed_mcp_call_marks_the_node_not_ok() {
6434        let result = json!({ "error": "MCP is not configured on this runner" });
6435        let bound = json!({ "quote_result_data": result.clone() });
6436
6437        let out = mcp_output(bound.clone(), &result);
6438
6439        assert!(!out.ok, "a failed MCP call must not report ok");
6440        assert_eq!(
6441            out.meta.pointer("/error/message").and_then(Value::as_str),
6442            Some("MCP is not configured on this runner"),
6443            "the message must reach meta.error where the lift reads it, got {:?}",
6444            out.meta
6445        );
6446        assert_eq!(
6447            out.payload, bound,
6448            "the bound payload must be untouched — routing and any flow \
6449             reading the bound value must behave exactly as before"
6450        );
6451    }
6452
6453    /// The success path must stay exactly as it was.
6454    #[test]
6455    fn a_successful_mcp_call_stays_ok() {
6456        let result = json!({ "annual_premium": 1234 });
6457        let bound = json!({ "quote_result_data": result.clone() });
6458
6459        let out = mcp_output(bound.clone(), &result);
6460
6461        assert!(out.ok, "a successful MCP call must report ok");
6462        assert_eq!(out.payload, bound);
6463        assert!(
6464            out.meta.get("error").is_none(),
6465            "a successful call must not stash an error, got {:?}",
6466            out.meta
6467        );
6468    }
6469
6470    #[test]
6471    fn lift_first_node_error_promotes_node_meta_to_output_metadata() {
6472        // Two nodes ran; the first failed, the second produced a default-
6473        // looking output (flow author wrote no error routing). The executor
6474        // must lift the first failure into output.metadata so the messaging
6475        // provider renders the error card without any flow-author changes.
6476        let mut nodes: HashMap<String, NodeOutput> = HashMap::new();
6477        let err: Box<dyn std::error::Error + 'static> =
6478            Box::<dyn std::error::Error + 'static>::from("weatherapi returned 401 Unauthorized");
6479        nodes.insert(
6480            "call_weather".to_string(),
6481            NodeOutput::with_error("call_weather", err.as_ref()),
6482        );
6483        nodes.insert(
6484            "render_current_card".to_string(),
6485            NodeOutput::new(json!({ "text": "message" })),
6486        );
6487
6488        let final_output = json!({ "text": "message" });
6489        let enriched = lift_first_node_error_from_nodes(final_output, &nodes);
6490        assert_eq!(
6491            enriched["metadata"]["error_kind"], "flow_node_failed",
6492            "first failing node's kind must be lifted"
6493        );
6494        assert_eq!(
6495            enriched["metadata"]["error_message"],
6496            "weatherapi returned 401 Unauthorized"
6497        );
6498        assert_eq!(enriched["metadata"]["node_id"], "call_weather");
6499        // Preserves the original payload bits so downstream renderers still
6500        // see what the flow produced.
6501        assert_eq!(enriched["text"], "message");
6502    }
6503
6504    #[test]
6505    fn lift_first_node_error_is_noop_when_all_nodes_ok() {
6506        let mut nodes: HashMap<String, NodeOutput> = HashMap::new();
6507        nodes.insert(
6508            "ok_node".to_string(),
6509            NodeOutput::new(json!({ "text": "all good" })),
6510        );
6511        let output = json!({ "text": "all good" });
6512        let lifted = lift_first_node_error_from_nodes(output.clone(), &nodes);
6513        assert_eq!(lifted, output);
6514    }
6515
6516    #[tokio::test]
6517    async fn execute_user_facing_flow_failure_returns_completed_with_error_envelope() {
6518        // Flow whose start node is missing — drive_flow will return Err on
6519        // node lookup. With session_id present, execute() must convert that
6520        // to a Completed FlowExecution carrying error_kind/error_message in
6521        // output.metadata so the chat user sees the error card.
6522        let flow_id_str = "broken.flow";
6523        let pack_id_str = "test-pack";
6524        let host_flow = host_flow_for_test(flow_id_str, &["only-node"], Some("does-not-exist"));
6525        let engine = FlowEngine {
6526            packs: Vec::new(),
6527            flows: Vec::new(),
6528            flow_sources: HashMap::new(),
6529            messaging_provider_pack_ids: std::collections::HashSet::new(),
6530            flow_cache: RwLock::new(HashMap::from([(
6531                FlowKey {
6532                    pack_id: pack_id_str.to_string(),
6533                    flow_id: flow_id_str.to_string(),
6534                },
6535                host_flow,
6536            )])),
6537            default_env: "local".to_string(),
6538            validation: ValidationConfig {
6539                mode: ValidationMode::Off,
6540            },
6541            cross_pack_resolver: None,
6542            rollout_ids: RolloutIds::default(),
6543            remote_dispatch_handler: None,
6544            #[cfg(feature = "agentic-worker")]
6545            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
6546            #[cfg(feature = "agentic-worker")]
6547            agent_node_handler: None,
6548            #[cfg(feature = "agentic-worker")]
6549            graph_node_handler: None,
6550            #[cfg(feature = "agentic-worker")]
6551            mcp_tool_source: None,
6552        };
6553        let ctx = FlowContext {
6554            tenant: "demo",
6555            pack_id: pack_id_str,
6556            flow_id: flow_id_str,
6557            node_id: None,
6558            tool: None,
6559            action: None,
6560            session_id: Some("conv-1"),
6561            provider_id: None,
6562            reply_scope: None,
6563            retry_config: RetryConfig {
6564                max_attempts: 1,
6565                base_delay_ms: 1,
6566            },
6567            attempt: 1,
6568            observer: None,
6569            mocks: None,
6570        };
6571        let result = engine
6572            .execute(ctx, Value::Null)
6573            .await
6574            .expect("must not propagate Err");
6575        assert!(matches!(result.status, FlowStatus::Completed));
6576        assert_eq!(
6577            result.output["metadata"]["error_kind"],
6578            "flow_execution_failed"
6579        );
6580        let msg = result.output["metadata"]["error_message"]
6581            .as_str()
6582            .unwrap_or("");
6583        assert!(!msg.is_empty(), "error_message must be populated");
6584        assert_eq!(result.output["metadata"]["flow_id"], "broken.flow");
6585    }
6586
6587    #[test]
6588    fn mcp_tool_error_recognises_generator_error_shape() {
6589        // greentic-mcp-generator's tool_error_with_status emits this exact
6590        // shape when the upstream HTTP call to weatherapi.com returns 401.
6591        let value = json!({
6592            "error": {
6593                "code": "tool_error",
6594                "message": "API request returned status 401",
6595                "status": 401
6596            }
6597        });
6598        let (code, message) = mcp_tool_error(&value).expect("must detect MCP error shape");
6599        assert_eq!(code, "tool_error");
6600        assert!(message.contains("API request returned status 401"));
6601        assert!(message.contains("(status 401)"));
6602    }
6603
6604    #[test]
6605    fn mcp_tool_error_skips_success_responses() {
6606        // A success response uses `result`, not `error`.
6607        let value = json!({ "result": { "current": { "temp_c": 19.0 } } });
6608        assert!(mcp_tool_error(&value).is_none());
6609    }
6610
6611    #[test]
6612    fn mcp_tool_error_skips_non_object_and_unrelated_shapes() {
6613        assert!(mcp_tool_error(&Value::Null).is_none());
6614        assert!(mcp_tool_error(&json!({"unrelated": true})).is_none());
6615        // `error` must be an object; a string isn't enough.
6616        assert!(mcp_tool_error(&json!({"error": "oops"})).is_none());
6617    }
6618
6619    #[tokio::test]
6620    async fn execute_non_user_facing_flow_failure_still_propagates() {
6621        // No session_id => internal job. Errors still propagate as Err so
6622        // operator alerting / metrics pipelines stay intact.
6623        let flow_id_str = "broken.flow";
6624        let pack_id_str = "test-pack";
6625        let host_flow = host_flow_for_test(flow_id_str, &["only-node"], Some("does-not-exist"));
6626        let engine = FlowEngine {
6627            packs: Vec::new(),
6628            flows: Vec::new(),
6629            flow_sources: HashMap::new(),
6630            messaging_provider_pack_ids: std::collections::HashSet::new(),
6631            flow_cache: RwLock::new(HashMap::from([(
6632                FlowKey {
6633                    pack_id: pack_id_str.to_string(),
6634                    flow_id: flow_id_str.to_string(),
6635                },
6636                host_flow,
6637            )])),
6638            default_env: "local".to_string(),
6639            validation: ValidationConfig {
6640                mode: ValidationMode::Off,
6641            },
6642            cross_pack_resolver: None,
6643            rollout_ids: RolloutIds::default(),
6644            remote_dispatch_handler: None,
6645            #[cfg(feature = "agentic-worker")]
6646            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
6647            #[cfg(feature = "agentic-worker")]
6648            agent_node_handler: None,
6649            #[cfg(feature = "agentic-worker")]
6650            graph_node_handler: None,
6651            #[cfg(feature = "agentic-worker")]
6652            mcp_tool_source: None,
6653        };
6654        let ctx = FlowContext {
6655            tenant: "demo",
6656            pack_id: pack_id_str,
6657            flow_id: flow_id_str,
6658            node_id: None,
6659            tool: None,
6660            action: None,
6661            session_id: None,
6662            provider_id: None,
6663            reply_scope: None,
6664            retry_config: RetryConfig {
6665                max_attempts: 1,
6666                base_delay_ms: 1,
6667            },
6668            attempt: 1,
6669            observer: None,
6670            mocks: None,
6671        };
6672        let result = engine.execute(ctx, Value::Null).await;
6673        assert!(result.is_err(), "non-user-facing flow must propagate Err");
6674    }
6675
6676    // ---- Phase D: slot_schema injection tests ----
6677
6678    #[test]
6679    fn host_flow_extracts_slot_schema_from_metadata_extra() {
6680        use greentic_types::FlowMetadata;
6681        use std::collections::BTreeSet;
6682
6683        let schema = json!([
6684            {"name": "counterparty", "slot_type": "string", "required": true},
6685            {"name": "due_date", "slot_type": "date", "required": true}
6686        ]);
6687        let flow = Flow {
6688            schema_version: "flow-v1".into(),
6689            id: FlowId::from_str("test.flow").unwrap(),
6690            kind: FlowKind::Messaging,
6691            entrypoints: BTreeMap::new(),
6692            nodes: IndexMap::default(),
6693            metadata: FlowMetadata {
6694                title: None,
6695                description: None,
6696                tags: BTreeSet::new(),
6697                extra: json!({(SLOT_SCHEMA_METADATA_KEY): schema}),
6698            },
6699        };
6700        let host = HostFlow::from(flow);
6701        assert_eq!(
6702            host.slot_schema.as_ref(),
6703            Some(&schema),
6704            "HostFlow must extract slot_schema from metadata.extra"
6705        );
6706    }
6707
6708    #[test]
6709    fn host_flow_slot_schema_is_none_when_absent() {
6710        let flow = Flow {
6711            schema_version: "flow-v1".into(),
6712            id: FlowId::from_str("test.flow").unwrap(),
6713            kind: FlowKind::Messaging,
6714            entrypoints: BTreeMap::new(),
6715            nodes: IndexMap::default(),
6716            metadata: Default::default(),
6717        };
6718        let host = HostFlow::from(flow);
6719        assert!(
6720            host.slot_schema.is_none(),
6721            "HostFlow.slot_schema must be None when metadata.extra has no greentic.slot_schema"
6722        );
6723    }
6724
6725    #[test]
6726    fn inject_slot_definitions_adds_to_object_input() {
6727        let schema = json!([
6728            {"name": "city", "slot_type": "string"}
6729        ]);
6730        let mut input = json!({"utterance": "hello"});
6731        inject_slot_definitions(&mut input, &schema, "f", "n");
6732        assert_eq!(
6733            input,
6734            json!({"utterance": "hello", "slot_definitions": schema}),
6735            "slot_definitions must be injected into existing object"
6736        );
6737    }
6738
6739    #[test]
6740    fn inject_slot_definitions_wraps_null_input() {
6741        let schema = json!([{"name": "x", "slot_type": "string"}]);
6742        let mut input = Value::Null;
6743        inject_slot_definitions(&mut input, &schema, "f", "n");
6744        assert_eq!(
6745            input,
6746            json!({"slot_definitions": schema}),
6747            "null input must become an object with slot_definitions"
6748        );
6749    }
6750
6751    #[test]
6752    fn inject_slot_definitions_preserves_explicit_inline() {
6753        let flow_schema = json!([{"name": "city", "slot_type": "string"}]);
6754        let inline_defs = json!([{"name": "country", "slot_type": "string"}]);
6755        let mut input = json!({
6756            "utterance": "hello",
6757            "slot_definitions": inline_defs
6758        });
6759        inject_slot_definitions(&mut input, &flow_schema, "f", "n");
6760        assert_eq!(
6761            input["slot_definitions"], inline_defs,
6762            "explicit inline slot_definitions must not be overwritten"
6763        );
6764    }
6765
6766    #[test]
6767    fn inject_slot_definitions_skips_non_object_input() {
6768        let schema = json!([{"name": "x", "slot_type": "string"}]);
6769        let mut input = json!("a string");
6770        inject_slot_definitions(&mut input, &schema, "f", "n");
6771        assert_eq!(
6772            input,
6773            json!("a string"),
6774            "non-object input must be left unchanged"
6775        );
6776    }
6777
6778    fn make_flow_doc_for_test(
6779        id: &str,
6780        node_name: &str,
6781        component: &str,
6782        slot_schema: Option<Value>,
6783    ) -> greentic_flow::model::FlowDoc {
6784        use greentic_flow::model::{FlowDoc, NodeDoc};
6785
6786        let mut nodes = IndexMap::new();
6787        nodes.insert(
6788            node_name.to_string(),
6789            NodeDoc {
6790                raw: {
6791                    let mut m = IndexMap::new();
6792                    m.insert(
6793                        "component.exec".to_string(),
6794                        json!({ "component": component }),
6795                    );
6796                    m
6797                },
6798                routing: json!([{ "out": true }]),
6799                ..Default::default()
6800            },
6801        );
6802
6803        FlowDoc {
6804            id: id.into(),
6805            title: None,
6806            description: None,
6807            flow_type: "messaging".into(),
6808            start: Some(node_name.into()),
6809            parameters: json!({}),
6810            tags: Vec::new(),
6811            schema_version: None,
6812            entrypoints: IndexMap::new(),
6813            meta: None,
6814            slot_schema,
6815            nodes,
6816        }
6817    }
6818
6819    /// Integration test: exercises the real `greentic_flow::compile_flow`
6820    /// producer path with a `FlowDoc` carrying `slot_schema`, then converts
6821    /// through `HostFlow::from` and verifies the runtime-side `slot_schema`
6822    /// field is populated — closing the gap Codex flagged where the existing
6823    /// unit tests constructed `FlowMetadata` directly.
6824    #[test]
6825    fn compile_flow_round_trips_slot_schema_into_host_flow() {
6826        let slot_defs = json!([
6827            { "name": "counterparty", "slot_type": "string", "required": true,
6828              "pattern": ".+" },
6829            { "name": "due_date", "slot_type": "date", "required": true,
6830              "pattern": "\\d{4}-\\d{2}-\\d{2}" }
6831        ]);
6832        let doc = make_flow_doc_for_test(
6833            "slot-test",
6834            "extractor",
6835            "slot-extractor",
6836            Some(slot_defs.clone()),
6837        );
6838
6839        let flow = greentic_flow::compile_flow(doc).expect("compile_flow must succeed");
6840        assert_eq!(
6841            flow.metadata.extra.get(SLOT_SCHEMA_METADATA_KEY),
6842            Some(&slot_defs),
6843            "compile_flow must forward slot_schema into metadata.extra"
6844        );
6845
6846        let host = HostFlow::from(flow);
6847        assert_eq!(
6848            host.slot_schema.as_ref(),
6849            Some(&slot_defs),
6850            "HostFlow.slot_schema must survive the compile_flow -> HostFlow round-trip"
6851        );
6852    }
6853
6854    /// Verify that `compile_flow` without `slot_schema` produces a `Flow`
6855    /// whose `metadata.extra` has no `greentic.slot_schema` key, and that
6856    /// `HostFlow.slot_schema` stays `None` through the real compile path.
6857    #[test]
6858    fn compile_flow_without_slot_schema_leaves_host_flow_none() {
6859        let doc = make_flow_doc_for_test("no-slots", "echo", "echo", None);
6860
6861        let flow = greentic_flow::compile_flow(doc).expect("compile_flow must succeed");
6862        assert!(
6863            flow.metadata.extra.get(SLOT_SCHEMA_METADATA_KEY).is_none(),
6864            "metadata.extra must not contain greentic.slot_schema when FlowDoc.slot_schema is None"
6865        );
6866
6867        let host = HostFlow::from(flow);
6868        assert!(
6869            host.slot_schema.is_none(),
6870            "HostFlow.slot_schema must be None when FlowDoc has no slot_schema"
6871        );
6872    }
6873
6874    #[test]
6875    fn multi_edge_node_routes_on_injected_event() {
6876        let raw_routing = json!([
6877            { "condition": "event == \"on_success\"", "to": "next" },
6878            { "condition": "event == \"on_error\"", "to": "err" }
6879        ]);
6880        let flow_ir = HostFlow {
6881            id: "flow.test".to_string(),
6882            start: None,
6883            nodes: IndexMap::new(),
6884            slot_schema: None,
6885            vars_init: JsonMap::new(),
6886        };
6887        let current = NodeId::from_str("current").unwrap();
6888        let state = ExecutionState::new(json!({}));
6889
6890        // ok:true with no explicit outcome → default event "on_success" → "next".
6891        let ok_out = NodeOutput::new(json!({ "x": 1 }));
6892        match evaluate_custom_routing(&raw_routing, &ok_out, &state, &flow_ir, &current) {
6893            CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "next"),
6894            other => panic!("expected Next(\"next\"), got {other:?}"),
6895        }
6896
6897        // An explicit outcome in the node metadata wins over the ok-default.
6898        let routed = NodeOutput::with_meta(json!({}), json!({ "outcome": "on_error" }));
6899        match evaluate_custom_routing(&raw_routing, &routed, &state, &flow_ir, &current) {
6900            CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "err"),
6901            other => panic!("expected Next(\"err\"), got {other:?}"),
6902        }
6903    }
6904
6905    /// A node whose component reports a failure (`{ok:false, error}`) and which
6906    /// has an `on_error`-family route must surface a node_io `Errors` output
6907    /// (`ok == false`) and route to that branch instead of aborting the flow.
6908    #[test]
6909    fn errored_output_routes_to_on_error_branch() {
6910        let raw_routing = json!([
6911            { "condition": "event == \"on_success\"", "to": "ok_node" },
6912            { "condition": "event == \"on_error\"", "to": "err_node" }
6913        ]);
6914        let flow_ir = HostFlow {
6915            id: "flow.test".to_string(),
6916            start: None,
6917            nodes: IndexMap::new(),
6918            slot_schema: None,
6919            vars_init: JsonMap::new(),
6920        };
6921        let current = NodeId::from_str("current").unwrap();
6922        let state = ExecutionState::new(json!({}));
6923
6924        let errored =
6925            NodeOutput::errored(json!({ "ok": false, "error": { "code": "E", "message": "m" } }));
6926        match evaluate_custom_routing(&raw_routing, &errored, &state, &flow_ir, &current) {
6927            CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "err_node"),
6928            other => panic!("expected on_error route, got {other:?}"),
6929        }
6930    }
6931
6932    #[test]
6933    fn node_has_error_route_detects_error_family_ports() {
6934        let with_err = Routing::Custom(json!([
6935            { "condition": "event == \"on_success\"", "to": "n" },
6936            { "condition": "event == \"on_error\"", "to": "e" }
6937        ]));
6938        assert!(
6939            node_has_error_route(&with_err),
6940            "on_error route must be detected"
6941        );
6942
6943        let only_success = Routing::Custom(json!([
6944            { "condition": "event == \"on_success\"", "to": "n" }
6945        ]));
6946        assert!(
6947            !node_has_error_route(&only_success),
6948            "a success-only Custom routing has no error branch"
6949        );
6950
6951        let plain = Routing::Next {
6952            node_id: NodeId::from_str("n").unwrap(),
6953        };
6954        assert!(
6955            !node_has_error_route(&plain),
6956            "Routing::Next has no error branch"
6957        );
6958    }
6959
6960    /// When a successful node emits no explicit `outcome`, the runner must
6961    /// derive the success `event` from the success-family port the node
6962    /// actually has an outgoing edge for (priority `on_success` → `on_complete`
6963    /// → `on_submit`), not blindly default to `on_success`. This is what lets
6964    /// native nodes whose happy port is `on_complete` (qa.process,
6965    /// llm.openai.chat, template_render) — or `on_submit` (forms) — route
6966    /// instead of silently stalling at `Wait`, while leaving `on_success`
6967    /// components (e.g. http) unchanged.
6968    #[test]
6969    fn success_default_matches_available_outcome_port() {
6970        let flow_ir = HostFlow {
6971            id: "flow.test".to_string(),
6972            start: None,
6973            nodes: IndexMap::new(),
6974            slot_schema: None,
6975            vars_init: JsonMap::new(),
6976        };
6977        let current = NodeId::from_str("current").unwrap();
6978        let state = ExecutionState::new(json!({}));
6979        // ok:true, no explicit outcome — the case every native happy path hits.
6980        let ok_out = NodeOutput::new(json!({ "answer": "hi" }));
6981
6982        // qa/llm/template shape: happy port is `on_complete`, no `on_success` edge.
6983        let on_complete_routing = json!([
6984            { "condition": "event == \"on_complete\"", "to": "next" },
6985            { "condition": "event == \"on_cancel\"", "to": "cancelled" }
6986        ]);
6987        match evaluate_custom_routing(&on_complete_routing, &ok_out, &state, &flow_ir, &current) {
6988            CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "next"),
6989            other => panic!("expected Next(\"next\") via on_complete default, got {other:?}"),
6990        }
6991
6992        // form shape: happy port is `on_submit`.
6993        let on_submit_routing = json!([
6994            { "condition": "event == \"on_submit\"", "to": "saved" },
6995            { "condition": "event == \"on_cancel\"", "to": "cancelled" }
6996        ]);
6997        match evaluate_custom_routing(&on_submit_routing, &ok_out, &state, &flow_ir, &current) {
6998            CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "saved"),
6999            other => panic!("expected Next(\"saved\") via on_submit default, got {other:?}"),
7000        }
7001
7002        // http shape: `on_success` present → still routes on_success (priority,
7003        // no regression for components whose success name is the old default).
7004        let on_success_routing = json!([
7005            { "condition": "event == \"on_success\"", "to": "ok" },
7006            { "condition": "event == \"on_error\"", "to": "err" }
7007        ]);
7008        match evaluate_custom_routing(&on_success_routing, &ok_out, &state, &flow_ir, &current) {
7009            CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "ok"),
7010            other => panic!("expected Next(\"ok\") via on_success default, got {other:?}"),
7011        }
7012    }
7013
7014    /// `evaluate_simple_condition` backs the user-authored `conditional_branch`
7015    /// expressions the catalog documents (e.g. `register.q_age >= 18`,
7016    /// `submit.status == "ok"`). Beyond `==`/`!=` it must handle numeric
7017    /// ordering (`>=` `<=` `>` `<`) and `contains` (case-insensitive substring);
7018    /// otherwise those conditions silently evaluate to false and route wrong.
7019    #[test]
7020    fn condition_evaluator_supports_comparisons_and_contains() {
7021        let ctx = json!({
7022            "register": { "q_age": 18 },
7023            "submit": { "status": "ok" },
7024            "msg": { "text": "Hello World" }
7025        });
7026
7027        // Numeric ordering (operands parsed as numbers).
7028        assert!(evaluate_simple_condition("register.q_age >= 18", &ctx));
7029        assert!(!evaluate_simple_condition("register.q_age > 18", &ctx));
7030        assert!(evaluate_simple_condition("register.q_age <= 18", &ctx));
7031        assert!(!evaluate_simple_condition("register.q_age < 18", &ctx));
7032
7033        // contains: case-insensitive substring over the resolved string.
7034        assert!(evaluate_simple_condition(
7035            "msg.text contains \"world\"",
7036            &ctx
7037        ));
7038        assert!(!evaluate_simple_condition(
7039            "msg.text contains \"bye\"",
7040            &ctx
7041        ));
7042
7043        // Existing equality semantics unchanged (regression guard).
7044        assert!(evaluate_simple_condition("submit.status == \"ok\"", &ctx));
7045        assert!(!evaluate_simple_condition("submit.status != \"ok\"", &ctx));
7046        // A non-numeric operand on an ordering op is false, not a panic.
7047        assert!(!evaluate_simple_condition("submit.status >= 1", &ctx));
7048    }
7049
7050    /// Symmetric to the success default: when a node FAILS (`ok == false`)
7051    /// without an explicit outcome, route to the error-family port the node
7052    /// actually has an edge for (priority `on_error` → `on_cancel` →
7053    /// `on_timeout`), not blindly `on_error`. Lets a node whose failure port is
7054    /// `on_cancel` (qa) or `on_timeout` (http) route instead of stalling.
7055    #[test]
7056    fn failure_default_matches_available_outcome_port() {
7057        let flow_ir = HostFlow {
7058            id: "flow.test".to_string(),
7059            start: None,
7060            nodes: IndexMap::new(),
7061            slot_schema: None,
7062            vars_init: JsonMap::new(),
7063        };
7064        let current = NodeId::from_str("current").unwrap();
7065        let state = ExecutionState::new(json!({}));
7066        // ok:false, no explicit outcome — the failure case.
7067        let err_out = NodeOutput {
7068            ok: false,
7069            payload: json!({}),
7070            meta: Value::Null,
7071        };
7072
7073        // qa shape: failure port is `on_cancel`, no `on_error` edge.
7074        let on_cancel_routing = json!([
7075            { "condition": "event == \"on_complete\"", "to": "next" },
7076            { "condition": "event == \"on_cancel\"", "to": "cancelled" }
7077        ]);
7078        match evaluate_custom_routing(&on_cancel_routing, &err_out, &state, &flow_ir, &current) {
7079            CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "cancelled"),
7080            other => panic!("expected Next(\"cancelled\") via on_cancel default, got {other:?}"),
7081        }
7082
7083        // http shape: `on_error` present → on_error (priority, unchanged).
7084        let on_error_routing = json!([
7085            { "condition": "event == \"on_success\"", "to": "ok" },
7086            { "condition": "event == \"on_error\"", "to": "err" }
7087        ]);
7088        match evaluate_custom_routing(&on_error_routing, &err_out, &state, &flow_ir, &current) {
7089            CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "err"),
7090            other => panic!("expected Next(\"err\") via on_error default, got {other:?}"),
7091        }
7092
7093        // on_timeout-only failure port.
7094        let on_timeout_routing = json!([
7095            { "condition": "event == \"on_success\"", "to": "ok" },
7096            { "condition": "event == \"on_timeout\"", "to": "timed_out" }
7097        ]);
7098        match evaluate_custom_routing(&on_timeout_routing, &err_out, &state, &flow_ir, &current) {
7099            CustomRoutingDecision::Next(nid) => assert_eq!(nid.as_str(), "timed_out"),
7100            other => panic!("expected Next(\"timed_out\") via on_timeout default, got {other:?}"),
7101        }
7102    }
7103
7104    #[test]
7105    fn outcome_meta_surfaces_component_emitted_outcome() {
7106        // A component opts into outcome routing by adding `outcome` to its
7107        // output envelope; the runner surfaces it as node meta for routing.
7108        assert_eq!(
7109            outcome_meta(&json!({ "ok": true, "outcome": "on_complete" })),
7110            json!({ "outcome": "on_complete" })
7111        );
7112        // No `outcome` → null meta → engine uses the ok-derived default.
7113        assert_eq!(
7114            outcome_meta(&json!({ "ok": true, "body": {} })),
7115            Value::Null
7116        );
7117    }
7118
7119    /// Live end-to-end test: `dw.agent` NATS dispatch path.
7120    ///
7121    /// Requires a real NATS server (JetStream not needed for this test — core
7122    /// NATS pub/sub is sufficient) and an `aw-serve` consumer (or the in-process
7123    /// fake bridge below acts as one).
7124    ///
7125    /// # Run recipe
7126    ///
7127    /// ```text
7128    /// # Terminal 1 – NATS server (JetStream-enabled for prod parity, but core works too)
7129    /// nats-server -js
7130    ///
7131    /// # Terminal 2 – aw-serve test-mock (replies "pong" for any agent)
7132    /// AW_SERVE_AGENT_ID=greeter AW_SERVE_REPLY=pong \
7133    ///   GREENTIC_EVENTS_NATS_URL=nats://127.0.0.1:4222 \
7134    ///   GREENTIC_AW_JETSTREAM=off \
7135    ///   cargo run -p greentic-aw-runtime --features serve,test-mock --bin aw-serve
7136    ///
7137    /// # Terminal 3 – run this ignored test
7138    /// GREENTIC_EVENTS_NATS_URL=nats://127.0.0.1:4222 \
7139    ///   cargo test -p greentic-runner-host --lib \
7140    ///   tests::dw_agent_scale_to_zero_nats_e2e \
7141    ///   -- --nocapture --ignored
7142    /// ```
7143    ///
7144    /// When `GREENTIC_EVENTS_NATS_URL` is unset the test skips immediately.
7145    /// The test wires its own in-process fake bridge so the `aw-serve` binary is
7146    /// optional; running with the real `aw-serve` exercises the full out-of-process
7147    /// path. Both variants must produce a resumed reply of `"pong"`.
7148    #[cfg(feature = "agentic-worker")]
7149    #[tokio::test]
7150    #[ignore = "requires live NATS; run with --ignored after `nats-server -js`"]
7151    async fn dw_agent_scale_to_zero_nats_e2e() {
7152        use crate::runner::agent_node::DwAgentDispatch;
7153        use crate::runner::dispatch_listener::{SessionResumer, run_response_listener};
7154        use crate::runner::remote_dispatch::NatsDispatcher;
7155        use futures::StreamExt as _;
7156        use greentic_types::{
7157            RuntimeDispatchResponse, TenantCtx as DispatchTenantCtx, request_topic, response_topic,
7158        };
7159        use tokio::sync::Notify;
7160
7161        let nats_url = match std::env::var("GREENTIC_EVENTS_NATS_URL") {
7162            Ok(url) => url,
7163            Err(_) => {
7164                eprintln!(
7165                    "skipping dw_agent_scale_to_zero_nats_e2e: GREENTIC_EVENTS_NATS_URL not set"
7166                );
7167                return;
7168            }
7169        };
7170
7171        // ── 1. Build a two-node flow: dw.agent → emit.log (resume target) ──
7172        // The agent node must have Routing::Next so the engine knows the resume
7173        // target (same requirement as agentic.call / sorla.call in production).
7174        let resume_id = NodeId::from_str("after-agent").unwrap();
7175        let agent_node_id = NodeId::from_str("agent-e2e").unwrap();
7176        let agent_node = Node {
7177            id: agent_node_id.clone(),
7178            component: FlowComponentRef {
7179                id: "dw.agent".parse().unwrap(),
7180                pack_alias: None,
7181                operation: Some("greeter".to_string()),
7182            },
7183            input: InputMapping {
7184                mapping: json!({ "user_text": "ping" }),
7185            },
7186            output: OutputMapping {
7187                mapping: Value::Null,
7188            },
7189            err_map: None,
7190            routing: Routing::Next {
7191                node_id: resume_id.clone(),
7192            },
7193            telemetry: TelemetryHints::default(),
7194            conversational: false,
7195        };
7196        let resume_node = Node {
7197            id: resume_id.clone(),
7198            component: FlowComponentRef {
7199                id: "emit.log".parse().unwrap(),
7200                pack_alias: None,
7201                operation: None,
7202            },
7203            input: InputMapping {
7204                mapping: json!({ "message": "resumed" }),
7205            },
7206            output: OutputMapping {
7207                mapping: Value::Null,
7208            },
7209            err_map: None,
7210            routing: Routing::End,
7211            telemetry: TelemetryHints::default(),
7212            conversational: false,
7213        };
7214        let mut nodes = indexmap::IndexMap::default();
7215        nodes.insert(agent_node_id.clone(), agent_node);
7216        nodes.insert(resume_id.clone(), resume_node);
7217        let flow = greentic_types::Flow {
7218            schema_version: "1.0".into(),
7219            id: greentic_types::FlowId::from_str("e2e-agent.flow").unwrap(),
7220            kind: greentic_types::FlowKind::Messaging,
7221            entrypoints: BTreeMap::from([(
7222                "default".to_string(),
7223                Value::String(agent_node_id.to_string()),
7224            )]),
7225            nodes,
7226            metadata: Default::default(),
7227        };
7228        let host_flow = HostFlow::from(flow);
7229
7230        // ── 2. Connect NATS clients ──
7231        let dispatcher_client = async_nats::connect(&nats_url)
7232            .await
7233            .expect("NATS: dispatcher client");
7234        let bridge_client = async_nats::connect(&nats_url)
7235            .await
7236            .expect("NATS: fake bridge client");
7237        let listener_client = async_nats::connect(&nats_url)
7238            .await
7239            .expect("NATS: response listener client");
7240
7241        // ── 3. Fake bridge: subscribe to agentic request subject, reply "pong" ──
7242        let agentic_request_subject = request_topic("agentic");
7243        let agentic_response_subject = response_topic("agentic");
7244        let mut req_sub = bridge_client
7245            .subscribe(agentic_request_subject.clone())
7246            .await
7247            .expect("fake bridge: subscribe to agentic request subject");
7248        let bridge_reply_client = bridge_client.clone();
7249        let reply_subject = agentic_response_subject.clone();
7250        tokio::spawn(async move {
7251            while let Some(msg) = req_sub.next().await {
7252                let headers = msg.headers.as_ref();
7253                let get_hdr = |name: &str| {
7254                    headers
7255                        .and_then(|h| h.get(name))
7256                        .map(|v| v.as_str().to_owned())
7257                        .unwrap_or_default()
7258                };
7259                let correlation_id = get_hdr("Greentic-Correlation-Id");
7260                let tenant = get_hdr("Greentic-Tenant");
7261                let env = get_hdr("Greentic-Env");
7262
7263                let response_payload = RuntimeDispatchResponse {
7264                    ok: true,
7265                    output: json!({
7266                        "reply": "pong",
7267                        "trail": [],
7268                        "terminated_by": "final_reply"
7269                    }),
7270                    events: vec![],
7271                    error: None,
7272                };
7273                let body =
7274                    serde_json::to_vec(&response_payload).expect("serialize fake bridge response");
7275
7276                let mut resp_headers = async_nats::HeaderMap::new();
7277                resp_headers.insert("Greentic-Correlation-Id", correlation_id.as_str());
7278                resp_headers.insert("Greentic-Tenant", tenant.as_str());
7279                resp_headers.insert("Greentic-Env", env.as_str());
7280
7281                bridge_reply_client
7282                    .publish_with_headers(reply_subject.clone(), resp_headers, body.into())
7283                    .await
7284                    .expect("fake bridge: publish response");
7285            }
7286        });
7287
7288        // ── 4. Recording resumer + run_response_listener ──
7289        struct RecordingResumer {
7290            calls: std::sync::Mutex<Vec<(String, Value)>>,
7291            notify: Notify,
7292        }
7293
7294        impl RecordingResumer {
7295            fn new() -> Self {
7296                Self {
7297                    calls: std::sync::Mutex::new(vec![]),
7298                    notify: Notify::new(),
7299                }
7300            }
7301        }
7302
7303        #[async_trait::async_trait]
7304        impl SessionResumer for RecordingResumer {
7305            async fn resume(
7306                &self,
7307                _tenant: DispatchTenantCtx,
7308                correlation_id: &str,
7309                output: Value,
7310            ) -> anyhow::Result<()> {
7311                self.calls
7312                    .lock()
7313                    .unwrap()
7314                    .push((correlation_id.to_string(), output));
7315                self.notify.notify_one();
7316                Ok(())
7317            }
7318        }
7319
7320        let resumer = Arc::new(RecordingResumer::new());
7321        let resumer_for_listener = resumer.clone();
7322        tokio::spawn(async move {
7323            run_response_listener(listener_client, "agentic".to_owned(), resumer_for_listener)
7324                .await
7325                .expect("response listener exited unexpectedly");
7326        });
7327
7328        // Give subscriptions a moment to register.
7329        tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;
7330
7331        // ── 5. Build FlowEngine with NatsDispatcher + DwAgentDispatch::Nats ──
7332        let nats_engine_dispatcher = Arc::new(NatsDispatcher::new(dispatcher_client));
7333        let engine = FlowEngine {
7334            packs: Vec::new(),
7335            flows: Vec::new(),
7336            flow_sources: StdHashMap::new(),
7337            messaging_provider_pack_ids: std::collections::HashSet::new(),
7338            rollout_ids: RolloutIds::default(),
7339            flow_cache: RwLock::new(StdHashMap::from([(
7340                FlowKey {
7341                    pack_id: "e2e-pack".to_string(),
7342                    flow_id: "e2e-agent.flow".to_string(),
7343                },
7344                host_flow,
7345            )])),
7346            default_env: "local".to_string(),
7347            validation: crate::validate::ValidationConfig {
7348                mode: crate::validate::ValidationMode::Off,
7349            },
7350            cross_pack_resolver: None,
7351            remote_dispatch_handler: Some(
7352                nats_engine_dispatcher
7353                    as Arc<dyn crate::runner::remote_dispatch::RemoteDispatchHandler>,
7354            ),
7355            dw_agent_dispatch: DwAgentDispatch::Nats,
7356            agent_node_handler: None,
7357            graph_node_handler: None,
7358            mcp_tool_source: None,
7359        };
7360
7361        let ctx = FlowContext {
7362            tenant: "demo",
7363            pack_id: "e2e-pack",
7364            flow_id: "e2e-agent.flow",
7365            node_id: None,
7366            tool: None,
7367            action: None,
7368            session_id: Some("e2e-sess-1"),
7369            provider_id: None,
7370            reply_scope: None,
7371            retry_config: RetryConfig {
7372                max_attempts: 1,
7373                base_delay_ms: 1,
7374            },
7375            attempt: 1,
7376            observer: None,
7377            mocks: None,
7378        };
7379
7380        // ── 6. Execute: the dw.agent NATS path must PAUSE the flow ──
7381        let result = engine
7382            .execute(ctx, json!({ "user_text": "ping" }))
7383            .await
7384            .expect("engine.execute succeeded");
7385
7386        assert!(
7387            matches!(result.status, FlowStatus::Waiting(_)),
7388            "expected FlowStatus::Waiting from dw.agent Nats path, got: {:?}",
7389            result.status
7390        );
7391        eprintln!("dw.agent: flow paused (Waiting) — dispatch published to NATS");
7392
7393        // ── 7. Wait for the fake bridge reply to reach the resumer (up to 5 s) ──
7394        let wait = tokio::time::timeout(
7395            tokio::time::Duration::from_secs(5),
7396            resumer.notify.notified(),
7397        )
7398        .await;
7399
7400        assert!(
7401            wait.is_ok(),
7402            "timed out waiting for fake bridge reply — is NATS running? ({nats_url})"
7403        );
7404
7405        // ── 8. Assert the resumed reply == "pong" ──
7406        let calls = resumer.calls.lock().unwrap();
7407        assert_eq!(
7408            calls.len(),
7409            1,
7410            "resumer should have been called exactly once"
7411        );
7412        let (ref _corr, ref output) = calls[0];
7413        assert_eq!(
7414            output["output"]["reply"],
7415            json!("pong"),
7416            "resumed reply must match the aw-serve canned reply"
7417        );
7418        eprintln!(
7419            "PASSED: dw.agent scale-to-zero NATS e2e — reply={:?}",
7420            output["output"]["reply"]
7421        );
7422    }
7423
7424    #[test]
7425    fn execution_state_vars_survive_serde_round_trip() {
7426        // vars must persist across a park/resume, which is a serde round-trip of ExecutionState.
7427        let mut st = ExecutionState::new(json!({}));
7428        st.vars.insert("counter".into(), json!(3));
7429        st.vars.insert("region".into(), json!("us-east-1"));
7430
7431        let encoded = serde_json::to_string(&st).expect("serialize");
7432        let decoded: ExecutionState = serde_json::from_str(&encoded).expect("deserialize");
7433
7434        assert_eq!(decoded.vars.get("counter"), Some(&json!(3)));
7435        assert_eq!(decoded.vars.get("region"), Some(&json!("us-east-1")));
7436    }
7437
7438    #[test]
7439    fn execution_state_vars_default_empty_for_old_snapshots() {
7440        // A snapshot serialized before `vars` existed (no `vars` key) must still load.
7441        let legacy = r#"{"entry":{},"input":{},"nodes":{},"egress":[],"redirect_count":0}"#;
7442        let decoded: ExecutionState = serde_json::from_str(legacy).expect("legacy loads");
7443        assert!(decoded.vars.is_empty());
7444    }
7445
7446    #[test]
7447    fn template_context_exposes_vars_namespace_typed() {
7448        let mut st = ExecutionState::new(serde_json::json!({}));
7449        st.vars.insert("count".into(), serde_json::json!(5));
7450        st.vars.insert("name".into(), serde_json::json!("aws"));
7451
7452        let ctx = template_context(&st, serde_json::Value::Null);
7453        // {{vars.count}} must resolve to the JSON number 5, not the string "5".
7454        let rendered_num = render_template_value(
7455            &serde_json::json!("{{vars.count}}"),
7456            &ctx,
7457            TemplateOptions::default(),
7458        )
7459        .expect("render num");
7460        assert_eq!(rendered_num, serde_json::json!(5));
7461
7462        let rendered_str = render_template_value(
7463            &serde_json::json!("prefix-{{vars.name}}"),
7464            &ctx,
7465            TemplateOptions::default(),
7466        )
7467        .expect("render str");
7468        assert_eq!(rendered_str, serde_json::json!("prefix-aws"));
7469    }
7470
7471    #[test]
7472    fn vars_namespace_does_not_shadow_existing_namespaces() {
7473        let st = ExecutionState::new(serde_json::json!({"user": {"id": 7}}));
7474        let ctx = template_context(&st, serde_json::Value::Null);
7475        let obj = ctx.as_object().expect("ctx object");
7476        for key in ["entry", "in", "prev", "node", "state", "vars"] {
7477            assert!(obj.contains_key(key), "context must expose `{key}`");
7478        }
7479    }
7480
7481    // ── vars_init tests ────────────────────────────────────────────────────
7482
7483    /// Build a minimal flow with the given free-form `metadata.extra` value.
7484    /// Mirrors the construction used by neighbouring engine tests: a schema-1.0
7485    /// Messaging flow with no nodes and no entrypoints, only the metadata set.
7486    fn flow_with_extra(extra: serde_json::Value) -> Flow {
7487        Flow {
7488            schema_version: "1.0".into(),
7489            id: FlowId::from_str("test.flow").unwrap(),
7490            kind: FlowKind::Messaging,
7491            entrypoints: BTreeMap::new(),
7492            nodes: indexmap::IndexMap::default(),
7493            metadata: FlowMetadata {
7494                title: None,
7495                description: None,
7496                tags: Default::default(),
7497                extra,
7498            },
7499        }
7500    }
7501
7502    #[test]
7503    fn from_flow_extracts_vars_init() {
7504        let flow = flow_with_extra(serde_json::json!({
7505            "vars_init": {
7506                "region":  { "type": "string", "default": "us-east-1" },
7507                "counter": { "type": "number", "default": 0 }
7508            }
7509        }));
7510        let host: HostFlow = HostFlow::from(flow);
7511        assert_eq!(
7512            host.vars_init.get("region"),
7513            Some(&serde_json::json!("us-east-1"))
7514        );
7515        assert_eq!(host.vars_init.get("counter"), Some(&serde_json::json!(0)));
7516    }
7517
7518    #[test]
7519    fn from_flow_vars_init_absent() {
7520        let flow = flow_with_extra(serde_json::json!({}));
7521        let host: HostFlow = HostFlow::from(flow);
7522        assert!(host.vars_init.is_empty());
7523    }
7524
7525    #[test]
7526    fn execute_once_seeds_declared_vars() {
7527        // A flow with vars_init seeds state.vars before the first node runs.
7528        // We verify this by using an emit.log node whose message template
7529        // references {{vars.region}}: if the var is seeded, the rendered
7530        // output will contain "us-east-1".
7531        let node_id = NodeId::from_str("n1").unwrap();
7532        let node = Node {
7533            id: node_id.clone(),
7534            component: FlowComponentRef {
7535                id: "emit.log".parse().unwrap(),
7536                pack_alias: None,
7537                operation: None,
7538            },
7539            input: InputMapping {
7540                mapping: json!({ "message": "{{vars.region}}" }),
7541            },
7542            output: OutputMapping {
7543                mapping: Value::Null,
7544            },
7545            err_map: None,
7546            routing: Routing::End,
7547            telemetry: TelemetryHints::default(),
7548            conversational: false,
7549        };
7550        let mut nodes = indexmap::IndexMap::default();
7551        nodes.insert(node_id.clone(), node);
7552        let flow = Flow {
7553            schema_version: "1.0".into(),
7554            id: FlowId::from_str("vars.flow").unwrap(),
7555            kind: FlowKind::Messaging,
7556            entrypoints: BTreeMap::from([(
7557                "default".to_string(),
7558                Value::String(node_id.to_string()),
7559            )]),
7560            nodes,
7561            metadata: FlowMetadata {
7562                title: None,
7563                description: None,
7564                tags: Default::default(),
7565                extra: json!({
7566                    "vars_init": {
7567                        "region": { "type": "string", "default": "us-east-1" }
7568                    }
7569                }),
7570            },
7571        };
7572        let host_flow = HostFlow::from(flow);
7573
7574        let engine = FlowEngine {
7575            packs: Vec::new(),
7576            flows: Vec::new(),
7577            flow_sources: HashMap::new(),
7578            messaging_provider_pack_ids: std::collections::HashSet::new(),
7579            rollout_ids: RolloutIds::default(),
7580            flow_cache: RwLock::new(HashMap::from([(
7581                FlowKey {
7582                    pack_id: "test-pack".to_string(),
7583                    flow_id: "vars.flow".to_string(),
7584                },
7585                host_flow,
7586            )])),
7587            default_env: "local".to_string(),
7588            validation: ValidationConfig {
7589                mode: ValidationMode::Off,
7590            },
7591            cross_pack_resolver: None,
7592            remote_dispatch_handler: None,
7593            #[cfg(feature = "agentic-worker")]
7594            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
7595            #[cfg(feature = "agentic-worker")]
7596            agent_node_handler: None,
7597            #[cfg(feature = "agentic-worker")]
7598            graph_node_handler: None,
7599            #[cfg(feature = "agentic-worker")]
7600            mcp_tool_source: None,
7601        };
7602
7603        let observer = CountingObserver::new();
7604        let ctx = FlowContext {
7605            tenant: "demo",
7606            pack_id: "test-pack",
7607            flow_id: "vars.flow",
7608            node_id: None,
7609            tool: None,
7610            action: None,
7611            session_id: None,
7612            provider_id: None,
7613            reply_scope: None,
7614            retry_config: RetryConfig {
7615                max_attempts: 1,
7616                base_delay_ms: 1,
7617            },
7618            attempt: 1,
7619            observer: Some(&observer),
7620            mocks: None,
7621        };
7622
7623        let rt = Runtime::new().unwrap();
7624        let result = rt.block_on(engine.execute(ctx, Value::Null)).unwrap();
7625        assert!(matches!(result.status, FlowStatus::Completed));
7626
7627        let ends = observer.ends.lock().unwrap();
7628        assert_eq!(ends.len(), 1);
7629        assert_eq!(
7630            ends[0].get("message").and_then(Value::as_str),
7631            Some("us-east-1"),
7632            "vars.region must be seeded to its default and rendered in the node payload"
7633        );
7634    }
7635
7636    // ── var_set tests ──────────────────────────────────────────────────────
7637
7638    /// Build a two-node flow: var_set → emit.log, with optional vars_init.
7639    ///
7640    /// `var_set_input` is the raw input mapping for the var.set node,
7641    /// e.g. `json!({ "name": "greeting", "value": "hi" })`.
7642    /// `emit_input` is the input mapping for the emit.log node.
7643    /// `vars_init_extra` is optional flow-level vars_init metadata.
7644    fn var_set_flow(
7645        var_set_input: Value,
7646        emit_input: Value,
7647        vars_init_extra: Option<Value>,
7648    ) -> Flow {
7649        let set_id = NodeId::from_str("set1").unwrap();
7650        let emit_id = NodeId::from_str("emit1").unwrap();
7651
7652        let set_node = Node {
7653            id: set_id.clone(),
7654            component: FlowComponentRef {
7655                id: "var.set".parse().unwrap(),
7656                pack_alias: None,
7657                operation: None,
7658            },
7659            input: InputMapping {
7660                mapping: var_set_input,
7661            },
7662            output: OutputMapping {
7663                mapping: Value::Null,
7664            },
7665            err_map: None,
7666            routing: Routing::Next {
7667                node_id: emit_id.clone(),
7668            },
7669            telemetry: TelemetryHints::default(),
7670            conversational: false,
7671        };
7672
7673        let emit_node = Node {
7674            id: emit_id.clone(),
7675            component: FlowComponentRef {
7676                id: "emit.log".parse().unwrap(),
7677                pack_alias: None,
7678                operation: None,
7679            },
7680            input: InputMapping {
7681                mapping: emit_input,
7682            },
7683            output: OutputMapping {
7684                mapping: Value::Null,
7685            },
7686            err_map: None,
7687            routing: Routing::End,
7688            telemetry: TelemetryHints::default(),
7689            conversational: false,
7690        };
7691
7692        let mut nodes = indexmap::IndexMap::default();
7693        nodes.insert(set_id.clone(), set_node);
7694        nodes.insert(emit_id.clone(), emit_node);
7695
7696        let extra = vars_init_extra.unwrap_or(serde_json::json!({}));
7697
7698        Flow {
7699            schema_version: "1.0".into(),
7700            id: FlowId::from_str("var.set.flow").unwrap(),
7701            kind: FlowKind::Messaging,
7702            entrypoints: BTreeMap::from([(
7703                "default".to_string(),
7704                Value::String(set_id.to_string()),
7705            )]),
7706            nodes,
7707            metadata: FlowMetadata {
7708                title: None,
7709                description: None,
7710                tags: Default::default(),
7711                extra,
7712            },
7713        }
7714    }
7715
7716    fn run_var_set_flow(flow: Flow) -> (FlowStatus, Vec<Value>) {
7717        let host_flow = HostFlow::from(flow);
7718        let engine = FlowEngine {
7719            packs: Vec::new(),
7720            flows: Vec::new(),
7721            flow_sources: StdHashMap::new(),
7722            messaging_provider_pack_ids: std::collections::HashSet::new(),
7723            rollout_ids: RolloutIds::default(),
7724            flow_cache: RwLock::new(StdHashMap::from([(
7725                FlowKey {
7726                    pack_id: "test-pack".to_string(),
7727                    flow_id: "var.set.flow".to_string(),
7728                },
7729                host_flow,
7730            )])),
7731            default_env: "local".to_string(),
7732            validation: ValidationConfig {
7733                mode: ValidationMode::Off,
7734            },
7735            cross_pack_resolver: None,
7736            remote_dispatch_handler: None,
7737            #[cfg(feature = "agentic-worker")]
7738            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
7739            #[cfg(feature = "agentic-worker")]
7740            agent_node_handler: None,
7741            #[cfg(feature = "agentic-worker")]
7742            graph_node_handler: None,
7743            #[cfg(feature = "agentic-worker")]
7744            mcp_tool_source: None,
7745        };
7746        let observer = CountingObserver::new();
7747        let ctx = FlowContext {
7748            tenant: "demo",
7749            pack_id: "test-pack",
7750            flow_id: "var.set.flow",
7751            node_id: None,
7752            tool: None,
7753            action: None,
7754            session_id: None,
7755            provider_id: None,
7756            reply_scope: None,
7757            retry_config: RetryConfig {
7758                max_attempts: 1,
7759                base_delay_ms: 1,
7760            },
7761            attempt: 1,
7762            observer: Some(&observer),
7763            mocks: None,
7764        };
7765        let rt = Runtime::new().unwrap();
7766        let result = rt.block_on(engine.execute(ctx, Value::Null)).unwrap();
7767        let ends = observer.ends.lock().unwrap().clone();
7768        (result.status, ends)
7769    }
7770
7771    #[test]
7772    fn var_set_node_writes_literal_value_into_vars() {
7773        // A var_set node with a literal value: greeting="hi".
7774        // The following emit.log node uses {{vars.greeting}} and its output
7775        // proves the var was written.
7776        let flow = var_set_flow(
7777            json!({ "name": "greeting", "value": "hi" }),
7778            json!({ "message": "{{vars.greeting}}" }),
7779            None,
7780        );
7781        let (status, ends) = run_var_set_flow(flow);
7782
7783        assert!(
7784            matches!(status, FlowStatus::Completed),
7785            "flow must complete"
7786        );
7787        assert_eq!(ends.len(), 2, "both nodes must fire");
7788        // var_set node output
7789        assert_eq!(ends[0].get("ok"), Some(&json!(true)), "var_set output ok");
7790        // emit.log node output: vars.greeting was written
7791        assert_eq!(
7792            ends[1].get("message").and_then(Value::as_str),
7793            Some("hi"),
7794            "vars.greeting must be written and renderable in the next node"
7795        );
7796    }
7797
7798    #[test]
7799    fn var_set_node_writes_templated_value_with_type_preserved() {
7800        // vars_init seeds counter=1 (a number).
7801        // var_set copies it into "copy" via {{vars.counter}}.
7802        // The emit.log node uses {{vars.copy}} as the sole message template;
7803        // render_template_value returns the typed JSON number, not a string.
7804        let flow = var_set_flow(
7805            json!({ "name": "copy", "value": "{{vars.counter}}" }),
7806            json!({ "message": "{{vars.copy}}" }),
7807            Some(json!({
7808                "vars_init": {
7809                    "counter": { "type": "number", "default": 1 }
7810                }
7811            })),
7812        );
7813        let (status, ends) = run_var_set_flow(flow);
7814
7815        assert!(
7816            matches!(status, FlowStatus::Completed),
7817            "flow must complete"
7818        );
7819        assert_eq!(ends.len(), 2, "both nodes must fire");
7820        // emit.log message must be the typed number 1, not the string "1"
7821        assert_eq!(
7822            ends[1].get("message"),
7823            Some(&json!(1)),
7824            "vars.copy must preserve the JSON number type from vars.counter"
7825        );
7826    }
7827
7828    #[test]
7829    fn var_set_empty_name_is_skipped_not_written() {
7830        // A var_set node with an empty (or whitespace-only) name must complete
7831        // without panic and must NOT insert a "" key into state.vars.
7832        let engine = minimal_engine();
7833        let rt = Runtime::new().unwrap();
7834        let retry_config = RetryConfig {
7835            max_attempts: 1,
7836            base_delay_ms: 1,
7837        };
7838        let ctx = FlowContext {
7839            tenant: "demo",
7840            pack_id: "test-pack",
7841            flow_id: "var.set.flow",
7842            node_id: Some("set1"),
7843            tool: None,
7844            action: None,
7845            session_id: None,
7846            provider_id: None,
7847            reply_scope: None,
7848            retry_config,
7849            attempt: 1,
7850            observer: None,
7851            mocks: None,
7852        };
7853        let node = HostNode {
7854            kind: NodeKind::VarSet {
7855                name: "".to_string(),
7856                value: json!("garbage"),
7857            },
7858            component: "var.set".into(),
7859            component_id: "var.set".into(),
7860            operation_name: None,
7861            operation_in_mapping: None,
7862            payload_expr: Value::Null,
7863            routing: Routing::End,
7864            vars_out: None,
7865        };
7866        let mut state = ExecutionState::new(Value::Null);
7867        let payload = Value::Null;
7868        let event = NodeEvent {
7869            context: &ctx,
7870            node_id: "set1",
7871            node: &node,
7872            payload: &payload,
7873        };
7874
7875        let outcome = rt
7876            .block_on(engine.dispatch_node(
7877                &ctx,
7878                "set1",
7879                &node,
7880                &mut state,
7881                payload.clone(),
7882                &event,
7883            ))
7884            .expect("dispatch_node must not error on empty var name");
7885
7886        // Must return {ok: true} (not an error).
7887        assert_eq!(
7888            outcome.output.payload,
7889            json!({ "ok": true }),
7890            "dispatch must return ok:true even when name is empty"
7891        );
7892        // Must NOT have inserted a \"\" key into state.vars.
7893        assert!(
7894            state.vars.get("").is_none(),
7895            "empty var name must not create a \"\" key in state.vars"
7896        );
7897    }
7898
7899    #[test]
7900    fn var_set_node_has_empty_payload_expr() {
7901        // Lowering a var.set Node must yield a HostNode whose payload_expr is
7902        // Value::Null. The VarSet dispatch arm reads name/value directly from
7903        // NodeKind::VarSet, so forwarding the mapping as payload_expr is redundant.
7904        let node_id = NodeId::from_str("set1").unwrap();
7905        let node = Node {
7906            id: node_id.clone(),
7907            component: FlowComponentRef {
7908                id: "var.set".parse().unwrap(),
7909                pack_alias: None,
7910                operation: None,
7911            },
7912            input: InputMapping {
7913                mapping: json!({ "name": "greeting", "value": "hi" }),
7914            },
7915            output: OutputMapping {
7916                mapping: Value::Null,
7917            },
7918            err_map: None,
7919            routing: Routing::End,
7920            telemetry: TelemetryHints::default(),
7921            conversational: false,
7922        };
7923
7924        let host_node = HostNode::from(node);
7925
7926        // payload_expr must be Null.
7927        assert_eq!(
7928            host_node.payload_expr,
7929            Value::Null,
7930            "var.set node must have Null payload_expr after lowering"
7931        );
7932        // NodeKind::VarSet must still carry the original name and value.
7933        match &host_node.kind {
7934            NodeKind::VarSet { name, value } => {
7935                assert_eq!(name.as_str(), "greeting", "name must be preserved in kind");
7936                assert_eq!(value, &json!("hi"), "value must be preserved in kind");
7937            }
7938            other => panic!("expected NodeKind::VarSet, got {other:?}"),
7939        }
7940    }
7941
7942    // ── vars_out tests ──────────────────────────────────────────────────────
7943
7944    /// Build a two-node flow: emit.log (with vars_out) → emit.log.
7945    ///
7946    /// `emit1_input` is the raw input mapping for the first emit.log node
7947    /// (should include the `vars_out` binding).
7948    /// `emit2_input` is the input mapping for the second emit.log node
7949    /// (reads from `vars.*` to prove bindings were applied).
7950    fn vars_out_flow(emit1_input: Value, emit2_input: Value) -> Flow {
7951        let emit1_id = NodeId::from_str("emit1").unwrap();
7952        let emit2_id = NodeId::from_str("emit2").unwrap();
7953
7954        let emit1_node = Node {
7955            id: emit1_id.clone(),
7956            component: FlowComponentRef {
7957                id: "emit.log".parse().unwrap(),
7958                pack_alias: None,
7959                operation: None,
7960            },
7961            input: InputMapping {
7962                mapping: emit1_input,
7963            },
7964            output: OutputMapping {
7965                mapping: Value::Null,
7966            },
7967            err_map: None,
7968            routing: Routing::Next {
7969                node_id: emit2_id.clone(),
7970            },
7971            telemetry: TelemetryHints::default(),
7972            conversational: false,
7973        };
7974
7975        let emit2_node = Node {
7976            id: emit2_id.clone(),
7977            component: FlowComponentRef {
7978                id: "emit.log".parse().unwrap(),
7979                pack_alias: None,
7980                operation: None,
7981            },
7982            input: InputMapping {
7983                mapping: emit2_input,
7984            },
7985            output: OutputMapping {
7986                mapping: Value::Null,
7987            },
7988            err_map: None,
7989            routing: Routing::End,
7990            telemetry: TelemetryHints::default(),
7991            conversational: false,
7992        };
7993
7994        let mut nodes = indexmap::IndexMap::default();
7995        nodes.insert(emit1_id.clone(), emit1_node);
7996        nodes.insert(emit2_id.clone(), emit2_node);
7997
7998        // Reuse the same flow_id as var_set_flow so we can pass it directly to
7999        // `run_var_set_flow`, which registers the flow under that key.
8000        Flow {
8001            schema_version: "1.0".into(),
8002            id: FlowId::from_str("var.set.flow").unwrap(),
8003            kind: FlowKind::Messaging,
8004            entrypoints: BTreeMap::from([(
8005                "default".to_string(),
8006                Value::String(emit1_id.to_string()),
8007            )]),
8008            nodes,
8009            metadata: FlowMetadata {
8010                title: None,
8011                description: None,
8012                tags: Default::default(),
8013                extra: serde_json::json!({}),
8014            },
8015        }
8016    }
8017
8018    #[test]
8019    fn vars_out_binds_node_output_into_vars() {
8020        // `emit.log` outputs its rendered payload directly. Node 1 emits
8021        // `{ message: "hello" }` and declares `vars_out = { lastReply:
8022        // "{{prev.message}}" }`. After it runs, `state.vars["lastReply"]`
8023        // must equal "hello". Node 2 reads that var so the assertion is driven
8024        // from the second node's output rather than internal state.
8025        let flow = vars_out_flow(
8026            json!({
8027                "message": "hello",
8028                "vars_out": { "lastReply": "{{prev.message}}" }
8029            }),
8030            json!({ "message": "{{vars.lastReply}}" }),
8031        );
8032        let (status, ends) = run_var_set_flow(flow);
8033
8034        assert!(
8035            matches!(status, FlowStatus::Completed),
8036            "flow must complete"
8037        );
8038        assert_eq!(ends.len(), 2, "both nodes must fire");
8039        // Node 2's message must equal the value captured by vars_out in node 1.
8040        assert_eq!(
8041            ends[1].get("message").and_then(Value::as_str),
8042            Some("hello"),
8043            "vars_out binding from node 1 must be readable in node 2"
8044        );
8045    }
8046
8047    /// Build a three-node flow: var_set → session.wait → emit.log.
8048    /// `vars_init` seeds `counter = 1`; `var_set` writes `greeting = "hello"`.
8049    /// The wait parks the flow; resume runs emit.log which reads both vars.
8050    fn vars_survive_flow() -> Flow {
8051        let set_id = NodeId::from_str("set1").unwrap();
8052        let wait_id = NodeId::from_str("wait1").unwrap();
8053        let emit_id = NodeId::from_str("emit1").unwrap();
8054
8055        let set_node = Node {
8056            id: set_id.clone(),
8057            component: FlowComponentRef {
8058                id: "var.set".parse().unwrap(),
8059                pack_alias: None,
8060                operation: None,
8061            },
8062            input: InputMapping {
8063                mapping: json!({ "name": "greeting", "value": "hello" }),
8064            },
8065            output: OutputMapping {
8066                mapping: Value::Null,
8067            },
8068            err_map: None,
8069            routing: Routing::Next {
8070                node_id: wait_id.clone(),
8071            },
8072            telemetry: TelemetryHints::default(),
8073            conversational: false,
8074        };
8075
8076        let wait_node = Node {
8077            id: wait_id.clone(),
8078            component: FlowComponentRef {
8079                id: "session.wait".parse().unwrap(),
8080                pack_alias: None,
8081                operation: None,
8082            },
8083            input: InputMapping {
8084                mapping: Value::Null,
8085            },
8086            output: OutputMapping {
8087                mapping: Value::Null,
8088            },
8089            err_map: None,
8090            routing: Routing::Next {
8091                node_id: emit_id.clone(),
8092            },
8093            telemetry: TelemetryHints::default(),
8094            conversational: false,
8095        };
8096
8097        let emit_node = Node {
8098            id: emit_id.clone(),
8099            component: FlowComponentRef {
8100                id: "emit.log".parse().unwrap(),
8101                pack_alias: None,
8102                operation: None,
8103            },
8104            input: InputMapping {
8105                mapping: json!({
8106                    "greeting": "{{vars.greeting}}",
8107                    "counter": "{{vars.counter}}"
8108                }),
8109            },
8110            output: OutputMapping {
8111                mapping: Value::Null,
8112            },
8113            err_map: None,
8114            routing: Routing::End,
8115            telemetry: TelemetryHints::default(),
8116            conversational: false,
8117        };
8118
8119        let mut nodes = indexmap::IndexMap::default();
8120        nodes.insert(set_id.clone(), set_node);
8121        nodes.insert(wait_id.clone(), wait_node);
8122        nodes.insert(emit_id.clone(), emit_node);
8123
8124        Flow {
8125            schema_version: "1.0".into(),
8126            id: FlowId::from_str("vars.survive.flow").unwrap(),
8127            kind: FlowKind::Messaging,
8128            entrypoints: BTreeMap::from([(
8129                "default".to_string(),
8130                Value::String(set_id.to_string()),
8131            )]),
8132            nodes,
8133            metadata: FlowMetadata {
8134                title: None,
8135                description: None,
8136                tags: Default::default(),
8137                extra: json!({
8138                    "vars_init": {
8139                        "counter": { "type": "number", "default": 1 }
8140                    }
8141                }),
8142            },
8143        }
8144    }
8145
8146    #[test]
8147    fn vars_survive_park_and_resume_end_to_end() {
8148        // vars_init seeds counter=1; var_set writes greeting="hello"; the flow
8149        // parks at session.wait; resume drives emit.log which reads both vars.
8150        let flow = vars_survive_flow();
8151        let host_flow = HostFlow::from(flow);
8152        let flow_id = "vars.survive.flow";
8153        let pack_id = "test-pack";
8154        let engine = FlowEngine {
8155            packs: Vec::new(),
8156            flows: Vec::new(),
8157            flow_sources: StdHashMap::new(),
8158            messaging_provider_pack_ids: std::collections::HashSet::new(),
8159            rollout_ids: RolloutIds::default(),
8160            flow_cache: RwLock::new(StdHashMap::from([(
8161                FlowKey {
8162                    pack_id: pack_id.to_string(),
8163                    flow_id: flow_id.to_string(),
8164                },
8165                host_flow,
8166            )])),
8167            default_env: "local".to_string(),
8168            validation: ValidationConfig {
8169                mode: ValidationMode::Off,
8170            },
8171            cross_pack_resolver: None,
8172            remote_dispatch_handler: None,
8173            #[cfg(feature = "agentic-worker")]
8174            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
8175            #[cfg(feature = "agentic-worker")]
8176            agent_node_handler: None,
8177            #[cfg(feature = "agentic-worker")]
8178            graph_node_handler: None,
8179            #[cfg(feature = "agentic-worker")]
8180            mcp_tool_source: None,
8181        };
8182        let rt = Runtime::new().unwrap();
8183
8184        // First execution: must park at session.wait after var_set fires.
8185        let ctx1 = FlowContext {
8186            tenant: "demo",
8187            pack_id,
8188            flow_id,
8189            node_id: None,
8190            tool: None,
8191            action: None,
8192            session_id: None,
8193            provider_id: None,
8194            reply_scope: None,
8195            retry_config: RetryConfig {
8196                max_attempts: 1,
8197                base_delay_ms: 1,
8198            },
8199            attempt: 1,
8200            observer: None,
8201            mocks: None,
8202        };
8203        let result1 = rt.block_on(engine.execute(ctx1, Value::Null)).unwrap();
8204        let snapshot = match result1.status {
8205            FlowStatus::Waiting(w) => w.snapshot,
8206            other => panic!("expected Waiting after session.wait, got {other:?}"),
8207        };
8208
8209        // Both vars must be present in the snapshot before resume.
8210        assert_eq!(
8211            snapshot.state.vars.get("greeting"),
8212            Some(&json!("hello")),
8213            "greeting var must be in snapshot"
8214        );
8215        assert_eq!(
8216            snapshot.state.vars.get("counter"),
8217            Some(&json!(1)),
8218            "counter var (from vars_init) must be in snapshot"
8219        );
8220
8221        // Resume: emit.log must read both vars from the restored state.
8222        let observer2 = CountingObserver::new();
8223        let ctx2 = FlowContext {
8224            tenant: "demo",
8225            pack_id,
8226            flow_id,
8227            node_id: None,
8228            tool: None,
8229            action: None,
8230            session_id: None,
8231            provider_id: None,
8232            reply_scope: None,
8233            retry_config: RetryConfig {
8234                max_attempts: 1,
8235                base_delay_ms: 1,
8236            },
8237            attempt: 1,
8238            observer: Some(&observer2),
8239            mocks: None,
8240        };
8241        let result2 = rt
8242            .block_on(engine.resume(ctx2, snapshot, Value::Null))
8243            .unwrap();
8244        assert!(
8245            matches!(result2.status, FlowStatus::Completed),
8246            "flow must complete after resume"
8247        );
8248        let ends2 = observer2.ends.lock().unwrap().clone();
8249        assert_eq!(ends2.len(), 1, "only emit.log fires after resume");
8250        assert_eq!(
8251            ends2[0].get("greeting").and_then(Value::as_str),
8252            Some("hello"),
8253            "vars.greeting must survive the park/resume"
8254        );
8255        assert_eq!(
8256            ends2[0].get("counter"),
8257            Some(&json!(1)),
8258            "vars.counter (vars_init) must survive the park/resume"
8259        );
8260    }
8261
8262    // ── Conversational `dw.agent` park-and-loop harness — PORT PENDING ─────
8263    //
8264    // These tests encode the RESEARCH lane's behaviour: a conversational
8265    // `dw.agent` node parks after every reply and re-enters itself until the
8266    // agent emits `conversation_ended`, with `MAX_PARK_TURNS` as the safety
8267    // backstop. This lane's engine carries none of it — `NodeKind::DwAgent`
8268    // has no `conversational` flag, `dispatch_node` has no conversational
8269    // branch, and `NodeControl` has no `LoopHere`/`AwaitHere` variants for
8270    // such a branch to return. `FlowState::park_turns` survives only as
8271    // snapshot-compatibility ballast; no lib code ever bumps it.
8272    //
8273    // While `agentic-worker` was a stub these tests were invisible: nothing
8274    // here compiled, so the gap read as covered. Turning the feature back on
8275    // exposed that. They are kept verbatim behind their own off-by-default
8276    // feature so the debt is explicit and the spec survives for whoever ports
8277    // the park-loop. It is a bare cfg rather than a cargo feature because CI
8278    // builds `--all-features`, which would switch a feature on; build it with
8279    // `RUSTFLAGS="--cfg conversational_dw_agent_port"`, and expect it NOT to
8280    // compile until the port lands — that is the point.
8281    #[cfg(conversational_dw_agent_port)]
8282    mod conversational_dw_agent {
8283        use super::*;
8284
8285        #[cfg(feature = "agentic-worker")]
8286        struct StubAgentHandler {
8287            payload: serde_json::Value,
8288        }
8289        #[cfg(feature = "agentic-worker")]
8290        #[async_trait::async_trait]
8291        impl crate::runner::agent_node::AgentNodeHandler for StubAgentHandler {
8292            async fn execute(
8293                &self,
8294                _tenant_id: &str,
8295                _env_id: &str,
8296                _agent_id: &str,
8297                _session_id: &str,
8298                _flow_input: &serde_json::Value,
8299                _conversational: bool,
8300            ) -> anyhow::Result<serde_json::Value> {
8301                Ok(self.payload.clone())
8302            }
8303        }
8304
8305        /// Build a 2-node flow: a `dw.agent` node (id "agent", conversational as
8306        /// given) routing to an emit "thanks" node that ends the flow.
8307        #[cfg(feature = "agentic-worker")]
8308        fn conversational_dw_flow(conversational: bool) -> HostFlow {
8309            let mut nodes = IndexMap::new();
8310            let agent_id = NodeId::from_str("agent").unwrap();
8311            let thanks_id = NodeId::from_str("thanks").unwrap();
8312            nodes.insert(
8313                agent_id.clone(),
8314                HostNode {
8315                    kind: NodeKind::DwAgent {
8316                        agent_id: "a".to_string(),
8317                        conversational,
8318                    },
8319                    component: "dw.agent".to_string(),
8320                    component_id: "dw.agent".to_string(),
8321                    operation_name: Some("a".to_string()),
8322                    operation_in_mapping: None,
8323                    payload_expr: json!({ "user_text": "hi" }),
8324                    routing: Routing::Next {
8325                        node_id: thanks_id.clone(),
8326                    },
8327                    vars_out: None,
8328                },
8329            );
8330            nodes.insert(
8331                thanks_id.clone(),
8332                HostNode {
8333                    kind: NodeKind::BuiltinEmit {
8334                        kind: EmitKind::Response,
8335                    },
8336                    component: "emit.response".to_string(),
8337                    component_id: "emit.response".to_string(),
8338                    operation_name: None,
8339                    operation_in_mapping: None,
8340                    payload_expr: json!({ "text": "thanks" }),
8341                    routing: Routing::End,
8342                    vars_out: None,
8343                },
8344            );
8345            HostFlow {
8346                slot_schema: None,
8347                id: "conv.flow".to_string(),
8348                start: Some(agent_id),
8349                nodes,
8350                vars_init: JsonMap::new(),
8351                required_vars: Vec::new(),
8352            }
8353        }
8354
8355        /// Build an engine holding `flow` with a stub agent handler returning `payload`.
8356        /// Mirrors the FlowEngine literal in `vars_survive_park_and_resume_end_to_end`.
8357        #[cfg(feature = "agentic-worker")]
8358        fn conv_engine(flow: HostFlow, payload: serde_json::Value) -> FlowEngine {
8359            FlowEngine {
8360                rollout_ids: RolloutIds::default(),
8361                packs: Vec::new(),
8362                flows: Vec::new(),
8363                flow_sources: StdHashMap::new(),
8364                flow_cache: RwLock::new(StdHashMap::from([(
8365                    FlowKey {
8366                        pack_id: "test-pack".to_string(),
8367                        flow_id: "conv.flow".to_string(),
8368                    },
8369                    flow,
8370                )])),
8371                default_env: "local".to_string(),
8372                validation: ValidationConfig {
8373                    mode: ValidationMode::Off,
8374                },
8375                cross_pack_resolver: None,
8376                remote_dispatch_handler: None,
8377                dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
8378                agent_node_handler: Some(std::sync::Arc::new(StubAgentHandler { payload })),
8379                graph_node_handler: None,
8380                mcp_tool_source: None,
8381            }
8382        }
8383
8384        #[cfg(feature = "agentic-worker")]
8385        fn conv_ctx<'a>() -> FlowContext<'a> {
8386            FlowContext {
8387                tenant: "demo",
8388                pack_id: "test-pack",
8389                flow_id: "conv.flow",
8390                node_id: None,
8391                tool: None,
8392                action: None,
8393                session_id: Some("sess-conv"),
8394                provider_id: None,
8395                reply_scope: None,
8396                retry_config: RetryConfig {
8397                    max_attempts: 1,
8398                    base_delay_ms: 1,
8399                },
8400                attempt: 1,
8401                observer: None,
8402                mocks: None,
8403            }
8404        }
8405
8406        #[cfg(feature = "agentic-worker")]
8407        #[test]
8408        fn conversational_dw_agent_parks_and_loops_on_normal_reply() {
8409            let engine = conv_engine(
8410                conversational_dw_flow(true),
8411                json!({ "reply": "hello there", "trail": [], "terminated_by": "final_reply" }),
8412            );
8413            let rt = Runtime::new().unwrap();
8414            let result = rt
8415                .block_on(engine.execute(conv_ctx(), Value::Null))
8416                .unwrap();
8417            let snapshot = match result.status {
8418                FlowStatus::Waiting(w) => w.snapshot,
8419                other => panic!("expected Waiting (park-loop), got {other:?}"),
8420            };
8421            assert_eq!(
8422                snapshot.next_node, "agent",
8423                "must re-enter the dw.agent node itself"
8424            );
8425            // The reply is rendered in the parked output.
8426            assert!(
8427                serde_json::to_string(&result.output)
8428                    .unwrap()
8429                    .contains("hello there"),
8430                "the agent reply must be rendered before parking: {:?}",
8431                result.output
8432            );
8433        }
8434
8435        #[cfg(feature = "agentic-worker")]
8436        #[test]
8437        fn conversational_dw_agent_advances_on_conversation_ended() {
8438            let engine = conv_engine(
8439                conversational_dw_flow(true),
8440                json!({ "reply": "bye", "trail": [], "terminated_by": "conversation_ended" }),
8441            );
8442            let rt = Runtime::new().unwrap();
8443            let result = rt
8444                .block_on(engine.execute(conv_ctx(), Value::Null))
8445                .unwrap();
8446            assert!(
8447                matches!(result.status, FlowStatus::Completed),
8448                "conversation_ended must advance to the successor and complete, got {:?}",
8449                result.status
8450            );
8451        }
8452
8453        #[cfg(feature = "agentic-worker")]
8454        #[test]
8455        fn non_conversational_dw_agent_never_loops() {
8456            // Even with terminated_by == conversation_ended, a non-conversational
8457            // node just routes onward (today's one-shot behaviour) — never parks.
8458            for tb in ["final_reply", "conversation_ended"] {
8459                let engine = conv_engine(
8460                    conversational_dw_flow(false),
8461                    json!({ "reply": "x", "trail": [], "terminated_by": tb }),
8462                );
8463                let rt = Runtime::new().unwrap();
8464                let result = rt
8465                    .block_on(engine.execute(conv_ctx(), Value::Null))
8466                    .unwrap();
8467                assert!(
8468                    matches!(result.status, FlowStatus::Completed),
8469                    "non-conversational must complete (route onward) for terminated_by={tb}, got {:?}",
8470                    result.status
8471                );
8472            }
8473        }
8474
8475        /// Safety-backstop behavioral test: a conversational `dw.agent` that
8476        /// never emits `conversation_ended` must keep parking up to
8477        /// `MAX_PARK_TURNS` turns, then force-advance to the successor instead
8478        /// of trapping the flow forever.
8479        #[cfg(feature = "agentic-worker")]
8480        #[test]
8481        fn conversational_dw_agent_force_advances_after_park_loop_cap() {
8482            let engine = conv_engine(
8483                conversational_dw_flow(true),
8484                json!({ "reply": "still thinking", "trail": [], "terminated_by": "final_reply" }),
8485            );
8486            let rt = Runtime::new().unwrap();
8487
8488            let result = rt
8489                .block_on(engine.execute(conv_ctx(), Value::Null))
8490                .unwrap();
8491            let mut snapshot = match result.status {
8492                FlowStatus::Waiting(w) => w.snapshot,
8493                other => panic!("expected Waiting after turn 1, got {other:?}"),
8494            };
8495
8496            // Turns 2..MAX_PARK_TURNS (exclusive) must keep parking.
8497            for turn in 2..MAX_PARK_TURNS {
8498                let result = rt
8499                    .block_on(engine.resume(conv_ctx(), snapshot, json!({ "text": "still here" })))
8500                    .unwrap();
8501                snapshot = match result.status {
8502                    FlowStatus::Waiting(w) => w.snapshot,
8503                    other => panic!("expected Waiting at turn {turn}, got {other:?}"),
8504                };
8505            }
8506
8507            // The MAX_PARK_TURNS-th turn must force-advance instead of parking again.
8508            let result = rt
8509                .block_on(engine.resume(conv_ctx(), snapshot, json!({ "text": "still here" })))
8510                .unwrap();
8511            assert!(
8512                matches!(result.status, FlowStatus::Completed),
8513                "park-loop cap must force-advance to the successor at turn {MAX_PARK_TURNS}, got {:?}",
8514                result.status
8515            );
8516        }
8517
8518        // ── NATS conversational `dw.agent` park-loop (Task 6) ──────────────────
8519        //
8520        // These tests drive the SAME `conversational_dw_flow`/`conv_ctx` harness as
8521        // the in-process tests above, but with `DwAgentDispatch::Nats` and a stub
8522        // `RemoteDispatchHandler` that never touches a live NATS server — it just
8523        // records the dispatch and immediately returns `AwaitingResponse`, exactly
8524        // like `dw_agent_nats_mode_dispatches_remote` above. The "NATS response
8525        // arriving" half of the round trip is simulated by calling `engine.resume`
8526        // directly with a hand-built envelope `{ok, output, events, error}` — the
8527        // exact shape `dispatch_listener::decode_response` builds and that lands in
8528        // `state.entry` on a real resume (spike finding §Q2). No live NATS server is
8529        // needed or used.
8530
8531        /// Records every dispatch and immediately returns `AwaitingResponse`, so the
8532        /// engine parks without a live NATS server. Mirrors `RecordingDispatcher` in
8533        /// `dw_agent_nats_mode_dispatches_remote`, kept separate (and named for
8534        /// re-use across the tests below) since three tests share it.
8535        #[cfg(feature = "agentic-worker")]
8536        struct ScriptedNatsDispatcher {
8537            calls: Mutex<Vec<crate::runner::remote_dispatch::RemoteDispatch>>,
8538        }
8539
8540        #[cfg(feature = "agentic-worker")]
8541        #[async_trait::async_trait]
8542        impl crate::runner::remote_dispatch::RemoteDispatchHandler for ScriptedNatsDispatcher {
8543            async fn dispatch(
8544                &self,
8545                request: crate::runner::remote_dispatch::RemoteDispatch,
8546            ) -> anyhow::Result<crate::runner::remote_dispatch::RemoteDispatchAction> {
8547                let correlation_id = request.correlation_id.clone();
8548                self.calls.lock().unwrap().push(request);
8549                Ok(
8550                    crate::runner::remote_dispatch::RemoteDispatchAction::AwaitingResponse {
8551                        correlation_id,
8552                    },
8553                )
8554            }
8555        }
8556
8557        /// Build an engine holding `flow` in `DwAgentDispatch::Nats` mode, wired to
8558        /// `dispatcher`. Mirrors `conv_engine` (the in-process counterpart) so the
8559        /// two harnesses are structurally comparable.
8560        #[cfg(feature = "agentic-worker")]
8561        fn nats_conv_engine(
8562            flow: HostFlow,
8563            dispatcher: std::sync::Arc<dyn crate::runner::remote_dispatch::RemoteDispatchHandler>,
8564        ) -> FlowEngine {
8565            FlowEngine {
8566                rollout_ids: RolloutIds::default(),
8567                packs: Vec::new(),
8568                flows: Vec::new(),
8569                flow_sources: StdHashMap::new(),
8570                flow_cache: RwLock::new(StdHashMap::from([(
8571                    FlowKey {
8572                        pack_id: "test-pack".to_string(),
8573                        flow_id: "conv.flow".to_string(),
8574                    },
8575                    flow,
8576                )])),
8577                default_env: "local".to_string(),
8578                validation: ValidationConfig {
8579                    mode: ValidationMode::Off,
8580                },
8581                cross_pack_resolver: None,
8582                remote_dispatch_handler: Some(dispatcher),
8583                dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::Nats,
8584                agent_node_handler: None,
8585                graph_node_handler: None,
8586                mcp_tool_source: None,
8587            }
8588        }
8589
8590        /// Build the envelope a real NATS response resume lands in `state.entry`,
8591        /// per spike finding §Q2: `{ok, output: {reply, trail, terminated_by},
8592        /// events, error}` (mirrors `dispatch_listener::decode_response`).
8593        #[cfg(feature = "agentic-worker")]
8594        fn agent_response_envelope(reply: &str, terminated_by: &str) -> Value {
8595            json!({
8596                "ok": true,
8597                "output": { "reply": reply, "trail": [], "terminated_by": terminated_by },
8598                "events": [],
8599                "error": Value::Null,
8600            })
8601        }
8602
8603        /// Turn 1 (fresh, no prior await marker): the conversational Nats arm must
8604        /// mark the pending await, dispatch to NATS exactly once, and park via
8605        /// `NodeControl::AwaitHere` — resuming at the node itself (not the routing
8606        /// successor) with no reply surfaced yet (the response hasn't arrived).
8607        #[cfg(feature = "agentic-worker")]
8608        #[test]
8609        fn conversational_dw_agent_nats_turn1_parks_via_await_here() {
8610            let dispatcher = Arc::new(ScriptedNatsDispatcher {
8611                calls: Mutex::new(vec![]),
8612            });
8613            let engine = nats_conv_engine(conversational_dw_flow(true), dispatcher.clone());
8614            let rt = Runtime::new().unwrap();
8615
8616            let result = rt
8617                .block_on(engine.execute(conv_ctx(), Value::Null))
8618                .unwrap();
8619            let snapshot = match result.status {
8620                FlowStatus::Waiting(w) => w.snapshot,
8621                other => panic!("expected Waiting after fresh dispatch, got {other:?}"),
8622            };
8623            assert_eq!(
8624                snapshot.next_node, "agent",
8625                "AwaitHere must resume at self, not the routing successor"
8626            );
8627            assert_eq!(
8628                dispatcher.calls.lock().unwrap().len(),
8629                1,
8630                "a fresh user turn must dispatch to NATS exactly once"
8631            );
8632            assert_eq!(
8633                result.output,
8634                Value::Null,
8635                "no reply is known yet on the initial dispatch — the async response hasn't arrived"
8636            );
8637        }
8638
8639        /// Full turn cycle, behavioral: fresh dispatch → AwaitHere park; simulated
8640        /// "not ended" NATS response resume → LoopHere park (reply surfaced,
8641        /// session-keyed park awaiting the next user message); a user-reply resume
8642        /// dispatches to NATS again; a `conversation_ended` response resume →
8643        /// Completed (advanced to the successor). This is the exact turn-by-turn
8644        /// script called for in Task 6's brief.
8645        #[cfg(feature = "agentic-worker")]
8646        #[test]
8647        fn conversational_dw_agent_nats_park_loop_full_turn_cycle() {
8648            let dispatcher = Arc::new(ScriptedNatsDispatcher {
8649                calls: Mutex::new(vec![]),
8650            });
8651            let engine = nats_conv_engine(conversational_dw_flow(true), dispatcher.clone());
8652            let rt = Runtime::new().unwrap();
8653
8654            // Turn 1: fresh user turn → dispatch to NATS → AwaitHere (self, park).
8655            let result = rt
8656                .block_on(engine.execute(conv_ctx(), Value::Null))
8657                .unwrap();
8658            let snapshot = match result.status {
8659                FlowStatus::Waiting(w) => w.snapshot,
8660                other => {
8661                    panic!("expected Waiting (AwaitHere) after turn 1 dispatch, got {other:?}")
8662                }
8663            };
8664            assert_eq!(snapshot.next_node, "agent");
8665            assert_eq!(dispatcher.calls.lock().unwrap().len(), 1);
8666
8667            // Simulated NATS response resume, "not ended": LoopHere (session-keyed
8668            // park awaiting the next user message), reply surfaced.
8669            let result = rt
8670                .block_on(engine.resume(
8671                    conv_ctx(),
8672                    snapshot,
8673                    agent_response_envelope("hello there", "final_reply"),
8674                ))
8675                .unwrap();
8676            let snapshot = match result.status {
8677                FlowStatus::Waiting(w) => w.snapshot,
8678                other => {
8679                    panic!("expected Waiting (LoopHere) after not-ended response, got {other:?}")
8680                }
8681            };
8682            assert_eq!(
8683                snapshot.next_node, "agent",
8684                "LoopHere also re-enters the node itself"
8685            );
8686            assert!(
8687                serde_json::to_string(&result.output)
8688                    .unwrap()
8689                    .contains("hello there"),
8690                "the agent's reply must be surfaced once the response resume lands: {:?}",
8691                result.output
8692            );
8693            assert_eq!(
8694                dispatcher.calls.lock().unwrap().len(),
8695                1,
8696                "the response landing must not itself trigger another NATS dispatch"
8697            );
8698
8699            // User-reply resume: a fresh user turn dispatches to NATS again.
8700            let result = rt
8701                .block_on(engine.resume(conv_ctx(), snapshot, json!({ "text": "user says more" })))
8702                .unwrap();
8703            let snapshot = match result.status {
8704                FlowStatus::Waiting(w) => w.snapshot,
8705                other => {
8706                    panic!("expected Waiting (AwaitHere) after turn 2 dispatch, got {other:?}")
8707                }
8708            };
8709            assert_eq!(snapshot.next_node, "agent");
8710            assert_eq!(
8711                dispatcher.calls.lock().unwrap().len(),
8712                2,
8713                "a second fresh user turn must dispatch to NATS again"
8714            );
8715
8716            // Simulated NATS response resume, `conversation_ended`: advance to the
8717            // successor and complete.
8718            let result = rt
8719                .block_on(engine.resume(
8720                    conv_ctx(),
8721                    snapshot,
8722                    agent_response_envelope("bye", "conversation_ended"),
8723                ))
8724                .unwrap();
8725            assert!(
8726                matches!(result.status, FlowStatus::Completed),
8727                "conversation_ended response must advance to the successor and complete, got {:?}",
8728                result.status
8729            );
8730            assert_eq!(
8731                dispatcher.calls.lock().unwrap().len(),
8732                2,
8733                "conversation end must not trigger another NATS dispatch"
8734            );
8735        }
8736
8737        /// Safety-backstop parity with the in-process cap test: a NATS
8738        /// conversational `dw.agent` whose response never carries
8739        /// `conversation_ended` must keep parking (dispatch → AwaitHere →
8740        /// response-resume → LoopHere) up to `MAX_PARK_TURNS` "not ended" responses,
8741        /// then force-advance to the successor instead of trapping the flow.
8742        #[cfg(feature = "agentic-worker")]
8743        #[test]
8744        fn conversational_dw_agent_nats_force_advances_after_park_loop_cap() {
8745            let dispatcher = Arc::new(ScriptedNatsDispatcher {
8746                calls: Mutex::new(vec![]),
8747            });
8748            let engine = nats_conv_engine(conversational_dw_flow(true), dispatcher.clone());
8749            let rt = Runtime::new().unwrap();
8750
8751            // Turn 1: fresh dispatch (does not itself count toward the park cap —
8752            // the cap is bumped only on a "not ended" response, matching the
8753            // in-process semantics).
8754            let result = rt
8755                .block_on(engine.execute(conv_ctx(), Value::Null))
8756                .unwrap();
8757            let mut snapshot = match result.status {
8758                FlowStatus::Waiting(w) => w.snapshot,
8759                other => panic!("expected Waiting after turn 1 dispatch, got {other:?}"),
8760            };
8761
8762            // Responses 1..MAX_PARK_TURNS (exclusive) must keep looping: a "not
8763            // ended" response resume (LoopHere), then a user-message resume that
8764            // re-dispatches to NATS (AwaitHere) for the next response.
8765            for turn in 1..MAX_PARK_TURNS {
8766                let result = rt
8767                    .block_on(engine.resume(
8768                        conv_ctx(),
8769                        snapshot,
8770                        agent_response_envelope("still thinking", "final_reply"),
8771                    ))
8772                    .unwrap();
8773                snapshot = match result.status {
8774                    FlowStatus::Waiting(w) => w.snapshot,
8775                    other => {
8776                        panic!("expected Waiting (LoopHere) at response #{turn}, got {other:?}")
8777                    }
8778                };
8779                let result = rt
8780                    .block_on(engine.resume(conv_ctx(), snapshot, json!({ "text": "still here" })))
8781                    .unwrap();
8782                snapshot = match result.status {
8783                    FlowStatus::Waiting(w) => w.snapshot,
8784                    other => {
8785                        panic!(
8786                            "expected Waiting (AwaitHere) after user turn #{turn}, got {other:?}"
8787                        )
8788                    }
8789                };
8790            }
8791
8792            // The MAX_PARK_TURNS-th "not ended" response must force-advance instead
8793            // of parking again.
8794            let result = rt
8795                .block_on(engine.resume(
8796                    conv_ctx(),
8797                    snapshot,
8798                    agent_response_envelope("still thinking", "final_reply"),
8799                ))
8800                .unwrap();
8801            assert!(
8802                matches!(result.status, FlowStatus::Completed),
8803                "park-loop cap must force-advance to the successor at response {MAX_PARK_TURNS}, got {:?}",
8804                result.status
8805            );
8806            assert_eq!(
8807                dispatcher.calls.lock().unwrap().len(),
8808                1 + (MAX_PARK_TURNS as usize - 1),
8809                "exactly one NATS dispatch per user turn across the whole park-loop"
8810            );
8811        }
8812
8813        /// Parity: for the same scripted two-turn conversation (turn 1 replies
8814        /// "hello there", not ended; turn 2 replies "bye", `conversation_ended`),
8815        /// the NATS and in-process dispatch paths must be *observationally*
8816        /// identical — same sequence of user-visible statuses, and the same
8817        /// surfaced reply text on the parked turn.
8818        ///
8819        /// Caveat (documented, not hidden): the NATS path has one extra *internal*
8820        /// resume between user turns — the async response landing (AwaitHere →
8821        /// LoopHere) — that the in-process path does synchronously inside a single
8822        /// `execute`/`resume` call. That extra step is invisible to the flow's
8823        /// outward status/reply, which is exactly what this test asserts; it does
8824        /// NOT assert the two paths take the same number of `resume` calls.
8825        #[cfg(feature = "agentic-worker")]
8826        #[test]
8827        fn conversational_dw_agent_nats_and_inprocess_transcripts_match_for_same_script() {
8828            let rt = Runtime::new().unwrap();
8829
8830            // ── In-process transcript ──
8831            let inproc_handler = Arc::new(ScriptedAgentHandler {
8832                script: Mutex::new(std::collections::VecDeque::from(vec![
8833                    json!({ "reply": "hello there", "trail": [], "terminated_by": "final_reply" }),
8834                    json!({ "reply": "bye", "trail": [], "terminated_by": "conversation_ended" }),
8835                ])),
8836            });
8837            let inproc_engine = conv_engine_scripted(conversational_dw_flow(true), inproc_handler);
8838            let r1 = rt
8839                .block_on(inproc_engine.execute(conv_ctx(), Value::Null))
8840                .unwrap();
8841            let inproc_snapshot = match r1.status {
8842                FlowStatus::Waiting(ref w) => w.snapshot.clone(),
8843                ref other => panic!("in-process turn 1: expected Waiting, got {other:?}"),
8844            };
8845            let r2 = rt
8846                .block_on(inproc_engine.resume(
8847                    conv_ctx(),
8848                    inproc_snapshot,
8849                    json!({ "text": "more" }),
8850                ))
8851                .unwrap();
8852
8853            // ── NATS transcript, same script ──
8854            let dispatcher = Arc::new(ScriptedNatsDispatcher {
8855                calls: Mutex::new(vec![]),
8856            });
8857            let nats_engine = nats_conv_engine(conversational_dw_flow(true), dispatcher);
8858            let n1 = rt
8859                .block_on(nats_engine.execute(conv_ctx(), Value::Null))
8860                .unwrap();
8861            let n1_snapshot = match n1.status {
8862                FlowStatus::Waiting(w) => w.snapshot,
8863                other => panic!("nats turn 1 dispatch: expected Waiting, got {other:?}"),
8864            };
8865            let n1r = rt
8866                .block_on(nats_engine.resume(
8867                    conv_ctx(),
8868                    n1_snapshot,
8869                    agent_response_envelope("hello there", "final_reply"),
8870                ))
8871                .unwrap();
8872            let n1r_snapshot = match n1r.status {
8873                FlowStatus::Waiting(ref w) => w.snapshot.clone(),
8874                ref other => panic!("nats turn 1 response resume: expected Waiting, got {other:?}"),
8875            };
8876            let n2 = rt
8877                .block_on(nats_engine.resume(conv_ctx(), n1r_snapshot, json!({ "text": "more" })))
8878                .unwrap();
8879            let n2_snapshot = match n2.status {
8880                FlowStatus::Waiting(w) => w.snapshot,
8881                other => panic!("nats turn 2 dispatch: expected Waiting, got {other:?}"),
8882            };
8883            let n2r = rt
8884                .block_on(nats_engine.resume(
8885                    conv_ctx(),
8886                    n2_snapshot,
8887                    agent_response_envelope("bye", "conversation_ended"),
8888                ))
8889                .unwrap();
8890
8891            // Same user-visible status per turn.
8892            assert!(matches!(r1.status, FlowStatus::Waiting(_)));
8893            assert!(
8894                matches!(n1r.status, FlowStatus::Waiting(_)),
8895                "nats turn 1's user-visible status must also be Waiting"
8896            );
8897            assert!(matches!(r2.status, FlowStatus::Completed));
8898            assert!(
8899                matches!(n2r.status, FlowStatus::Completed),
8900                "nats turn 2 must also complete, matching the in-process transcript"
8901            );
8902
8903            // Same surfaced reply text on the parked turn.
8904            assert!(
8905                serde_json::to_string(&r1.output)
8906                    .unwrap()
8907                    .contains("hello there"),
8908                "in-process turn 1 must surface the reply: {:?}",
8909                r1.output
8910            );
8911            assert!(
8912                serde_json::to_string(&n1r.output)
8913                    .unwrap()
8914                    .contains("hello there"),
8915                "nats turn 1 must surface the identical reply once the response resume lands: {:?}",
8916                n1r.output
8917            );
8918        }
8919
8920        /// Build the error envelope shape a NATS response resume can also land in
8921        /// `state.entry`: `{ok:false, output:null, events:[], error:{code,
8922        /// message}}` (mirrors `agent_response_envelope`, but for the failure
8923        /// path — a genuine agent/transport error. This code sets no deadline of
8924        /// its own, but the same `{ok:false}` shape is also what a flow-authored
8925        /// timeout, or any other error source, would arrive as — Fix B handles it
8926        /// identically either way.
8927        #[cfg(feature = "agentic-worker")]
8928        fn agent_error_envelope(message: &str, code: Option<&str>) -> Value {
8929            json!({
8930                "ok": false,
8931                "output": Value::Null,
8932                "events": [],
8933                "error": { "code": code, "message": message },
8934            })
8935        }
8936
8937        /// Fix A (interleave guard): a user message arriving before the agent's
8938        /// NATS response must NOT be misread as that response. With the
8939        /// pending-await marker set (turn 1's fresh dispatch), a resume whose
8940        /// `state.entry` is a plain user-message shape (no `"ok"` key) must fall
8941        /// through to the fresh-dispatch branch — re-dispatching to NATS as a new
8942        /// turn and parking via `AwaitHere` again — instead of being consumed as
8943        /// a (null) agent reply. The marker must also survive: it was NOT
8944        /// consumed by the misrouted resume, only by the eventual real response.
8945        #[cfg(feature = "agentic-worker")]
8946        #[test]
8947        fn conversational_dw_agent_nats_interleaved_user_message_is_not_misread_as_response() {
8948            let dispatcher = Arc::new(ScriptedNatsDispatcher {
8949                calls: Mutex::new(vec![]),
8950            });
8951            let engine = nats_conv_engine(conversational_dw_flow(true), dispatcher.clone());
8952            let rt = Runtime::new().unwrap();
8953
8954            // Turn 1: fresh dispatch marks the pending-await and parks (AwaitHere).
8955            let result = rt
8956                .block_on(engine.execute(conv_ctx(), Value::Null))
8957                .unwrap();
8958            let snapshot = match result.status {
8959                FlowStatus::Waiting(w) => w.snapshot,
8960                other => panic!("expected Waiting after turn 1 dispatch, got {other:?}"),
8961            };
8962            assert!(
8963                snapshot.state.pending_agent_await.contains_key("agent"),
8964                "turn 1 dispatch must mark the pending await"
8965            );
8966            assert_eq!(dispatcher.calls.lock().unwrap().len(), 1);
8967
8968            // A stray user message arrives BEFORE the agent's NATS response —
8969            // same shape a real inbound activity would resume with, no `"ok"` key.
8970            let result = rt
8971                .block_on(engine.resume(
8972                    conv_ctx(),
8973                    snapshot,
8974                    json!({ "text": "are you still there?" }),
8975                ))
8976                .unwrap();
8977            let snapshot = match result.status {
8978                FlowStatus::Waiting(w) => w.snapshot,
8979                other => panic!(
8980                    "a stray user message must re-dispatch as a fresh turn (Waiting/AwaitHere), got {other:?}"
8981                ),
8982            };
8983            assert_eq!(
8984                snapshot.next_node, "agent",
8985                "the fresh re-dispatch still awaits at self"
8986            );
8987            assert_eq!(
8988                dispatcher.calls.lock().unwrap().len(),
8989                2,
8990                "the stray user message must trigger its OWN fresh NATS dispatch, not be swallowed"
8991            );
8992            assert!(
8993                snapshot.state.pending_agent_await.contains_key("agent"),
8994                "the marker must still be set for the real response to land against"
8995            );
8996            assert_eq!(
8997                result.output,
8998                Value::Null,
8999                "no reply is surfaced — this was not a misread null agent turn"
9000            );
9001            assert!(
9002                !snapshot.state.park_turns.contains_key("agent"),
9003                "a stray user message must not touch the park-loop cap"
9004            );
9005        }
9006
9007        /// Fix B (error envelope handling): a `{ok:false, ...}` response — any
9008        /// agent/transport error, or a timeout-shaped envelope from any source
9009        /// (this code no longer sets its own deadline) — must surface the error
9010        /// message as the reply, re-park via `LoopHere` (fail-safe: await the
9011        /// next user message, do not force-advance), and must NOT bump the
9012        /// park-loop turn counter. Exercises two full error cycles (error →
9013        /// user turn → error) to confirm the cap counter never advances even
9014        /// after repeated failures.
9015        #[cfg(feature = "agentic-worker")]
9016        #[test]
9017        fn conversational_dw_agent_nats_error_envelope_surfaces_and_reparks_without_cap_bump() {
9018            let dispatcher = Arc::new(ScriptedNatsDispatcher {
9019                calls: Mutex::new(vec![]),
9020            });
9021            let engine = nats_conv_engine(conversational_dw_flow(true), dispatcher.clone());
9022            let rt = Runtime::new().unwrap();
9023
9024            // Turn 1: fresh dispatch → AwaitHere.
9025            let result = rt
9026                .block_on(engine.execute(conv_ctx(), Value::Null))
9027                .unwrap();
9028            let snapshot = match result.status {
9029                FlowStatus::Waiting(w) => w.snapshot,
9030                other => panic!("expected Waiting after turn 1 dispatch, got {other:?}"),
9031            };
9032
9033            // A plain agent/transport error resumes the flow.
9034            let result = rt
9035                .block_on(engine.resume(conv_ctx(), snapshot, agent_error_envelope("boom", None)))
9036                .unwrap();
9037            let snapshot = match result.status {
9038                FlowStatus::Waiting(w) => w.snapshot,
9039                other => panic!("an error envelope must re-park (Waiting/LoopHere), got {other:?}"),
9040            };
9041            assert_eq!(
9042                snapshot.next_node, "agent",
9043                "LoopHere re-enters the node itself"
9044            );
9045            assert!(
9046                serde_json::to_string(&result.output)
9047                    .unwrap()
9048                    .contains("boom"),
9049                "the error message must be surfaced as the reply: {:?}",
9050                result.output
9051            );
9052            assert!(
9053                !snapshot.state.park_turns.contains_key("agent"),
9054                "an error response must NOT bump the park-loop cap counter"
9055            );
9056
9057            // A user turn in between re-dispatches (as usual).
9058            let result = rt
9059                .block_on(engine.resume(conv_ctx(), snapshot, json!({ "text": "hello?" })))
9060                .unwrap();
9061            let snapshot = match result.status {
9062                FlowStatus::Waiting(w) => w.snapshot,
9063                other => panic!("expected Waiting (AwaitHere) after user turn, got {other:?}"),
9064            };
9065            assert_eq!(dispatcher.calls.lock().unwrap().len(), 2);
9066
9067            // A timeout-coded envelope (this code sets no deadline of its own —
9068            // this shape would only arrive from a flow-authored deadline or some
9069            // other upstream source) behaves identically to a plain error.
9070            let result = rt
9071                .block_on(engine.resume(
9072                    conv_ctx(),
9073                    snapshot,
9074                    agent_error_envelope("timeout waiting for agent response", Some("timeout")),
9075                ))
9076                .unwrap();
9077            let snapshot = match result.status {
9078                FlowStatus::Waiting(w) => w.snapshot,
9079                other => {
9080                    panic!("a timeout envelope must also re-park (Waiting/LoopHere), got {other:?}")
9081                }
9082            };
9083            assert!(
9084                serde_json::to_string(&result.output)
9085                    .unwrap()
9086                    .contains("timeout waiting for agent response"),
9087                "the timeout message must be surfaced as the reply: {:?}",
9088                result.output
9089            );
9090            assert!(
9091                !snapshot.state.park_turns.contains_key("agent"),
9092                "two error/timeout responses in a row (with an intervening user turn) must still \
9093                 not have bumped the park-loop cap counter"
9094            );
9095        }
9096
9097        /// Scriptable `AgentNodeHandler` stub: returns the next queued payload on
9098        /// each call, so a single in-process engine can simulate a multi-turn
9099        /// conversation with a different agent output per turn (unlike
9100        /// `StubAgentHandler`, which always returns the same fixed payload).
9101        #[cfg(feature = "agentic-worker")]
9102        struct ScriptedAgentHandler {
9103            script: Mutex<std::collections::VecDeque<serde_json::Value>>,
9104        }
9105        #[cfg(feature = "agentic-worker")]
9106        #[async_trait::async_trait]
9107        impl crate::runner::agent_node::AgentNodeHandler for ScriptedAgentHandler {
9108            async fn execute(
9109                &self,
9110                _tenant_id: &str,
9111                _env_id: &str,
9112                _agent_id: &str,
9113                _session_id: &str,
9114                _flow_input: &serde_json::Value,
9115                _conversational: bool,
9116            ) -> anyhow::Result<serde_json::Value> {
9117                Ok(self
9118                    .script
9119                    .lock()
9120                    .unwrap()
9121                    .pop_front()
9122                    .expect("ScriptedAgentHandler: script exhausted"))
9123            }
9124        }
9125
9126        /// Build an in-process engine holding `flow`, wired to a `ScriptedAgentHandler`
9127        /// so each agent turn can return a different payload. Mirrors `conv_engine`
9128        /// (which uses a fixed payload for every call).
9129        #[cfg(feature = "agentic-worker")]
9130        fn conv_engine_scripted(
9131            flow: HostFlow,
9132            handler: std::sync::Arc<ScriptedAgentHandler>,
9133        ) -> FlowEngine {
9134            FlowEngine {
9135                rollout_ids: RolloutIds::default(),
9136                packs: Vec::new(),
9137                flows: Vec::new(),
9138                flow_sources: StdHashMap::new(),
9139                flow_cache: RwLock::new(StdHashMap::from([(
9140                    FlowKey {
9141                        pack_id: "test-pack".to_string(),
9142                        flow_id: "conv.flow".to_string(),
9143                    },
9144                    flow,
9145                )])),
9146                default_env: "local".to_string(),
9147                validation: ValidationConfig {
9148                    mode: ValidationMode::Off,
9149                },
9150                cross_pack_resolver: None,
9151                remote_dispatch_handler: None,
9152                dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
9153                agent_node_handler: Some(handler),
9154                graph_node_handler: None,
9155                mcp_tool_source: None,
9156            }
9157        }
9158    }
9159
9160    #[test]
9161    fn submitted_fields_reads_root_inputs_on_the_demo_path() {
9162        // Run Demo puts input ids at the entry ROOT beside `metadata`
9163        // (greentic-designer flow_demo/host.rs::build_submit_payload).
9164        let entry = json!({
9165            "amount": "500",
9166            "reason": "looks good",
9167            "metadata": { "action": "approve" }
9168        });
9169        let fields = submitted_fields(&entry);
9170        assert_eq!(fields.get("amount"), Some(&json!("500")));
9171        assert_eq!(fields.get("reason"), Some(&json!("looks good")));
9172        assert!(
9173            !fields.contains_key("metadata"),
9174            "the envelope key is not a field"
9175        );
9176        assert!(
9177            !fields.contains_key("action"),
9178            "the route discriminator is not a field"
9179        );
9180    }
9181
9182    #[test]
9183    fn submitted_fields_reads_metadata_on_the_wrapped_path() {
9184        // greentic-start wraps the activity: entry.input.metadata.*
9185        let entry = json!({
9186            "input": {
9187                "metadata": { "action": "approve", "email": "a@b.c" },
9188                "text": "hi"
9189            }
9190        });
9191        let fields = submitted_fields(&entry);
9192        assert_eq!(fields.get("email"), Some(&json!("a@b.c")));
9193        assert!(!fields.contains_key("action"));
9194        assert!(!fields.contains_key("text"), "envelope text is not a field");
9195        assert!(
9196            !fields.contains_key("input"),
9197            "the envelope root must be resolved, or `input` becomes one giant field"
9198        );
9199    }
9200
9201    #[test]
9202    fn submitted_fields_lets_the_envelope_root_win_a_collision() {
9203        let entry = json!({
9204            "email": "root@x",
9205            "metadata": { "action": "go", "email": "meta@x" }
9206        });
9207        assert_eq!(
9208            submitted_fields(&entry).get("email"),
9209            Some(&json!("root@x"))
9210        );
9211    }
9212
9213    #[test]
9214    fn submitted_fields_counts_a_root_input_named_action() {
9215        // `action` is the route discriminator ONLY in metadata. At the root it is
9216        // a real keystroke on the demo path.
9217        let entry = json!({ "action": "typed", "metadata": { "action": "approve" } });
9218        assert_eq!(
9219            submitted_fields(&entry).get("action"),
9220            Some(&json!("typed"))
9221        );
9222    }
9223
9224    #[test]
9225    fn submitted_fields_is_empty_for_a_button_with_no_inputs() {
9226        let entry = json!({ "metadata": { "action": "approve" } });
9227        assert!(submitted_fields(&entry).is_empty());
9228    }
9229
9230    /// A card node with no declared `answer_fields` — i.e. a pack built before
9231    /// this feature existed. `attach_pending_card_answers` must treat this as
9232    /// "no allow-list" (today's permissive behaviour), not "zero fields".
9233    fn plain_card_node() -> HostNode {
9234        HostNode {
9235            kind: NodeKind::Exec {
9236                target_component: "card".to_string(),
9237            },
9238            component: "component.exec".to_string(),
9239            component_id: "component.exec".to_string(),
9240            operation_name: None,
9241            operation_in_mapping: None,
9242            payload_expr: Value::Null,
9243            routing: Routing::End,
9244            vars_out: None,
9245        }
9246    }
9247
9248    #[test]
9249    fn an_old_snapshot_without_the_flag_deserializes_as_not_awaiting_submit() {
9250        // Snapshots persisted before this field existed must keep their exact
9251        // current behaviour: no answers attached.
9252        let raw = json!({
9253            "pack_id": "p",
9254            "flow_id": "f",
9255            "next_node": "card",
9256            "state": {}
9257        });
9258        let snap: FlowSnapshot = serde_json::from_value(raw).expect("legacy snapshot decodes");
9259        assert!(!snap.awaiting_submit);
9260    }
9261
9262    /// A resumed node's submitted fields must be readable from its output by a
9263    /// LATER node, through the ordinary `{{node.<id>.<field>}}` grammar.
9264    ///
9265    /// This is the whole feature, and it is also the ordering proof: the resume
9266    /// RE-DISPATCHES the parked node and `state.nodes.insert` replaces its stored
9267    /// output, so an implementation that attaches the answers before the dispatch
9268    /// makes this test fail. Do not "simplify" the merge earlier.
9269    #[test]
9270    fn submitted_answers_survive_the_resume_redispatch_and_reach_a_later_node() {
9271        let mut state = ExecutionState::new(json!({}));
9272        // The parked node has already run once; its stored output is what the
9273        // resume dispatch will REPLACE.
9274        state.nodes.insert(
9275            "card".to_string(),
9276            NodeOutput::new(json!({ "event": "rendered" })),
9277        );
9278        state.pending_card_answers = Some(PendingCardAnswers {
9279            node_id: "card".to_string(),
9280            answers: submitted_fields(&json!({
9281                "email": "a@b.c",
9282                "metadata": { "action": "submit" }
9283            })),
9284        });
9285
9286        // Simulate the re-dispatch: a FRESH output object, exactly as the loop
9287        // builds one, then the merge at its real call position.
9288        let mut fresh = NodeOutput::new(json!({ "event": "rendered" }));
9289        attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut fresh);
9290        state.nodes.insert("card".to_string(), fresh);
9291
9292        // Read it the way a downstream node's config would.
9293        let ctx = template_context(&state, Value::Null);
9294        let rendered = render_template_value(
9295            &json!("{{node.card.answers.email}}"),
9296            &ctx,
9297            TemplateOptions::default(),
9298        )
9299        .expect("render");
9300        assert_eq!(rendered, json!("a@b.c"));
9301    }
9302
9303    #[test]
9304    fn the_card_render_context_carries_the_answers_too() {
9305        // One write, two read surfaces: `outputs_map()` feeds {{node.…}} and
9306        // `context()` feeds the `state` handed to the adaptive-card component.
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!({ "email": "a@b.c" })),
9311        });
9312        let mut output = NodeOutput::new(json!({ "event": "rendered" }));
9313        attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut output);
9314        state.nodes.insert("card".to_string(), output);
9315
9316        let ctx = state.context();
9317        assert_eq!(
9318            ctx["nodes"]["card"]["payload"]["answers"]["email"],
9319            json!("a@b.c")
9320        );
9321    }
9322
9323    #[test]
9324    fn answers_are_absent_before_any_submit() {
9325        // Absent is not empty: absent means never submitted, {} means submitted
9326        // with no fields. Do not collapse the two.
9327        let mut state = ExecutionState::new(json!({}));
9328        let mut output = NodeOutput::new(json!({ "event": "rendered" }));
9329        attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut output);
9330        assert!(output.payload.get("answers").is_none());
9331    }
9332
9333    #[test]
9334    fn a_button_with_no_inputs_yields_an_empty_answers_object() {
9335        let mut state = ExecutionState::new(json!({}));
9336        state.pending_card_answers = Some(PendingCardAnswers {
9337            node_id: "card".to_string(),
9338            answers: submitted_fields(&json!({ "metadata": { "action": "approve" } })),
9339        });
9340        let mut output = NodeOutput::new(json!({ "event": "rendered" }));
9341        attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut output);
9342        assert_eq!(output.payload["answers"], json!({}));
9343    }
9344
9345    #[test]
9346    fn the_pending_answers_are_consumed_exactly_once() {
9347        // A loop back through the same node without a resume must not re-attach
9348        // stale answers.
9349        let mut state = ExecutionState::new(json!({}));
9350        state.pending_card_answers = Some(PendingCardAnswers {
9351            node_id: "card".to_string(),
9352            answers: submitted_fields(&json!({ "email": "a@b.c" })),
9353        });
9354        let mut first = NodeOutput::new(json!({ "event": "rendered" }));
9355        attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut first);
9356        assert!(first.payload.get("answers").is_some());
9357
9358        let mut second = NodeOutput::new(json!({ "event": "rendered" }));
9359        attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut second);
9360        assert!(
9361            second.payload.get("answers").is_none(),
9362            "consumed, not read"
9363        );
9364    }
9365
9366    #[test]
9367    fn answers_attach_even_when_the_redispatch_failed() {
9368        // The answers are real regardless of whether the re-render succeeded.
9369        // Dropping them would let a transient render error destroy what someone
9370        // typed.
9371        let mut state = ExecutionState::new(json!({}));
9372        state.pending_card_answers = Some(PendingCardAnswers {
9373            node_id: "card".to_string(),
9374            answers: submitted_fields(&json!({ "email": "a@b.c" })),
9375        });
9376        let mut output = NodeOutput::new(json!({ "ok": false, "error": { "code": "boom" } }));
9377        attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut output);
9378        assert_eq!(output.payload["answers"]["email"], json!("a@b.c"));
9379    }
9380
9381    #[test]
9382    fn answers_are_not_attached_to_a_different_node() {
9383        let mut state = ExecutionState::new(json!({}));
9384        state.pending_card_answers = Some(PendingCardAnswers {
9385            node_id: "card".to_string(),
9386            answers: submitted_fields(&json!({ "email": "a@b.c" })),
9387        });
9388        let mut other = NodeOutput::new(json!({ "event": "rendered" }));
9389        attach_pending_card_answers(&mut state, "next_step", &plain_card_node(), &mut other);
9390        assert!(other.payload.get("answers").is_none());
9391        assert!(
9392            state.pending_card_answers.is_some(),
9393            "still pending for its own node"
9394        );
9395    }
9396
9397    /// THE defect this feature closes. On the `greentic-start` path
9398    /// `entry.input` is a whole `ChannelMessageEnvelope` (see
9399    /// `greentic-types::messaging`), not a flat map of input ids — so without
9400    /// an allow-list, `submitted_fields` unions transport identity
9401    /// (`tenant`, `session_id`, `from`, `attachments`, ...), routing keys
9402    /// (`nextCardId`, `route`), channel keys (`env`, `team`, `locale`,
9403    /// `autoStart`), and greentic-start's own injected pack setup answers
9404    /// (`url`, `model`, `provider`, `api_key_secret` — a `secrets://...`
9405    /// reference) into `answers`, under a key documented as "the fields
9406    /// someone typed into this card". With a declared `answer_fields`
9407    /// allow-list, none of that must survive — only the two genuine card
9408    /// inputs.
9409    #[test]
9410    fn answers_intersect_the_declared_allow_list_on_a_wrapped_start_envelope() {
9411        let entry = json!({
9412            "input": {
9413                "id": "msg-1",
9414                "tenant": "acme",
9415                "channel": "webchat",
9416                "session_id": "sess-1",
9417                "from": "user-1",
9418                "to": "bot-1",
9419                "correlation_id": "corr-1",
9420                "attachments": [],
9421                "metadata": {
9422                    "action": "submit",
9423                    "nextCardId": "confirm",
9424                    "route": "default",
9425                    "env": "prod",
9426                    "team": "support",
9427                    "locale": "en-US",
9428                    "autoStart": true,
9429                    "url": "https://api.example.com",
9430                    "model": "gpt-4",
9431                    "provider": "openai",
9432                    "api_key_secret": "secrets://tenant/acme/openai_key",
9433                    "full_name": "Ada Lovelace",
9434                    "email": "ada@example.com"
9435                },
9436                "text": "submitted"
9437            }
9438        });
9439
9440        let mut state = ExecutionState::new(json!({}));
9441        state.pending_card_answers = Some(PendingCardAnswers {
9442            node_id: "card".to_string(),
9443            answers: submitted_fields(&entry),
9444        });
9445
9446        let node = HostNode {
9447            payload_expr: json!({ "answer_fields": ["full_name", "email"] }),
9448            ..plain_card_node()
9449        };
9450        let mut output = NodeOutput::new(json!({ "event": "rendered" }));
9451        attach_pending_card_answers(&mut state, "card", &node, &mut output);
9452
9453        let answers = output.payload["answers"]
9454            .as_object()
9455            .expect("answers must be an object");
9456        assert_eq!(
9457            answers,
9458            &serde_json::Map::from_iter([
9459                ("full_name".to_string(), json!("Ada Lovelace")),
9460                ("email".to_string(), json!("ada@example.com")),
9461            ]),
9462            "answers must contain exactly the two declared card inputs, and \
9463             nothing from transport identity, routing, or pack config: {answers:?}"
9464        );
9465    }
9466
9467    #[test]
9468    fn answer_fields_absent_keeps_todays_permissive_behaviour() {
9469        // Pre-upgrade packs, and any path that never runs the designer
9470        // injector, must keep exactly today's behaviour: no allow-list means
9471        // no filtering, even on a wrapped envelope carrying non-input keys.
9472        let entry = json!({
9473            "input": {
9474                "tenant": "acme",
9475                "metadata": { "action": "submit", "email": "a@b.c" },
9476                "text": "hi"
9477            }
9478        });
9479        let mut state = ExecutionState::new(json!({}));
9480        state.pending_card_answers = Some(PendingCardAnswers {
9481            node_id: "card".to_string(),
9482            answers: submitted_fields(&entry),
9483        });
9484        let mut output = NodeOutput::new(json!({ "event": "rendered" }));
9485        attach_pending_card_answers(&mut state, "card", &plain_card_node(), &mut output);
9486
9487        assert_eq!(output.payload["answers"]["tenant"], json!("acme"));
9488        assert_eq!(output.payload["answers"]["email"], json!("a@b.c"));
9489    }
9490
9491    #[test]
9492    fn answer_fields_declared_empty_yields_no_answers() {
9493        // A card that genuinely declares zero inputs must produce `{}` — an
9494        // empty allow-list is not the same as an absent one, and must not be
9495        // collapsed into the unfiltered set.
9496        let mut state = ExecutionState::new(json!({}));
9497        state.pending_card_answers = Some(PendingCardAnswers {
9498            node_id: "card".to_string(),
9499            answers: submitted_fields(&json!({
9500                "email": "a@b.c",
9501                "metadata": { "action": "approve" }
9502            })),
9503        });
9504        let node = HostNode {
9505            payload_expr: json!({ "answer_fields": [] }),
9506            ..plain_card_node()
9507        };
9508        let mut output = NodeOutput::new(json!({ "event": "rendered" }));
9509        attach_pending_card_answers(&mut state, "card", &node, &mut output);
9510
9511        assert_eq!(output.payload["answers"], json!({}));
9512    }
9513
9514    /// The three-way distinction `declared_answer_fields` exists to preserve:
9515    /// absent (ordinary pre-upgrade pack) and malformed (a designer-side bug)
9516    /// both fall back to the SAME permissive, unfiltered `answers` — that part
9517    /// is asserted here — but only the malformed case is meant to be logged
9518    /// (`tracing::warn!` in `attach_pending_card_answers`). This test can only
9519    /// assert the behavioural half: nothing in this crate's dev-dependencies
9520    /// captures `tracing` output (no `tracing-test`/subscriber-capture
9521    /// harness is wired up here), and adding one purely to assert a log line
9522    /// felt like more machinery than the assertion warrants. The `Malformed`
9523    /// arm's `tracing::warn!` call is exercised (not just present in source)
9524    /// by the "malformed" case below, since the same match arm both logs and
9525    /// returns the unfiltered map — a change that broke or removed the log
9526    /// call, if it also broke the fallback, would fail this test.
9527    #[test]
9528    fn absent_and_malformed_answer_fields_both_stay_permissive_but_declared_filters() {
9529        let entry = json!({ "email": "a@b.c", "phone": "555-1234" });
9530
9531        // Absent: no `answer_fields` key at all.
9532        let mut absent_state = ExecutionState::new(json!({}));
9533        absent_state.pending_card_answers = Some(PendingCardAnswers {
9534            node_id: "card".to_string(),
9535            answers: submitted_fields(&entry),
9536        });
9537        let mut absent_output = NodeOutput::new(json!({ "event": "rendered" }));
9538        attach_pending_card_answers(
9539            &mut absent_state,
9540            "card",
9541            &plain_card_node(),
9542            &mut absent_output,
9543        );
9544        assert_eq!(
9545            absent_output.payload["answers"],
9546            json!({ "email": "a@b.c", "phone": "555-1234" }),
9547            "absent must stay fully permissive"
9548        );
9549
9550        // Malformed: the key is present but is not an array of strings (e.g.
9551        // an unresolved template, or a wrong-typed value from a designer bug).
9552        let mut malformed_state = ExecutionState::new(json!({}));
9553        malformed_state.pending_card_answers = Some(PendingCardAnswers {
9554            node_id: "card".to_string(),
9555            answers: submitted_fields(&entry),
9556        });
9557        let malformed_node = HostNode {
9558            payload_expr: json!({ "answer_fields": "{{unresolved.template}}" }),
9559            ..plain_card_node()
9560        };
9561        let mut malformed_output = NodeOutput::new(json!({ "event": "rendered" }));
9562        attach_pending_card_answers(
9563            &mut malformed_state,
9564            "card",
9565            &malformed_node,
9566            &mut malformed_output,
9567        );
9568        assert_eq!(
9569            malformed_output.payload["answers"],
9570            json!({ "email": "a@b.c", "phone": "555-1234" }),
9571            "malformed must ALSO stay fully permissive, same as absent"
9572        );
9573
9574        // Declared: a valid, non-empty allow-list actually filters.
9575        let mut declared_state = ExecutionState::new(json!({}));
9576        declared_state.pending_card_answers = Some(PendingCardAnswers {
9577            node_id: "card".to_string(),
9578            answers: submitted_fields(&entry),
9579        });
9580        let declared_node = HostNode {
9581            payload_expr: json!({ "answer_fields": ["email"] }),
9582            ..plain_card_node()
9583        };
9584        let mut declared_output = NodeOutput::new(json!({ "event": "rendered" }));
9585        attach_pending_card_answers(
9586            &mut declared_state,
9587            "card",
9588            &declared_node,
9589            &mut declared_output,
9590        );
9591        assert_eq!(
9592            declared_output.payload["answers"],
9593            json!({ "email": "a@b.c" }),
9594            "declared must filter down to exactly the allow-listed keys"
9595        );
9596    }
9597
9598    #[test]
9599    fn pending_from_snapshot_names_next_node_when_awaiting_submit() {
9600        let snapshot = FlowSnapshot {
9601            pack_id: "p".to_string(),
9602            flow_id: "f".to_string(),
9603            next_flow: None,
9604            next_node: "card".to_string(),
9605            awaiting_submit: true,
9606            state: ExecutionState::new(json!({})),
9607        };
9608        let input = json!({ "email": "a@b.c", "metadata": { "action": "submit" } });
9609        let pending = pending_from_snapshot(&snapshot, &input).expect("should park answers");
9610        assert_eq!(pending.node_id, "card");
9611        assert_eq!(pending.answers.get("email"), Some(&json!("a@b.c")));
9612    }
9613
9614    #[test]
9615    fn pending_from_snapshot_is_none_when_not_awaiting_submit() {
9616        let snapshot = FlowSnapshot {
9617            pack_id: "p".to_string(),
9618            flow_id: "f".to_string(),
9619            next_flow: None,
9620            next_node: "successor".to_string(),
9621            awaiting_submit: false,
9622            state: ExecutionState::new(json!({})),
9623        };
9624        let input = json!({ "email": "a@b.c" });
9625        assert!(pending_from_snapshot(&snapshot, &input).is_none());
9626    }
9627
9628    /// Build a two-node flow: `card` (`Routing::Custom`, parks until
9629    /// `response.action == "submit"`) -> `next` (reads
9630    /// `{{node.card.answers.email}}`). `card`'s first pass has no
9631    /// `response.action`, so its conditional routing falls through and it
9632    /// parks with `awaiting_submit: true`.
9633    /// Two chained cards that route on the SAME action, then a terminal node.
9634    ///
9635    /// This is the shape every designer card journey has: each page's Continue
9636    /// button submits `action = "continue"`, and each page's routing tests for
9637    /// it.
9638    fn two_card_chain_flow() -> Flow {
9639        let card_node = |id: &str, to: Option<&str>| Node {
9640            id: NodeId::from_str(id).unwrap(),
9641            component: FlowComponentRef {
9642                id: "emit.log".parse().unwrap(),
9643                pack_alias: None,
9644                operation: None,
9645            },
9646            input: InputMapping {
9647                mapping: json!({ "card": id }),
9648            },
9649            output: OutputMapping {
9650                mapping: Value::Null,
9651            },
9652            err_map: None,
9653            routing: match to {
9654                Some(target) => Routing::Custom(json!([
9655                    { "condition": "response.action == \"submit\"", "to": target }
9656                ])),
9657                None => Routing::End,
9658            },
9659            telemetry: TelemetryHints::default(),
9660            conversational: false,
9661        };
9662
9663        let mut nodes = indexmap::IndexMap::default();
9664        for (id, to) in [
9665            ("card1", Some("card2")),
9666            ("card2", Some("card3")),
9667            ("card3", None),
9668        ] {
9669            nodes.insert(NodeId::from_str(id).unwrap(), card_node(id, to));
9670        }
9671
9672        Flow {
9673            schema_version: "1.0".into(),
9674            id: FlowId::from_str("two.card.flow").unwrap(),
9675            kind: FlowKind::Messaging,
9676            entrypoints: BTreeMap::from([(
9677                "default".to_string(),
9678                Value::String("card1".to_string()),
9679            )]),
9680            nodes,
9681            metadata: FlowMetadata {
9682                title: None,
9683                description: None,
9684                tags: Default::default(),
9685                extra: json!({}),
9686            },
9687        }
9688    }
9689
9690    /// One submit must advance the journey by exactly ONE card.
9691    ///
9692    /// `response.*` is synthesised from the run's entry envelope, so it is
9693    /// run-scoped: without consuming it, the action that moved `card1` matches
9694    /// again at `card2` and the run walks straight past it. Measured on the
9695    /// meridian quote journey: page 1's Continue landed the user on page 3.
9696    ///
9697    /// The submit belongs to the node it was delivered to. Once that node has
9698    /// routed on it, later nodes in the same run must see a fresh card with no
9699    /// pending action, park, and wait for the user.
9700    #[test]
9701    fn one_submit_advances_exactly_one_card() {
9702        let host_flow = HostFlow::from(two_card_chain_flow());
9703        let (flow_id, pack_id) = ("two.card.flow", "test-pack");
9704        let engine = FlowEngine {
9705            rollout_ids: RolloutIds::default(),
9706            packs: Vec::new(),
9707            flows: Vec::new(),
9708            flow_sources: StdHashMap::new(),
9709            messaging_provider_pack_ids: std::collections::HashSet::new(),
9710            flow_cache: RwLock::new(StdHashMap::from([(
9711                FlowKey {
9712                    pack_id: pack_id.to_string(),
9713                    flow_id: flow_id.to_string(),
9714                },
9715                host_flow,
9716            )])),
9717            default_env: "local".to_string(),
9718            validation: ValidationConfig {
9719                mode: ValidationMode::Off,
9720            },
9721            cross_pack_resolver: None,
9722            remote_dispatch_handler: None,
9723            #[cfg(feature = "agentic-worker")]
9724            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
9725            #[cfg(feature = "agentic-worker")]
9726            agent_node_handler: None,
9727            #[cfg(feature = "agentic-worker")]
9728            graph_node_handler: None,
9729            #[cfg(feature = "agentic-worker")]
9730            mcp_tool_source: None,
9731        };
9732        let rt = Runtime::new().unwrap();
9733        let ctx = || 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
9752        // Turn 1: no action yet, so `card1` falls through and parks.
9753        let first = rt.block_on(engine.execute(ctx(), Value::Null)).unwrap();
9754        let snapshot = match first.status {
9755            FlowStatus::Waiting(w) => w.snapshot,
9756            other => panic!("expected Waiting at card1, got {other:?}"),
9757        };
9758        assert_eq!(snapshot.next_node, "card1");
9759
9760        // Turn 2: ONE submit. `card1` routes on it; `card2` must not.
9761        let submit = json!({ "input": { "metadata": { "action": "submit" } } });
9762        let second = rt.block_on(engine.resume(ctx(), snapshot, submit)).unwrap();
9763        match second.status {
9764            FlowStatus::Waiting(w) => assert_eq!(
9765                w.snapshot.next_node, "card2",
9766                "one submit must advance exactly one card and park at the next"
9767            ),
9768            FlowStatus::Completed => panic!(
9769                "the run reached the terminal node: the consumed action re-fired \
9770                 at card2 and skipped it"
9771            ),
9772        }
9773    }
9774
9775    fn card_answers_flow() -> Flow {
9776        let card_id = NodeId::from_str("card").unwrap();
9777        let next_id = NodeId::from_str("next").unwrap();
9778
9779        let card_node = Node {
9780            id: card_id.clone(),
9781            component: FlowComponentRef {
9782                id: "emit.log".parse().unwrap(),
9783                pack_alias: None,
9784                operation: None,
9785            },
9786            input: InputMapping {
9787                mapping: json!({ "event": "rendered" }),
9788            },
9789            output: OutputMapping {
9790                mapping: Value::Null,
9791            },
9792            err_map: None,
9793            routing: Routing::Custom(json!([
9794                { "condition": "response.action == \"submit\"", "to": next_id.to_string() }
9795            ])),
9796            telemetry: TelemetryHints::default(),
9797            conversational: false,
9798        };
9799
9800        let next_node = Node {
9801            id: next_id.clone(),
9802            component: FlowComponentRef {
9803                id: "emit.response".parse().unwrap(),
9804                pack_alias: None,
9805                operation: None,
9806            },
9807            input: InputMapping {
9808                mapping: json!({ "text": "{{node.card.answers.email}}" }),
9809            },
9810            output: OutputMapping {
9811                mapping: Value::Null,
9812            },
9813            err_map: None,
9814            routing: Routing::End,
9815            telemetry: TelemetryHints::default(),
9816            conversational: false,
9817        };
9818
9819        let mut nodes = indexmap::IndexMap::default();
9820        nodes.insert(card_id.clone(), card_node);
9821        nodes.insert(next_id.clone(), next_node);
9822
9823        Flow {
9824            schema_version: "1.0".into(),
9825            id: FlowId::from_str("card.answers.flow").unwrap(),
9826            kind: FlowKind::Messaging,
9827            entrypoints: BTreeMap::from([(
9828                "default".to_string(),
9829                Value::String(card_id.to_string()),
9830            )]),
9831            nodes,
9832            metadata: FlowMetadata {
9833                title: None,
9834                description: None,
9835                tags: Default::default(),
9836                extra: json!({}),
9837            },
9838        }
9839    }
9840
9841    /// The covering test for both findings from Task 3 code review: this
9842    /// drives the REAL `FlowEngine::resume` -> `drive_flow` -> dispatch-loop
9843    /// path, not a hand-simulated `ExecutionState`. It pins BOTH the `resume`
9844    /// wiring (`state.pending_card_answers = pending_card_answers;`, engine.rs
9845    /// near `resume`) AND the merge position immediately before
9846    /// `state.nodes.insert` in the dispatch loop: deleting or moving either
9847    /// makes this test fail, unlike the hand-simulated
9848    /// `submitted_answers_survive_the_resume_redispatch_and_reach_a_later_node`,
9849    /// whose ordering sensitivity is a property of its own three
9850    /// hand-written statements rather than of the engine.
9851    #[test]
9852    fn card_answers_survive_a_real_park_and_resume_and_reach_a_later_node() {
9853        let flow = card_answers_flow();
9854        let host_flow = HostFlow::from(flow);
9855        let flow_id = "card.answers.flow";
9856        let pack_id = "test-pack";
9857        let engine = FlowEngine {
9858            rollout_ids: RolloutIds::default(),
9859            packs: Vec::new(),
9860            flows: Vec::new(),
9861            flow_sources: StdHashMap::new(),
9862            messaging_provider_pack_ids: std::collections::HashSet::new(),
9863            flow_cache: RwLock::new(StdHashMap::from([(
9864                FlowKey {
9865                    pack_id: pack_id.to_string(),
9866                    flow_id: flow_id.to_string(),
9867                },
9868                host_flow,
9869            )])),
9870            default_env: "local".to_string(),
9871            validation: ValidationConfig {
9872                mode: ValidationMode::Off,
9873            },
9874            cross_pack_resolver: None,
9875            remote_dispatch_handler: None,
9876            #[cfg(feature = "agentic-worker")]
9877            dw_agent_dispatch: crate::runner::agent_node::DwAgentDispatch::InProcess,
9878            #[cfg(feature = "agentic-worker")]
9879            agent_node_handler: None,
9880            #[cfg(feature = "agentic-worker")]
9881            graph_node_handler: None,
9882            #[cfg(feature = "agentic-worker")]
9883            mcp_tool_source: None,
9884        };
9885        let rt = Runtime::new().unwrap();
9886
9887        let ctx1 = FlowContext {
9888            tenant: "demo",
9889            pack_id,
9890            flow_id,
9891            node_id: None,
9892            tool: None,
9893            action: None,
9894            session_id: None,
9895            provider_id: None,
9896            reply_scope: None,
9897            retry_config: RetryConfig {
9898                max_attempts: 1,
9899                base_delay_ms: 1,
9900            },
9901            attempt: 1,
9902            observer: None,
9903            mocks: None,
9904        };
9905        // First turn: no `response.action` yet, so `card`'s conditional
9906        // routing falls through and it parks awaiting the submit.
9907        let result1 = rt.block_on(engine.execute(ctx1, Value::Null)).unwrap();
9908        let snapshot = match result1.status {
9909            FlowStatus::Waiting(w) => w.snapshot,
9910            other => panic!("expected Waiting at the card node, got {other:?}"),
9911        };
9912        assert!(
9913            snapshot.awaiting_submit,
9914            "the card's conditional fall-through must set awaiting_submit"
9915        );
9916        assert_eq!(snapshot.next_node, "card");
9917
9918        // Resume with the submitted fields. `resume` must park them, the
9919        // dispatch loop must re-run `card`, and `attach_pending_card_answers`
9920        // must merge them into its FRESH re-dispatched output before `next`
9921        // runs.
9922        let ctx2 = FlowContext {
9923            tenant: "demo",
9924            pack_id,
9925            flow_id,
9926            node_id: None,
9927            tool: None,
9928            action: None,
9929            session_id: None,
9930            provider_id: None,
9931            reply_scope: None,
9932            retry_config: RetryConfig {
9933                max_attempts: 1,
9934                base_delay_ms: 1,
9935            },
9936            attempt: 1,
9937            observer: None,
9938            mocks: None,
9939        };
9940        let input = json!({ "email": "a@b.c", "metadata": { "action": "submit" } });
9941        let result2 = rt.block_on(engine.resume(ctx2, snapshot, input)).unwrap();
9942        assert!(
9943            matches!(result2.status, FlowStatus::Completed),
9944            "the submit must route past the card to `next`"
9945        );
9946        // This lane has no `FlowExecution::node_outputs` to inspect, so the
9947        // later node emits the value as a response and we assert on the flow
9948        // output. Same claim: `{{node.card.answers.email}}` resolved, which it
9949        // can only do if the resumed card attached the submitted answers.
9950        let rendered = serde_json::to_string(&result2.output).expect("encode output");
9951        assert!(
9952            rendered.contains("a@b.c"),
9953            "`next`, a LATER node, must read the answers through \
9954             {{node.card.answers.email}}; got {rendered}"
9955        );
9956    }
9957}
9958
9959use tracing::Instrument;
9960
9961pub struct FlowContext<'a> {
9962    pub tenant: &'a str,
9963    pub pack_id: &'a str,
9964    pub flow_id: &'a str,
9965    pub node_id: Option<&'a str>,
9966    pub tool: Option<&'a str>,
9967    pub action: Option<&'a str>,
9968    pub session_id: Option<&'a str>,
9969    pub provider_id: Option<&'a str>,
9970    /// Reply scope of the originating inbound activity, when known.
9971    ///
9972    /// Carried so async-dispatch nodes (`sorla.call await`) can encode the
9973    /// inbound `thread`/`reply_to` into the published correlation id. Without
9974    /// it, a wait saved against a threaded scope cannot be re-keyed on resume
9975    /// (the resumer would synthesize an empty thread/reply_to and miss the
9976    /// saved wait). See `execute_sorla_call` and `RuntimeSessionResumer`.
9977    pub reply_scope: Option<&'a greentic_types::ReplyScope>,
9978    pub retry_config: RetryConfig,
9979    pub attempt: u32,
9980    pub observer: Option<&'a dyn ExecutionObserver>,
9981    pub mocks: Option<&'a MockLayer>,
9982}
9983
9984#[derive(Copy, Clone)]
9985pub struct RetryConfig {
9986    pub max_attempts: u32,
9987    pub base_delay_ms: u64,
9988}
9989
9990/// Look across all node outputs, find the first one that finished with
9991/// `ok=false`, and lift its `meta.error` fields into
9992/// `output.metadata.error_kind` / `.error_message` / `.node_id`. Returns the
9993/// (possibly enriched) output unchanged otherwise.
9994///
9995/// This is how the executor "shows" an unhandled flow-node failure to the
9996/// caller without the flow author having to add error routing: the chat-side
9997/// provider (messaging-providers `extract_error_envelope`) picks the lifted
9998/// fields off `output.metadata` and renders a styled error card.
9999///
10000/// Takes a borrow of the node-output map rather than the whole
10001/// `ExecutionState` because the callers have already consumed `state` via
10002/// `state.finalize_with(...)`; we capture a cheap clone of `state.nodes` up
10003/// front and pass it in here.
10004/// Build an MCP node's output, marking it failed when the tool did not run.
10005///
10006/// `mcp_node::invoke` is infallible by contract: a runner without MCP
10007/// credentials, a tool missing from the tenant catalog, and a dead endpoint all
10008/// arrive as `{"error": ...}` inside `result`. Reporting `ok: true` for those
10009/// left `lift_first_node_error_from_nodes` with nothing to find, so the flow
10010/// completed clean — a Digital Worker run showed every node green and rendered
10011/// its quote card with blank fields, because the MCP call had silently done
10012/// nothing.
10013///
10014/// Only the status changes. `bound` is passed through untouched, so routing,
10015/// `node.<id>.payload`, and any flow reading the bound value behave exactly as
10016/// before; the node is simply no longer claiming success. `meta.error` uses the
10017/// shape the lift reads (`kind` + `message`).
10018fn mcp_output(bound: Value, result: &Value) -> NodeOutput {
10019    let Some(message) = result.get("error") else {
10020        return NodeOutput::new(bound);
10021    };
10022    let message = message
10023        .as_str()
10024        .map(str::to_string)
10025        .unwrap_or_else(|| message.to_string());
10026    NodeOutput {
10027        ok: false,
10028        payload: bound,
10029        meta: json!({
10030            "error": {
10031                "kind": "mcp_call_failed",
10032                "message": message,
10033            }
10034        }),
10035    }
10036}
10037
10038fn lift_first_node_error_from_nodes(output: Value, nodes: &HashMap<String, NodeOutput>) -> Value {
10039    let Some((node_id, failed)) = nodes.iter().find(|(_, out)| !out.ok) else {
10040        return output;
10041    };
10042    let err_meta = failed.meta.get("error");
10043    let message = err_meta
10044        .and_then(|e| e.get("message"))
10045        .and_then(|v| v.as_str())
10046        .unwrap_or("flow node failed");
10047    let kind = err_meta
10048        .and_then(|e| e.get("kind"))
10049        .and_then(|v| v.as_str())
10050        .unwrap_or("flow_node_failed");
10051
10052    let mut output = match output {
10053        Value::Object(map) => map,
10054        Value::Null => JsonMap::new(),
10055        other => {
10056            let mut wrap = JsonMap::new();
10057            wrap.insert("payload".to_string(), other);
10058            wrap
10059        }
10060    };
10061    let metadata_entry = output
10062        .entry("metadata".to_string())
10063        .or_insert_with(|| Value::Object(JsonMap::new()));
10064    let metadata_map = match metadata_entry {
10065        Value::Object(map) => map,
10066        _ => {
10067            *metadata_entry = Value::Object(JsonMap::new());
10068            metadata_entry.as_object_mut().unwrap()
10069        }
10070    };
10071    metadata_map
10072        .entry("error_kind".to_string())
10073        .or_insert(Value::String(kind.to_string()));
10074    metadata_map
10075        .entry("error_message".to_string())
10076        .or_insert(Value::String(message.to_string()));
10077    metadata_map
10078        .entry("node_id".to_string())
10079        .or_insert(Value::String(node_id.clone()));
10080    Value::Object(output)
10081}
10082
10083fn should_retry(err: &anyhow::Error) -> bool {
10084    let lower = err.to_string().to_lowercase();
10085    lower.contains("transient")
10086        || lower.contains("unavailable")
10087        || lower.contains("internal")
10088        || lower.contains("timeout")
10089}
10090
10091impl From<FlowRetryConfig> for RetryConfig {
10092    fn from(value: FlowRetryConfig) -> Self {
10093        Self {
10094            max_attempts: value.max_attempts.max(1),
10095            base_delay_ms: value.base_delay_ms.max(50),
10096        }
10097    }
10098}